This commit is contained in:
payacom
2026-09-03 16:04:52 +03:30
parent 76ef4e32a9
commit bf41ad4e0f
65 changed files with 1248 additions and 999 deletions

View File

@@ -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<TopHashtag[]> {
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 [];
}
}

View File

@@ -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 (
<LocalePageShell>
<Container>
<BackButton />
<h6 className="font-bold md:text-xl text-lg text-center mt-2 line-clamp-2 mb-4 text-[#FF0000] ">
{t("academy.payment.failed")}
</h6>
<PageTitle>{t("academy.payment.failed")}</PageTitle>
<div className="flex flex-col items-center text-sm">
<Image
width={50}

View File

@@ -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 { useSearchParams } from "next/navigation";
import Image from "next/image";
import Link from "next/link";
@@ -70,10 +70,7 @@ function SuccessProject() {
return (
<LocalePageShell>
<Container>
<BackButton />
<h6 className="font-bold md:text-xl text-lg text-center mt-2 line-clamp-2 mb-4 text-[#17A600] ">
{t("academy.payment.success")}
</h6>
<PageTitle>{t("academy.payment.success")}</PageTitle>
<div className="flex flex-col items-center text-sm">
<Image
width={50}

View File

@@ -8,7 +8,7 @@ import MainProjectCard from "@/components/projects/MainProjectCard";
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 { useTranslation } from "react-i18next";
import toast from "react-hot-toast";
import { AxiosError } from "axios";
@@ -118,10 +118,7 @@ function ProjectPaymentPage({ params }: ProjectPaymentProps) {
return (
<LocalePageShell>
<Container>
<BackButton />
<h6 className="mb-4 mt-2 line-clamp-2 text-center text-lg font-bold text-text-blue-light dark:text-text-blue-dark md:text-xl">
{t("projects.payment.title")}
</h6>
<PageTitle>{t("projects.payment.title")}</PageTitle>
<div className="mt-10 w-full px-4 text-sm font-semibold md:text-base">
{project && <MainProjectCard project={project} />}
<div className="mt-4 flex flex-col items-center gap-4">

View File

@@ -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 (
<LocalePageShell>
<Container>
<BackButton />
<h6 className="mb-4 mt-2 line-clamp-2 text-center text-lg font-bold text-[#FF0000] md:text-xl">
{t("projects.payment.failed")}
</h6>
<PageTitle>{t("projects.payment.failed")}</PageTitle>
<div className="flex flex-col items-center text-sm">
<Image
width={50}

View File

@@ -12,7 +12,7 @@ import Link from "next/link";
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";
function SuccessProject() {
const { t } = useTranslation("common");
@@ -104,10 +104,7 @@ function SuccessProject() {
return (
<LocalePageShell>
<Container>
<BackButton />
<h6 className="mb-4 mt-2 line-clamp-2 text-center text-lg font-bold text-[#17A600] md:text-xl">
{t("projects.payment.success")}
</h6>
<PageTitle>{t("projects.payment.success")}</PageTitle>
<div className="flex flex-col items-center text-sm">
<Image
width={50}

View File

@@ -7,7 +7,7 @@ import { useRouter } from "next/navigation";
import { AnimatePresence, motion, PanInfo } from "framer-motion";
import FilterModal from "@/components/models/FilterModal";
import BoldIcon from "@/components/ui/BoldIcon";
import BackButton from "@/components/ui/BackButton";
import PageTitle from "@/components/settings/PageTitle";
import Container from "@/components/elements/Container";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import SpecialistCarousel from "@/components/projects/SpecialistCarousel";
@@ -329,13 +329,9 @@ export default function SpecialistsPage() {
return (
<LocalePageShell>
<Container className="pb-28">
<div className="mb-4 flex items-center justify-between px-1 pt-2">
<BackButton href="/projects" />
<h1 className="text-base font-bold">
{t("projects.specialists.title")}
</h1>
<span className="w-9" />
</div>
<PageTitle backHref="/projects">
{t("projects.specialists.title")}
</PageTitle>
{phase === "filter" || showFilter ? (
<FilterModal

View File

@@ -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 } from "next/navigation";
import { useTranslation } from "react-i18next";
@@ -85,10 +85,7 @@ function BillboardPayment({ params }: IBillboardProps) {
return (
<LocalePageShell>
<Container>
<BackButton />
<h6 className="font-bold md:text-xl text-lg text-center mt-2 line-clamp-2 mb-4 text-text-blue-light dark:text-text-blue-dark">
{t("billboards.payment.title")}
</h6>
<PageTitle>{t("billboards.payment.title")}</PageTitle>
<div className="px-4 w-full text-sm md:text-md font-semibold mt-10">
{advertising && <MainBillboardCard billboard={advertising} />}
<div className="flex flex-col items-center gap-4 mt-4">

View File

@@ -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 (
<LocalePageShell>
<Container>
<BackButton />
<h6 className="font-bold md:text-xl text-lg text-center mt-2 line-clamp-2 mb-4 text-[#FF0000] ">
{t("billboards.payment.failed")}
</h6>
<PageTitle>{t("billboards.payment.failed")}</PageTitle>
<div className="flex flex-col items-center text-sm">
<Image
width={50}

View File

@@ -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 { useSearchParams } from "next/navigation";
import Image from "next/image";
@@ -41,10 +41,7 @@ function SuccessBillboard() {
return (
<LocalePageShell>
<Container>
<BackButton />
<h6 className="font-bold md:text-xl text-lg text-center mt-2 line-clamp-2 mb-4 text-[#17A600] ">
{t("billboards.payment.success")}
</h6>
<PageTitle>{t("billboards.payment.success")}</PageTitle>
<div className="flex flex-col items-center text-sm">
<Image
width={50}

View File

@@ -69,12 +69,19 @@ export default function ExplorePage() {
return;
}
// اگه به این‌جا رسیدیم یعنی filter از قبل "near_me" نبوده (اون حالت بالاتر
// return شده)، پس coords همیشه باید پاک بشه
setActiveFilter(filter);
if (filter !== "near_me") {
setCoords(null);
}
setCoords(null);
}, [t]);
const handleSelectTopHashtag = useCallback((tag: string) => {
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}
/>
</Container>
</LocalePageShell>

View File

@@ -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 (
<LocalePageShell>
<Container>
<BackButton />
<h6 className="mb-10 mt-20 line-clamp-2 text-center text-lg font-bold text-[#FF0000] md:text-xl">
{t("giftPage.paymentFailed.title")}
</h6>
<PageTitle>{t("giftPage.paymentFailed.title")}</PageTitle>
<div className="flex flex-col items-center text-sm">
<Image
width={50}

View File

@@ -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";
@@ -18,10 +18,7 @@ function GiftPaymentSuccess() {
return (
<LocalePageShell>
<Container>
<BackButton />
<h6 className="mb-4 mt-10 line-clamp-2 text-center text-lg font-bold text-[#17A600] md:text-xl">
{t("giftPage.paymentSuccess.title")}
</h6>
<PageTitle>{t("giftPage.paymentSuccess.title")}</PageTitle>
<div className="flex flex-col items-center text-sm">
<Image
width={50}

View File

@@ -3,7 +3,7 @@
"use client";
import useAxios from "@/hooks/useAxios";
import { optimizeImageToWebP, optimizeVideoToMp4 } from "@/lib/media";
import { optimizeImageToWebP, optimizeVideoToMp4, isImageAspectRatioAllowed } from "@/lib/media";
import { createStoryBase64, StoryOverlay } from "@/api/fetchStories";
import StoryEditor from "@/components/stories/StoryEditor";
import TagUsersPicker, { TaggedUser } from "@/components/posts/TagUsersPicker";
@@ -37,7 +37,7 @@ import { fetchPostById } from "@/api/fetchPostById";
import CountryProvinceCitySelect, {
type GeoSelection,
} from "@/components/ui/CountryProvinceCitySelect";
import BackButton from "@/components/ui/BackButton";
import PageTitle from "@/components/settings/PageTitle";
import { useDefaultCountryId } from "@/hooks/useDefaultCountryId";
import Cookies from "js-cookie";
import { useRouter, useSearchParams } from "next/navigation";
@@ -207,8 +207,9 @@ export default function NewPostPage() {
return "image/*";
}, [mode]);
const onPickMain = (e: React.ChangeEvent<HTMLInputElement>) => {
const onPickMain = async (e: React.ChangeEvent<HTMLInputElement>) => {
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() {
<LocalePageShell>
<div className="mx-auto flex min-h-[calc(100dvh-8rem)] max-w-lg flex-col bg-background">
{/* Header — Instagram style */}
<header className="sticky top-0 z-10 flex items-center justify-between border-b border-neutral-200 px-4 py-3 dark:border-neutral-800">
<BackButton />
<span className="text-base font-semibold">
<header className="sticky top-0 z-10 border-b border-neutral-200 px-4 dark:border-neutral-800">
<PageTitle
action={
<button
type="button"
onClick={handleShareClick}
disabled={loading || loadingEdit}
className="text-sm font-semibold text-[#0095f6] disabled:opacity-40"
>
{loading || loadingEdit ? "…" : t("posts.shareButton")}
</button>
}
>
{isEditMode
? t("posts.editPostTitle")
: isStory
? t("posts.newStory")
: t("posts.newPost")}
</span>
<button
type="button"
onClick={handleShareClick}
disabled={loading || loadingEdit}
className="text-sm font-semibold text-[#0095f6] disabled:opacity-40"
>
{loading || loadingEdit ? "…" : t("posts.shareButton")}
</button>
</PageTitle>
</header>
{/* Mode tabs */}

View File

@@ -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 (
<LocalePageShell>
<Container>
<BackButton />
<h6 className="font-bold md:text-xl text-lg text-center mt-20 line-clamp-2 mb-10 text-[#FF0000] ">
{t("offerPage.paymentFailed.title")}
</h6>
<PageTitle>{t("offerPage.paymentFailed.title")}</PageTitle>
<div className="flex flex-col items-center text-sm">
<Image
width={50}

View File

@@ -6,7 +6,7 @@ import useAxios from "@/hooks/useAxios";
import React, { useEffect, useState } 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";
@@ -33,10 +33,7 @@ function SuccessBillboard() {
return (
<LocalePageShell>
<Container>
<BackButton />
<h6 className="font-bold md:text-xl text-lg text-center mt-10 line-clamp-2 mb-4 text-[#17A600] ">
{t("offerPage.paymentSuccess.title")}
</h6>
<PageTitle>{t("offerPage.paymentSuccess.title")}</PageTitle>
<div className="flex flex-col items-center text-sm">
<Image
width={50}

View File

@@ -8,7 +8,7 @@ import * as z from "zod";
import { motion } from "framer-motion";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import BackButton from "@/components/ui/BackButton";
import PageTitle from "@/components/settings/PageTitle";
import {
Form,
@@ -262,10 +262,7 @@ export default function StoreSettings() {
className="w-full max-w-4xl mx-auto px-4 pt-6 lg:px-6 pb-[calc(6rem+env(safe-area-inset-bottom))]"
dir={t("dir") || "rtl"}
>
<BackButton className="mb-4" />
<h2 className="text-3xl font-bold bg-gradient-to-r from-primary to-primary/60 bg-clip-text text-transparent mb-6">
{t("settings.academy.storeProfile.title")}
</h2>
<PageTitle>{t("settings.academy.storeProfile.title")}</PageTitle>
<Form {...form}>
<form
dir="rtl"

View File

@@ -7,10 +7,9 @@ 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 useAxios from "@/hooks/useAxios";
import { usePageTitle } from "@/hooks/usePageTitle";
import { useTranslation } from "react-i18next";
import { formatFullName } from "@/lib/formatFullName";
import toast from "react-hot-toast";
@@ -74,7 +73,6 @@ function filtersToParams(filters: PartnerFilters): Record<string, string> {
export default function FindPartnerPage() {
const { t } = useTranslation("common");
usePageTitle(t("findPartner.title"));
const { request } = useAxios();
const [filters, setFilters] = useState<PartnerFilters>(EMPTY_PARTNER_FILTERS);
@@ -159,10 +157,7 @@ export default function FindPartnerPage() {
return (
<LocalePageShell>
<Container className="pb-28">
<div className="mb-4 flex items-center gap-3 pt-2">
<BackButton href="/settings/chats" className="bg-neutral-100 dark:bg-neutral-800" />
<h1 className="text-base font-bold md:text-lg">{t("findPartner.title")}</h1>
</div>
<PageTitle backHref="/settings/chats">{t("findPartner.title")}</PageTitle>
<p className="mb-4 text-center text-xs text-neutral-500">{t("findPartner.hint")}</p>

View File

@@ -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<number>(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 (
<LocalePageShell>
<Container className="pb-28">
<div className="mb-4 flex items-center gap-3 pt-2">
<BackButton
href="/settings/chats"
className="bg-neutral-100 dark:bg-neutral-800"
/>
<h1 className="text-base font-bold md:text-lg">
{t("nearbyFriends.title")}
</h1>
</div>
<PageTitle backHref="/settings/chats">
{t("nearbyFriends.title")}
</PageTitle>
<div className="mb-6 flex flex-col items-center">
<div
@@ -187,8 +230,8 @@ export default function NearbyFriendsPage() {
<div className="pointer-events-none absolute left-0 top-1/2 h-px w-full -translate-y-1/2 bg-[#39ff14]/25" />
{/* 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 (
<div key={meters}>
@@ -202,7 +245,7 @@ export default function NearbyFriendsPage() {
top: `calc(${inset} - 1px)`,
}}
>
{meters}
{Math.round(meters / 1000)} کیلومتر
</span>
</div>
);
@@ -249,7 +292,8 @@ export default function NearbyFriendsPage() {
const pos = radarPosition(
index,
radarUsers.length,
item.distanceMeters
item.distanceMeters,
radiusMeters
);
return (
<Link
@@ -283,8 +327,30 @@ export default function NearbyFriendsPage() {
: null}
</div>
<p className="mt-3 text-center text-xs text-neutral-500">
{t("nearbyFriends.radiusHint")}
{t("nearbyFriends.radiusHintValue", { km: radiusKm })}
</p>
{/* عین NearbyFriendsScreen.tsx اندروید: ردیف افقی چیپ‌های شعاع، از ۵ تا ۵۰
کیلومتر با فواصل ۵ کیلومتری */}
<div
dir="rtl"
className="mt-2 flex w-full max-w-[340px] gap-2 overflow-x-auto px-1 pb-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
{RADIUS_OPTIONS_KM.map((km) => (
<button
key={km}
type="button"
onClick={() => handleRadiusChange(km)}
className={`shrink-0 rounded-2xl border px-3 py-1.5 text-xs font-medium transition-colors ${
km === radiusKm
? "border-[#FF107D] bg-[#FF107D] text-white"
: "border-[#C3C3C3] bg-white/90 text-foreground dark:bg-neutral-900/90"
}`}
>
{km}
</button>
))}
</div>
</div>
{gate === "checking" || gate === "locating" ? (
@@ -391,15 +457,109 @@ export default function NearbyFriendsPage() {
))
)}
<RoundedButton
className="!border-transparent mx-auto mt-4 h-9 w-full max-w-none !bg-neutral-100 text-neutral-600 dark:!bg-neutral-800 dark:text-neutral-300"
onClick={requestBrowserLocation}
>
{t("nearbyFriends.refresh")}
</RoundedButton>
{/* عین footerRow اندروید: بروزرسانی (primary) و نمایش نقشه (default) کنار
هم، هر دو flex-1 */}
<div className="mt-3 flex gap-2.5">
<RoundedButton
variant="primary"
className="h-11 w-full max-w-none"
onClick={requestBrowserLocation}
>
{t("nearbyFriends.refresh")}
</RoundedButton>
{coords ? (
<RoundedButton
className="h-11 w-full max-w-none"
onClick={() => setShowMap(true)}
>
{t("nearbyFriends.showOnMap")}
</RoundedButton>
) : null}
</div>
</div>
) : null}
</Container>
{showMap && coords ? (
<div className="fixed inset-0 z-[200] bg-black">
<button
type="button"
onClick={() => setShowMap(false)}
className="absolute right-4 top-[calc(1rem+env(safe-area-inset-top))] z-10 flex h-10 w-10 items-center justify-center rounded-full bg-black/50 text-white backdrop-blur-md"
aria-label={t("common.close")}
>
<BoldIcon name="close-circle" size={22} tinted />
</button>
<Map
initialViewState={{
longitude: coords.lng,
latitude: coords.lat,
zoom: 13,
}}
mapboxAccessToken={MAPBOX_ACCESS_TOKEN}
mapStyle="mapbox://styles/mapbox/streets-v11"
style={{ width: "100%", height: "100%" }}
>
<Marker longitude={coords.lng} latitude={coords.lat}>
<div className="flex flex-col items-center">
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-black ring-2 ring-[#39ff14] shadow-[0_0_14px_rgba(57,255,20,0.65)]">
<ProfileAvatar
src={user?.profile_image}
alt={user?.user_name || t("nearbyFriends.you")}
size="sm"
rounded="full"
className="!h-9 !w-9"
/>
</div>
<span className="mt-0.5 whitespace-nowrap rounded bg-black/60 px-1.5 py-0.5 text-[10px] font-semibold text-[#39ff14]">
{t("nearbyFriends.you")}
</span>
</div>
</Marker>
{users.map((item, index) => {
const [lng, lat] = approximateCoordinate(
coords.lat,
coords.lng,
item.distanceMeters,
index,
users.length
);
return (
<Marker key={item._id} longitude={lng} latitude={lat}>
<Link
href={`/offer/${item._id}`}
className="flex flex-col items-center"
title={item.user_name}
>
<div className="relative rounded-full ring-1 ring-[#39ff14]/80">
<ProfileAvatar
src={item.profile_image}
alt={item.user_name}
size="sm"
rounded="full"
className="!h-9 !w-9"
/>
</div>
<span
className="mt-0.5 max-w-[72px] truncate rounded bg-black/60 px-1 py-0.5 text-[9px] font-medium text-[#9dff8a]"
dir="ltr"
>
{item.user_name}
</span>
</Link>
</Marker>
);
})}
</Map>
<p className="pointer-events-none absolute inset-x-0 bottom-[calc(1rem+env(safe-area-inset-bottom))] px-6 text-center text-[11px] text-white/80">
{t("nearbyFriends.mapPrivacyHint")}
</p>
</div>
) : null}
</LocalePageShell>
);
}

View File

@@ -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<RoomDetail | null>(null);
const [messages, setMessages] = useState<RoomMessage[]>([]);
// این صفحه با تغییرِ فقط پارامترِ 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<RoomMessage | null>(null);
const [actionMode, setActionMode] = useState(false);
const [showAddMembers, setShowAddMembers] = useState(false);
const [showMembersModal, setShowMembersModal] = useState(false);
const [pendingImages, setPendingImages] = useState<File[]>([]);
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 (
<LocalePageShell>
<Container className="chat-page-bg flex h-[100dvh] max-h-[100dvh] flex-col overflow-hidden !px-0">
{/* هدر شناور شیشه‌ای — عین ChatRoomThreadScreen.tsx اندروید: خودِ نوارِ هدر
پس‌زمینه نداره، فقط سه‌تا پیل/دکمه‌ی جدا (بازگشت، مرکزی، اعضا) هر کدوم
پس‌زمینه‌ی شیشه‌ایِ خودشون رو دارن. لیستِ اعضا دیگه به‌صورت نوارِ افقیِ
زیرِ هدر نیست — با دکمه‌ی اعضا توی یه مودال باز می‌شه */}
<header
ref={headerRef}
className="fixed left-0 right-0 top-0 z-40 border-b border-white/10 bg-black/20 px-3 pb-3 pt-[calc(0.75rem+env(safe-area-inset-top))] backdrop-blur-md"
className="fixed left-0 right-0 top-0 z-40 px-3 pb-2 pt-[calc(0.5rem+env(safe-area-inset-top))]"
>
<div className="mx-auto flex w-full max-w-lg items-center gap-3">
<div className="mx-auto flex w-full max-w-lg items-center gap-2">
<BackButton
href="/settings/chats/rooms"
tinted
className="bg-white/10 text-white"
className="!h-11 !w-11 shrink-0 border border-white/70 bg-white/60 text-foreground shadow-md backdrop-blur-md dark:border-white/20 dark:bg-neutral-800/90"
/>
<div className="flex min-w-0 flex-1 items-center justify-center gap-3">
<div className="flex h-11 min-w-0 flex-1 items-center gap-2 rounded-full border border-white/70 bg-white/60 px-2 pl-1.5 shadow-md backdrop-blur-md dark:border-white/20 dark:bg-neutral-800/90">
<ProfileAvatar
src={room?.image}
alt={room?.title ?? t("chats.chatRoom")}
size="sm"
rounded="full"
className="h-10 w-10 shrink-0"
className="h-[34px] w-[34px] shrink-0"
/>
<div className="min-w-0 max-w-[65%]">
<div className="flex items-center justify-between gap-2">
<h1 className="truncate text-base font-bold text-white">
{room?.title ?? t("chats.chatRoom")}
</h1>
{room?.onlineCount != null ? (
<span className="shrink-0 text-[10px] text-[#22c55e]">
{t("chats.onlineCount", { count: room.onlineCount })}
</span>
) : null}
</div>
{room?.description ? (
<p className="truncate text-center text-[11px] text-white/70">
{room.description}
</p>
) : null}
<div className="min-w-0 flex-1 text-center">
<h1 className="truncate text-[15px] font-bold text-foreground">
{room?.title ?? t("chats.chatRoom")}
</h1>
<p className="truncate text-xs text-foreground/60">
{t("chats.groupStatusLine", { count: room?.onlineCount ?? 0 })}
</p>
</div>
</div>
{canAddMembers ? (
<button
type="button"
onClick={() => setShowAddMembers(true)}
className="gentle-transition flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-white/10 active:scale-95"
aria-label={t("chats.aria.addUser")}
>
<BoldIcon name="user-add" size={20} tinted className="text-white" />
</button>
) : (
<span className="h-9 w-9 shrink-0" />
)}
<button
type="button"
onClick={() => setShowMembersModal(true)}
aria-label={t("chats.modal.membersTitle")}
className="gentle-transition flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-white/70 bg-white/60 shadow-md backdrop-blur-md active:scale-95 dark:border-white/20 dark:bg-neutral-800/90"
>
<BoldIcon name="profile-2user" size={20} tinted className="text-foreground" />
</button>
</div>
{room?.members?.length ? (
<div className="mx-auto mt-3 flex w-full max-w-lg gap-2 overflow-x-auto pb-1">
{room.members.slice(0, 12).map((member) => (
<Link
key={member._id}
href={`/users/${member.user_name}`}
className="flex shrink-0 flex-col items-center gap-1"
title={member.user_name}
>
<ProfileAvatar
src={member.profile_image}
alt={member.user_name || ""}
size="sm"
rounded="full"
className="h-10 w-10"
/>
<span className="max-w-[56px] truncate text-[10px] text-white/80">
{member.user_name}
</span>
</Link>
))}
</div>
) : null}
</header>
<ChatRoomMembersModal
open={showMembersModal}
onClose={() => setShowMembersModal(false)}
members={room?.members ?? []}
canAddMembers={canAddMembers}
onAddMembers={() => setShowAddMembers(true)}
/>
<div
ref={scrollRef}
dir="rtl"

View File

@@ -9,8 +9,7 @@ import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import IOSSpinner from "@/components/ui/IOSSpinner";
import BoldIcon from "@/components/ui/BoldIcon";
import BackButton from "@/components/ui/BackButton";
import { usePageTitle } from "@/hooks/usePageTitle";
import PageTitle from "@/components/settings/PageTitle";
import {
acquireChatSocket,
joinGroupRoom,
@@ -37,7 +36,6 @@ type ChatRoomItem = {
export default function ChatRoomsPage() {
const { t } = useTranslation("common");
usePageTitle(t("chats.chatRoom"));
const { request } = useAxios();
const user = useUser();
const [rooms, setRooms] = useState<ChatRoomItem[]>([]);
@@ -87,20 +85,20 @@ export default function ChatRoomsPage() {
return (
<LocalePageShell>
<Container>
<div className="grid grid-cols-[2.5rem_1fr_2.5rem] items-center gap-3 px-4 py-7">
<BackButton className="justify-self-start" />
<h1 className="truncate text-center text-2xl font-bold text-foreground">
{t("chats.chatRoom")}
</h1>
<button
type="button"
onClick={() => setShowCreateModal(true)}
className="gentle-transition flex h-9 w-9 shrink-0 items-center justify-center justify-self-end rounded-full bg-[#0095f6] text-white shadow-md active:scale-95"
aria-label={t("chats.aria.createRoom")}
>
<BoldIcon name="add" size={20} tinted className="text-white" />
</button>
</div>
<PageTitle
action={
<button
type="button"
onClick={() => 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")}
>
<BoldIcon name="add" size={20} tinted className="text-white" />
</button>
}
>
{t("chats.chatRoom")}
</PageTitle>
<div className="px-4 text-xs md:text-sm">
{loading ? (

View File

@@ -66,8 +66,15 @@ export default function ShopChatPage() {
const [sending, setSending] = useState(false);
const bottomRef = useRef<HTMLDivElement>(null);
const attachmentInputRef = useRef<HTMLInputElement>(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);

View File

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

View File

@@ -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 (
<Container>
<BackButton />
<a
href={`/billboards/profile/${billboard?.creatorId}/${billboard?.title}`}
>
<h1 className="font-bold md:text-xl text-lg text-center mt-10 line-clamp-2 mb-4 text-text-blue-light dark:text-text-blue-dark">
<PageTitle>
<a
href={`/billboards/profile/${billboard?.creatorId}/${billboard?.title}`}
>
{billboard?.title}
</h1>
</a>
</a>
</PageTitle>
<div className="px-4 w-full text-sm md:text-md font-semibold">
<div className="flex justify-between">
<div className="flex gap-4">

View File

@@ -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] || "#";

View File

@@ -363,25 +363,27 @@ export default function ProductManagePage() {
<span className="flex-1 rounded-3xl bg-green-600 py-2.5 text-center text-sm font-bold text-white">
{t("shops.manageStockButton")}
</span>
<button
type="button"
disabled={!matchedVariant || savingStock}
onClick={() => adjustStock(-1)}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-green-600 text-lg font-bold text-white disabled:opacity-50"
>
−
</button>
<span className="w-8 shrink-0 text-center text-sm font-bold">
{matchedVariant?.stock ?? 0}
</span>
<button
type="button"
disabled={!matchedVariant || savingStock}
onClick={() => adjustStock(1)}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-green-600 text-lg font-bold text-white disabled:opacity-50"
>
+
</button>
<div className="flex h-12 shrink-0 items-center gap-2.5 rounded-2xl border border-neutral-200 px-2.5 dark:border-neutral-700">
<button
type="button"
disabled={!matchedVariant || savingStock}
onClick={() => adjustStock(-1)}
className="flex h-6 w-6 items-center justify-center text-xl font-bold text-foreground disabled:opacity-50"
>
−
</button>
<span className="w-4 shrink-0 text-center text-sm font-bold text-foreground">
{matchedVariant?.stock ?? 0}
</span>
<button
type="button"
disabled={!matchedVariant || savingStock}
onClick={() => adjustStock(1)}
className="flex h-6 w-6 items-center justify-center text-xl font-bold text-foreground disabled:opacity-50"
>
+
</button>
</div>
</div>
{matchedVariant && (

View File

@@ -432,34 +432,38 @@ export default function ListingDetailClient() {
</SpecRow>
)}
{/* عین ProductDetailScreen.tsx اندروید: دکمه‌ی ثبت خرید با colors.success (#387E65)،
و اسپینر تعداد یک پیل بوردردار خنثی با دکمه‌های +/- متنی، نه دو دکمه‌ی دایره‌ای رنگی */}
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => void handleBuy()}
disabled={loading || !matchedVariant}
className="flex-1 rounded-3xl bg-green-600 py-2.5 text-center text-sm font-bold text-white disabled:opacity-50"
className="flex-1 rounded-2xl bg-[#387E65] py-2.5 text-center text-sm font-bold text-white disabled:opacity-50"
>
{t("shops.placeOrderButton")}
</button>
<button
type="button"
onClick={() => setQuantity((q) => Math.max(1, q - 1))}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-green-600 text-lg font-bold text-white"
>
−
</button>
<span className="w-8 shrink-0 text-center text-sm font-bold">{quantity}</span>
<button
type="button"
onClick={() =>
setQuantity((q) =>
matchedVariant ? Math.min(matchedVariant.stock, q + 1) : q + 1
)
}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-green-600 text-lg font-bold text-white"
>
+
</button>
<div className="flex h-12 shrink-0 items-center gap-2.5 rounded-2xl border border-neutral-200 px-2.5 dark:border-neutral-700">
<button
type="button"
onClick={() => setQuantity((q) => Math.max(1, q - 1))}
className="flex h-6 w-6 items-center justify-center text-xl font-bold text-foreground"
>
−
</button>
<span className="w-4 shrink-0 text-center text-sm font-bold text-foreground">{quantity}</span>
<button
type="button"
onClick={() =>
setQuantity((q) =>
matchedVariant ? Math.min(matchedVariant.stock, q + 1) : q + 1
)
}
className="flex h-6 w-6 items-center justify-center text-xl font-bold text-foreground"
>
+
</button>
</div>
</div>
{matchedVariant && (
@@ -483,17 +487,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) توپر،
علاقه‌مندی فقط حاشیه‌دارِ همون رنگ (نه پرشده) */}
<button
type="button"
onClick={handleAddToCart}
className="flex-1 rounded-3xl bg-green-600 py-3 text-sm font-bold text-white"
className="flex-1 rounded-2xl bg-[#17337B] py-3 text-sm font-bold text-white"
>
{t("shops.addToCartButton")}
</button>
<button
type="button"
onClick={handleToggleFavorite}
className="flex-1 rounded-3xl bg-sky-500 py-3 text-sm font-bold text-white"
className="flex-1 rounded-2xl border border-[#17337B] py-3 text-sm font-bold text-[#17337B]"
>
{favorite ? t("shops.removeFromFavoritesButton") : t("shops.addToFavoritesButton")}
</button>

View File

@@ -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<M
listing?.shop && typeof listing.shop === "object" ? listing.shop : null;
if (!listing) {
const notFoundTitle = `${translateCommon(lang, "shops.productNotFoundTitle")} | ${site.titleSuffix}`;
const meta = generatePageMetadata({
title: `کالا یافت نشد | ${site.titleSuffix}`,
title: notFoundTitle,
description: site.defaultDescription,
path,
index: false,
lang,
});
return { ...meta, title: { absolute: `کالا یافت نشد | ${site.titleSuffix}` } };
return { ...meta, title: { absolute: notFoundTitle } };
}
const title = `${listing.title}${shop?.name ? ` - در فروشگاه (${shop.name})` : ""} | ${site.titleSuffix}`;
@@ -106,7 +109,7 @@ export default async function ListingDetailPage({ params }: IListingPageProps) {
{jsonLd && (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
dangerouslySetInnerHTML={{ __html: safeJsonLdString(jsonLd) }}
/>
)}
<ListingDetailClient />

View File

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

View File

@@ -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 (
<LocalePageShell>
<Container>
<BackButton />
<h6 className="mb-4 mt-2 text-center text-lg font-bold text-red-600 md:text-xl">
{t("shops.paymentFailedTitle")}
</h6>
<PageTitle>{t("shops.paymentFailedTitle")}</PageTitle>
<div className="flex flex-col items-center text-sm">
<Image
width={50}

View File

@@ -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 { useRouter, useSearchParams } from "next/navigation";
import Image from "next/image";
import { useTranslation } from "react-i18next";
@@ -17,10 +17,7 @@ export default function ShopPaymentSuccessPage() {
return (
<LocalePageShell>
<Container>
<BackButton />
<h6 className="mb-4 mt-2 text-center text-lg font-bold text-green-600 md:text-xl">
{t("shops.paymentSuccessTitle")}
</h6>
<PageTitle>{t("shops.paymentSuccessTitle")}</PageTitle>
<div className="flex flex-col items-center text-sm">
<Image
width={50}

View File

@@ -6,7 +6,9 @@ import { getServerLanguage } from "@/lib/i18n/server";
import { getSiteSeoMeta } from "@/lib/i18n/seo";
import { buildStorageUrl } from "@/components/main/BaseUrl";
import { buildShopJsonLd } from "@/lib/buildShopSeo";
import { safeJsonLdString } from "@/lib/seo/safeJsonLd";
import { SITE_URL } from "@/config/pageSeo";
import { translateCommon } from "@/lib/i18n/translate";
import ShopProfileClient from "./ShopProfileClient";
interface IShopProfilePageProps {
@@ -41,7 +43,7 @@ export async function generateMetadata({ params }: IShopProfilePageProps): Promi
const shop = await loadShop(shopId);
if (!shop) {
const title = `فروشگاه یافت نشد | ${site.titleSuffix}`;
const title = `${translateCommon(lang, "shops.shopNotFoundTitle")} | ${site.titleSuffix}`;
const meta = generatePageMetadata({
title,
description: site.defaultDescription,
@@ -60,7 +62,9 @@ export async function generateMetadata({ params }: IShopProfilePageProps): Promi
[
shop.description,
shop.address,
shop.has_physical_location ? "حضوری" : "اینترنتی",
shop.has_physical_location
? translateCommon(lang, "shops.inPerson")
: translateCommon(lang, "shops.online"),
]
.filter(Boolean)
.join(" - ") || site.defaultDescription;
@@ -96,7 +100,7 @@ export default async function ShopProfilePage({ params }: IShopProfilePageProps)
{jsonLd && (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
dangerouslySetInnerHTML={{ __html: safeJsonLdString(jsonLd) }}
/>
)}
<ShopProfileClient />

View File

@@ -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) {
<Container>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
dangerouslySetInnerHTML={{ __html: safeJsonLdString(jsonLd) }}
/>
<LocalePageShell>

View File

@@ -5,7 +5,7 @@ import BillboardImageSlider from "@/components/billboards/BillboardPage/Billboar
import BillboardDetails from "@/components/billboards/BillboardPage/BillboardDetails";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import MainBillboardCardActions from "@/components/billboards/MainBillboardCard/MainBillboardCardActions";
import BackButton from "@/components/ui/BackButton";
import PageTitle from "@/components/settings/PageTitle";
import { IAdvertising, IRate } from "@/types/types";
import { trackExploreInteraction } from "@/api/trackExploreInteraction";
import Cookies from "js-cookie";
@@ -44,14 +44,13 @@ export default function BillboardDetailContent({
return (
<LocalePageShell>
<Container>
<BackButton />
<a
href={`/billboards/profile/${billboard?.creatorId}/${encodeURIComponent(billboard?.title || "")}`}
>
<h1 className="font-bold md:text-xl text-lg text-center mt-2 line-clamp-2 mb-4 text-text-blue-light dark:text-text-blue-dark">
<PageTitle>
<a
href={`/billboards/profile/${billboard?.creatorId}/${encodeURIComponent(billboard?.title || "")}`}
>
{billboard?.title}
</h1>
</a>
</a>
</PageTitle>
<div className="px-4 w-full text-sm md:text-md font-semibold">
<div className="flex justify-between">
<div className="flex gap-4">

View File

@@ -27,12 +27,14 @@ export default function ChatDeleteBar({
transition={{ duration: 0.28, ease: [0.25, 0.46, 0.45, 0.94] }}
className="pointer-events-none fixed bottom-0 left-0 right-0 z-[60] flex justify-center px-3 chat-input-area--thread"
>
<div className="pointer-events-auto mb-2 flex w-full max-w-lg gap-2">
{/* عین قانون تاییدشده‌ی اندروید: دکمه‌های کنشِ ته‌چسبیده width:160 وسط‌چین، نه flex
کشیده؛ گردی هم استاندارد rounded-2xl، نه پیل کاملاً گرد */}
<div className="pointer-events-auto mb-2 flex w-full max-w-lg justify-center gap-2.5">
<button
type="button"
onClick={onCancel}
disabled={loading}
className="glass-panel gentle-transition flex-1 rounded-full py-3 text-sm font-medium active:scale-[0.98] disabled:opacity-50"
className="glass-panel gentle-transition w-40 rounded-2xl py-3 text-sm font-medium active:scale-[0.98] disabled:opacity-50"
>
{t("chats.actions.cancel")}
</button>
@@ -40,7 +42,7 @@ export default function ChatDeleteBar({
type="button"
onClick={onDelete}
disabled={loading || selectedCount === 0}
className="gentle-transition flex flex-[2] items-center justify-center gap-2 rounded-full bg-red-500 py-3 text-sm font-semibold text-white active:scale-[0.98] disabled:opacity-50"
className="gentle-transition flex w-40 items-center justify-center gap-2 rounded-2xl bg-red-500 py-3 text-sm font-semibold text-white active:scale-[0.98] disabled:opacity-50"
>
<BoldIcon name="trash" size={18} tinted className="text-white" />
{selectedCount > 0

View File

@@ -0,0 +1,114 @@
"use client";
import React from "react";
import Link from "next/link";
import Modal from "@/components/elements/Modal";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import BoldIcon from "@/components/ui/BoldIcon";
import { useTranslation } from "react-i18next";
export type RoomMemberItem = {
_id: string;
user_name?: string;
first_name?: string;
last_name?: string;
profile_image?: string;
};
function displayName(u: RoomMemberItem, fallback: string): string {
return (
[u.first_name, u.last_name].filter(Boolean).join(" ") ||
u.user_name ||
fallback
);
}
type Props = {
open: boolean;
onClose: () => void;
members: RoomMemberItem[];
canAddMembers: boolean;
onAddMembers: () => void;
};
// عین ChatRoomMembersModal.tsx اندروید — لیستِ اعضا داخلِ یه مودالِ جدا، به‌جای
// نوار افقیِ آواتارها زیرِ هدر
export default function ChatRoomMembersModal({
open,
onClose,
members,
canAddMembers,
onAddMembers,
}: Props) {
const { t } = useTranslation("common");
return (
<Modal
isOpen={open}
onClose={onClose}
height="fit"
elevated
panelClassName="!p-0 max-h-[80dvh] w-full overflow-y-auto"
>
<div className="px-4 py-5 pb-[calc(1.5rem+env(safe-area-inset-bottom,0px))]">
<div className="mx-auto w-full max-w-sm">
<h2 className="mb-4 text-center font-semibold">
{t("chats.modal.membersTitle")}
</h2>
{canAddMembers ? (
<button
type="button"
onClick={() => {
onClose();
onAddMembers();
}}
className="mb-1.5 flex w-full items-center gap-2.5 border-b border-neutral-200 py-2.5 dark:border-neutral-800"
>
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#FF107D1A]">
<BoldIcon name="user-add" size={18} tinted className="text-[#FF107D]" />
</span>
<span className="text-sm font-medium text-[#FF107D]">
{t("chats.modal.addMemberRow")}
</span>
</button>
) : null}
<div className="max-h-[400px] overflow-y-auto">
{members.length === 0 ? (
<p className="my-3 text-center text-sm text-neutral-500">
{t("chats.modal.noMembers")}
</p>
) : (
members.map((item) => (
<Link
key={item._id}
href={item.user_name ? `/users/${item.user_name}` : "#"}
className="flex items-center gap-2.5 py-2"
>
<ProfileAvatar
src={item.profile_image}
alt={item.user_name || ""}
size="sm"
rounded="full"
className="h-10 w-10 shrink-0"
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">
{displayName(item, t("chats.modal.memberFallbackName"))}
</p>
{item.user_name ? (
<p className="truncate text-xs text-neutral-500">
@{item.user_name}
</p>
) : null}
</div>
</Link>
))
)}
</div>
</div>
</div>
</Modal>
);
}

View File

@@ -17,7 +17,8 @@ import { Course } from "@/types/types";
import { toast } from "react-hot-toast";
import useAxios from "@/hooks/useAxios";
import SubscriptionCard from "./SubscriptionCard";
import BackButton from "@/components/ui/BackButton";
import PageTitle from "@/components/settings/PageTitle";
import { isImageAspectRatioAllowed } from "@/lib/media";
import Cookies from "js-cookie";
import { useTranslation } from "react-i18next";
function appendCourseFormFields(formData: FormData, data: any) {
@@ -195,6 +196,12 @@ export function CourseManager() {
return;
}
if (!(await isImageAspectRatioAllowed(fileInput.files[0]))) {
toast.error(t("posts.aspectRatioTooExtreme"));
setIsLoading(false);
return;
}
const formData = new FormData();
formData.append("course_image", fileInput.files[0]);
appendCourseFormFields(formData, data);
@@ -235,6 +242,11 @@ export function CourseManager() {
// ========== اضافه کردن عکس (فقط در صورت وجود) ==========
if (fileInput && fileInput.files && fileInput.files[0]) {
if (!(await isImageAspectRatioAllowed(fileInput.files[0]))) {
toast.error(t("posts.aspectRatioTooExtreme"));
setIsLoading(false);
return;
}
formData.append("course_image", fileInput.files[0]);
}
// اگر عکس جدیدی انتخاب نشده، فیلد course_image رو ارسال نکن
@@ -455,19 +467,21 @@ export function CourseManager() {
return (
<div className="container mx-auto px-4 py-6 lg:px-6">
<BackButton className="mb-4" />
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold">{t("academyCourse.manageTitle")}</h1>
<Button
onClick={() => {
setSelectedCourse(undefined);
setIsDialogOpen(true);
}}
>
<Plus className="w-4 h-4 ml-2" />
{t("academyCourse.createPackage")}
</Button>
</div>
<PageTitle
action={
<Button
onClick={() => {
setSelectedCourse(undefined);
setIsDialogOpen(true);
}}
>
<Plus className="w-4 h-4 ml-2" />
{t("academyCourse.createPackage")}
</Button>
}
>
{t("academyCourse.manageTitle")}
</PageTitle>
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="grid w-full grid-cols-1">

View File

@@ -2,8 +2,9 @@
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { fetchPosts } from "@/api/fetchPosts";
import { fetchTopHashtags, TopHashtag } from "@/api/fetchTopHashtags";
import {
AcademyExploreItem,
fetchAcademyExplore,
@@ -66,8 +67,65 @@ type ExploreGridProps = {
activeFilter: ExploreFilterId;
coords?: { lat: number; lng: number } | null;
searchQuery?: { q?: string; hashtag?: string };
onSelectTopHashtag?: (tag: string) => void;
};
function TopHashtagsList({
onSelect,
}: {
onSelect?: (tag: string) => void;
}) {
const { t } = useTranslation("common");
const { data, isLoading } = useQuery({
queryKey: ["explore-top-hashtags"],
queryFn: () => fetchTopHashtags(10),
staleTime: 60_000,
});
if (isLoading) {
return (
<div className="flex flex-col gap-4 px-4 pt-2">
{Array.from({ length: 6 }).map((_, index) => (
<div key={index} className="h-3.5 w-1/2 animate-pulse rounded-full bg-neutral-200 dark:bg-neutral-800" />
))}
</div>
);
}
if (!data?.length) {
return (
<p className="py-16 text-center text-sm text-neutral-500">
{t("explore.empty.top_hashtags")}
</p>
);
}
return (
<div className="flex flex-col gap-1 px-4 pt-2">
{data.map((h: TopHashtag, index: number) => (
<button
key={h.tag}
type="button"
onClick={() => onSelect?.(h.tag)}
className="flex items-center gap-3 rounded-xl px-2 py-3 text-right hover:bg-neutral-100 dark:hover:bg-neutral-900"
>
<span className="w-5 shrink-0 text-sm font-bold text-neutral-400">
{index + 1}
</span>
<span className="flex-1 min-w-0">
<span className="block truncate font-semibold text-neutral-900 dark:text-white">
#{h.tag}
</span>
<span className="block text-xs text-neutral-500">
{formatViewCount(h.viewsCount, "fa-IR")} بازدید
</span>
</span>
</button>
))}
</div>
);
}
function hashAspect(postId: string): (typeof ASPECT_RATIOS)[number] {
let hash = 0;
for (let i = 0; i < postId.length; i++) {
@@ -224,7 +282,6 @@ function ExploreCard({
fill
className="object-cover"
sizes="50vw"
unoptimized
/>
)}
<span className="absolute left-2 top-2 flex items-center gap-1 rounded-md bg-black/50 px-1.5 py-0.5 text-[10px] font-semibold text-white backdrop-blur-sm">
@@ -289,6 +346,7 @@ export default function ExploreGrid({
activeFilter,
coords = null,
searchQuery = {},
onSelectTopHashtag,
}: ExploreGridProps) {
const { t } = useTranslation("common");
const { language } = useAppLanguage();
@@ -301,6 +359,9 @@ export default function ExploreGrid({
const academyOnly = activeFilter === "academy";
const includeAcademy = activeFilter === "all" || academyOnly;
const needsLocation = activeFilter === "near_me" && !coords;
// «هشتگ‌های برتر» یه فیدِ پست نیست، یه لیستِ رتبه‌بندی‌شده‌ست — کوئریِ اصلیِ
// فید اصلاً نباید براش اجرا بشه
const isTopHashtags = activeFilter === "top_hashtags";
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, isError, refetch } =
useInfiniteQuery({
@@ -312,7 +373,7 @@ export default function ExploreGrid({
searchQuery,
postFilters,
],
enabled: !needsLocation,
enabled: !needsLocation && !isTopHashtags,
queryFn: async ({ pageParam = 1 }) => {
const page = pageParam as number;
@@ -458,6 +519,10 @@ export default function ExploreGrid({
return merged;
}, [data, activeFilter, includeAcademy]);
if (isTopHashtags) {
return <TopHashtagsList onSelect={onSelectTopHashtag} />;
}
if (needsLocation) {
return (
<p className="py-16 text-center text-sm text-neutral-500">
@@ -468,7 +533,7 @@ export default function ExploreGrid({
if (isLoading && !data?.pages?.length) {
return (
<div className="px-2 pb-2">
<div className="pb-2">
<div className="columns-2 gap-2">
{Array.from({ length: SKELETON_COUNT }).map((_, index) => (
<ExploreCardSkeleton key={`sk-${index}`} seed={index} />
@@ -504,7 +569,7 @@ export default function ExploreGrid({
}
return (
<div className="px-2 pb-2">
<div className="pb-2">
<div className="columns-2 gap-2">
{items.map((item) =>
item.kind === "post" ? (

View File

@@ -3,6 +3,7 @@
import React, { useEffect, useState } from "react";
import Image from "next/image";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useTranslation } from "react-i18next";
import useAxios from "@/hooks/useAxios";
import Cookies from "js-cookie";
@@ -21,6 +22,7 @@ function Header() {
const [unreadNotification, setUnreadNotification] = useState<number>();
const { compact, hidden } = useScrollDirection(20);
const token = Cookies.get("token");
const pathname = usePathname();
const refreshBadges = async () => {
const [notif, msgs] = await Promise.all([
@@ -41,10 +43,14 @@ function Header() {
setUnreadMessages(msgs?.unread_messages_count);
};
// بعد از باز کردنِ صفحه‌ی نوتیفیکیشن/پیام‌ها، بک‌اند همه‌چیز رو «خونده‌شده» علامت
// می‌زنه — ولی این هدر یه لایوت دائمیه که موقع ناوبری دوباره mount نمی‌شه، پس بدون
// این وابستگی به pathname، بجِ روی آیکون زنگوله/پیام تا رفرش کامل صفحه کهنه می‌موند
useEffect(() => {
if (!token) return;
void refreshBadges();
}, [request, token]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [request, token, pathname]);
const prevNetwork = React.useRef(state);
useEffect(() => {

View File

@@ -18,7 +18,13 @@ export default function CommentsModal({
postId,
}: CommentsModalProps) {
return (
<Modal isOpen={isOpen} onClose={onClose} height="min(82vh, 520px)" elevated>
<Modal
isOpen={isOpen}
onClose={onClose}
height="min(82vh, 520px)"
elevated
panelClassName="!border-0"
>
<PostCommentsContent
userId={userId}
postId={postId}

View File

@@ -1,14 +1,8 @@
"use client";
import React, { useCallback, useState } from "react";
import Image from "next/image";
import Link from "next/link";
import { AnimatePresence, motion, PanInfo } from "framer-motion";
import { buildStorageUrl } from "@/components/main/BaseUrl";
import React from "react";
import BoldIcon from "@/components/ui/BoldIcon";
import useAxios from "@/hooks/useAxios";
import Cookies from "js-cookie";
import { cn } from "@/lib/utils";
import SpecialistCarousel from "@/components/projects/SpecialistCarousel";
import { useTranslation } from "react-i18next";
export type SuggestedFollowUser = {
@@ -33,220 +27,17 @@ interface FollowingSuggestionsCarouselProps {
onDismiss?: () => void;
}
const SWIPE_THRESHOLD = 72;
const slideVariants = {
enter: (direction: number) => ({
x: direction > 0 ? "105%" : "-105%",
opacity: 0.35,
scale: 0.9,
}),
center: {
x: 0,
opacity: 1,
scale: 1,
},
exit: (direction: number) => ({
x: direction > 0 ? "-105%" : "105%",
opacity: 0.35,
scale: 0.9,
}),
};
function getDisplayName(user: SuggestedFollowUser, fallback: string) {
return (
[user.first_name, user.last_name].filter(Boolean).join(" ") ||
user.user_name ||
fallback
);
}
function SuggestionCardShell({
user,
variant,
onSkip,
onFollow,
busy,
isFollowing,
userFallback,
}: {
user: SuggestedFollowUser;
variant: "main" | "peek";
onSkip?: () => void;
onFollow?: () => void;
busy?: boolean;
isFollowing?: boolean;
userFallback: string;
}) {
const { t } = useTranslation("common");
const displayName = getDisplayName(user, userFallback);
const previewPath = user.previewPost?.files?.[0]?.path;
const previewUrl = previewPath ? buildStorageUrl(previewPath) : null;
const profileUrl = user.profile_image
? buildStorageUrl(user.profile_image)
: null;
const isMain = variant === "main";
return (
<div className="relative h-full w-full overflow-hidden rounded-3xl bg-neutral-900 shadow-2xl">
{previewUrl ? (
user.previewPost?.type === "video" ? (
<video
src={previewUrl}
className="h-full w-full object-cover"
muted
playsInline
autoPlay={isMain}
loop
preload={isMain ? "auto" : "metadata"}
/>
) : (
<Image
src={previewUrl}
alt=""
fill
className="object-cover"
sizes={isMain ? "280px" : "120px"}
unoptimized
/>
)
) : (
<div className="h-full w-full bg-neutral-800" />
)}
<div
className={cn(
"absolute inset-0 bg-gradient-to-t from-black/85 via-black/20 to-black/30",
!isMain && "from-black/70"
)}
/>
{isMain && onSkip ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onSkip();
}}
className="absolute left-3 top-3 flex h-8 w-8 items-center justify-center rounded-full bg-black/40 text-white backdrop-blur-sm"
aria-label={t("posts.skip")}
>
<BoldIcon name="close-circle" size={18} tinted className="text-white" />
</button>
) : null}
<div
className={cn(
"absolute inset-x-0 bottom-0 flex flex-col items-center pt-6",
isMain ? "px-4 pb-5" : "px-2 pb-3"
)}
>
<div
className={cn(
"relative mb-2 overflow-hidden rounded-full ring-2 ring-white/90",
isMain ? "h-16 w-16" : "h-9 w-9"
)}
>
{profileUrl ? (
<Image
src={profileUrl}
alt=""
fill
className="object-cover"
sizes={isMain ? "64px" : "36px"}
unoptimized
/>
) : (
<div className="flex h-full w-full items-center justify-center bg-neutral-700 text-white">
{displayName.charAt(0)}
</div>
)}
</div>
{isMain ? (
<>
<Link href={`/users/${user.user_name}`} className="text-center">
<p className="text-base font-bold text-white">{displayName}</p>
{user.user_name ? (
<p className="text-xs text-white/70">@{user.user_name}</p>
) : null}
</Link>
{user.expertise ? (
<p className="mt-1 text-xs text-white/55">{user.expertise}</p>
) : null}
<button
type="button"
disabled={busy || isFollowing}
onClick={onFollow}
className="mt-4 w-full max-w-[200px] rounded-full bg-[#fe2c55] py-2.5 text-sm font-bold text-white transition active:scale-95 disabled:opacity-60"
>
{isFollowing ? t("posts.followed") : t("posts.followAction")}
</button>
</>
) : (
<p className="line-clamp-1 text-center text-[10px] font-semibold text-white">
{displayName}
</p>
)}
</div>
</div>
);
}
// عین SuggestedCreatorsDeck.tsx اندروید: به‌جای دکِ تک‌کارتیِ کشیدنی قبلی، همون کاروسلِ
// افقیِ اسنپ‌دار مشترکی که «یافتن متخصص» هم استفاده می‌کنه (SpecialistCarousel) — چند کارت
// هم‌زمان دیده می‌شن، با سوایپ لمسی/درگ ماوس/فلش‌کیبورد روون جابه‌جا می‌شن، نه پرش تک‌کارتی
export default function FollowingSuggestionsCarousel({
users,
onFollowed,
onDismiss,
}: FollowingSuggestionsCarouselProps) {
const { t } = useTranslation("common");
const [[index, direction], setSlide] = useState<[number, number]>([0, 0]);
const [followingIds, setFollowingIds] = useState<Set<string>>(new Set());
const [busy, setBusy] = useState(false);
const { request } = useAxios();
const current = users[index];
const paginate = useCallback(
(step: number) => {
if (users.length <= 1) return;
setSlide(([i]) => [
(i + step + users.length) % users.length,
step > 0 ? 1 : -1,
]);
},
[users.length]
);
const handleDragEnd = (_: unknown, info: PanInfo) => {
if (info.offset.x < -SWIPE_THRESHOLD) paginate(1);
else if (info.offset.x > SWIPE_THRESHOLD) paginate(-1);
};
const handleFollow = async () => {
if (!current || busy) return;
const token = Cookies.get("token");
if (!token) return;
setBusy(true);
try {
await request(
"POST",
"/posts/follow",
{ userId: current._id },
{ noToast: true }
);
setFollowingIds((prev) => new Set(prev).add(String(current._id)));
onFollowed?.();
window.setTimeout(() => paginate(1), 350);
} catch {
/* ignore */
} finally {
setBusy(false);
}
};
if (!users.length || !current) {
if (!users.length) {
return (
<div className="flex min-h-[100dvh] w-full flex-col items-center justify-center overflow-hidden bg-black px-6 text-center text-white/70">
<p className="text-base font-semibold text-white">
@@ -257,10 +48,6 @@ export default function FollowingSuggestionsCarousel({
);
}
const isFollowing = followingIds.has(String(current._id));
const prevUser = users.length > 1 ? users[(index - 1 + users.length) % users.length] : null;
const nextUser = users.length > 1 ? users[(index + 1) % users.length] : null;
return (
<div className="fixed inset-0 z-[1] flex min-h-[100dvh] w-full flex-col items-center overflow-hidden bg-black px-3 pb-[env(safe-area-inset-bottom)] pt-[calc(6.75rem+env(safe-area-inset-top))]">
<div className="relative mb-6 w-full max-w-[420px] text-center">
@@ -276,65 +63,16 @@ export default function FollowingSuggestionsCarousel({
) : null}
<h2 className="text-lg font-bold text-white">{t("posts.suggestedCreators")}</h2>
<p className="mt-1 text-sm text-white/60">{t("posts.suggestedCreatorsHint")}</p>
<p className="mt-2 text-xs text-white/45">{t("posts.suggestedCreatorsSwipeHint")}</p>
</div>
<div
dir="ltr"
className="relative mx-auto h-[min(62dvh,500px)] w-full max-w-[420px] overflow-hidden"
>
{prevUser ? (
<button
type="button"
onClick={() => paginate(-1)}
className="absolute left-1 top-1/2 z-10 h-[88%] w-[22%] -translate-y-1/2 overflow-hidden rounded-2xl opacity-55 active:scale-[0.98]"
aria-label={t("posts.prevSuggestion")}
>
<SuggestionCardShell user={prevUser} variant="peek" userFallback={t("common.user")} />
</button>
) : null}
{nextUser ? (
<button
type="button"
onClick={() => paginate(1)}
className="absolute right-1 top-1/2 z-10 h-[88%] w-[22%] -translate-y-1/2 overflow-hidden rounded-2xl opacity-55 active:scale-[0.98]"
aria-label={t("posts.nextSuggestion")}
>
<SuggestionCardShell user={nextUser} variant="peek" userFallback={t("common.user")} />
</button>
) : null}
<AnimatePresence initial={false} custom={direction} mode="popLayout">
<motion.div
key={current._id}
custom={direction}
variants={slideVariants}
initial="enter"
animate="center"
exit="exit"
transition={{
x: { type: "spring", stiffness: 320, damping: 32, mass: 0.85 },
opacity: { duration: 0.22 },
scale: { duration: 0.22 },
}}
drag={users.length > 1 ? "x" : false}
dragConstraints={{ left: 0, right: 0 }}
dragElastic={0.14}
onDragEnd={handleDragEnd}
className="absolute left-1/2 top-1/2 z-20 h-[92%] w-[min(58vw,260px)] -translate-x-1/2 -translate-y-1/2"
>
<SuggestionCardShell
user={current}
variant="main"
onSkip={() => paginate(1)}
onFollow={handleFollow}
busy={busy}
isFollowing={isFollowing}
userFallback={t("common.user")}
/>
</motion.div>
</AnimatePresence>
</div>
<SpecialistCarousel
items={users}
actionLabel={t("posts.followAction")}
actionDoneLabel={t("posts.followed")}
onActionDone={() => onFollowed?.()}
className="w-full max-w-[420px]"
/>
{onDismiss ? (
<button

View File

@@ -260,7 +260,7 @@ export default function PostCommentsContent({
return (
<div className={cn("flex h-full min-h-0 flex-col", className)}>
<div className="relative flex shrink-0 items-center justify-between border-b border-neutral-200/60 px-4 py-3 dark:border-neutral-700">
<div className="relative flex shrink-0 items-center justify-between px-4 py-3">
<h3 className="flex-1 text-center font-bold">{t("posts.comments")}</h3>
{variant === "sheet" && onClose ? (
<button
@@ -296,10 +296,7 @@ export default function PostCommentsContent({
</div>
<div
className={cn(
"shrink-0 border-t border-neutral-200/60 px-4 pt-3 dark:border-neutral-700",
composerPad
)}
className={cn("shrink-0 px-4 pt-3", composerPad)}
>
{replyTo ? (
<div className="mb-2 flex items-center justify-between rounded-lg bg-neutral-100 px-3 py-1.5 text-xs dark:bg-neutral-900">

View File

@@ -5,6 +5,7 @@ import { useRouter, usePathname } from "next/navigation";
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
import { fetchPosts } from "@/api/fetchPosts";
import { fetchPostById } from "@/api/fetchPostById";
import { fetchBookmarkPosts } from "@/api/fetchBookmarkPosts";
import { Post } from "@/types/types";
import { MutedProvider } from "@/components/models/ModelPage/MainModelCardPost";
import BackButton from "@/components/ui/BackButton";
@@ -28,6 +29,7 @@ import { buildExplorePostFilters } from "@/lib/explore/buildPostFilters";
import { ExploreFilterId } from "@/constants/exploreFilters";
import { isPublicPostStatus } from "@/lib/moderationStatus";
import { useReelsSnapScroll, REELS_SCROLL_CLASS, getReelsStepHeight } from "@/hooks/useReelsSnapScroll";
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
import ReelsScrollSlot from "./ReelsScrollSlot";
import { ExploreReelsUiProvider } from "@/contexts/ExploreReelsUiContext";
import {
@@ -68,7 +70,7 @@ interface PostFeedViewProps {
showClose?: boolean;
videoOnly?: boolean;
exploreMode?: boolean;
feedMode?: "user" | "home" | "explore";
feedMode?: "user" | "home" | "explore" | "bookmarks";
/** از سرور — بدون Suspense و useSearchParams */
searchParams?: PostFeedSearchParams;
}
@@ -99,6 +101,21 @@ export default function PostFeedView({
const scrollRef = useRef<HTMLDivElement>(null);
useReelsSnapScroll(scrollRef);
const scrolledRef = useRef(false);
// این صفحه هدر سراسری (Header.tsx) رو نداره — پس وضعیت آفلاین/سینک باید همینجا
// جدا نشون داده بشه، عین رفتار بقیه‌ی صفحات
const { isOffline, isSyncing, state, startSync, endSync } = useNetworkStatus();
const prevNetworkRef = useRef(state);
useEffect(() => {
const wasOffline = prevNetworkRef.current === "offline";
prevNetworkRef.current = state;
if (!wasOffline || state !== "online") return;
startSync();
const timer = window.setTimeout(() => {
queryClient.invalidateQueries({ queryKey: ["post-feed"] }).finally(endSync);
}, 800);
return () => window.clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state]);
const [seedPost, setSeedPost] = useState<Post | null>(() =>
pickInstantPost(initialPostId, initialPostProp)
@@ -128,11 +145,13 @@ export default function PostFeedView({
feedModeProp ||
(feedFromQuery === "user" || isProfileReels
? "user"
: feedFromQuery === "explore" || exploreMode
? "explore"
: feedFromQuery === "home" || showClose
? "home"
: "user");
: feedFromQuery === "bookmarks"
? "bookmarks"
: feedFromQuery === "explore" || exploreMode
? "explore"
: feedFromQuery === "home" || showClose
? "home"
: "user");
const showReelsTabs =
(feedMode === "home" || feedMode === "explore") &&
@@ -229,7 +248,10 @@ export default function PostFeedView({
: resolveReelsMediaType(instantPost, videoOnly);
const isReelsFeed =
feedMode === "home" || feedMode === "explore" || feedMode === "user";
feedMode === "home" ||
feedMode === "explore" ||
feedMode === "user" ||
feedMode === "bookmarks";
const exploreFilterId =
(searchParamsProp.filter as ExploreFilterId) || "all";
@@ -293,6 +315,7 @@ export default function PostFeedView({
nearMeReady &&
(feedMode === "home" ||
feedMode === "explore" ||
feedMode === "bookmarks" ||
!!resolvedUserId ||
!!token) &&
(!isReelsFeed || !!reelsMediaType || !!instantPost);
@@ -310,33 +333,46 @@ export default function PostFeedView({
nearMeCoords?.lat,
nearMeCoords?.lng,
],
queryFn: ({ pageParam = 1 }) =>
fetchPosts(
pageParam as number,
pageParam === 1 ? REELS_FIRST_PAGE_SIZE : REELS_PAGE_SIZE,
{
...queryFilters,
...(isReelsFeed
? {
feedMode: "reels" as const,
...(reelsMediaType ? { type: reelsMediaType } : {}),
...(pageParam === 1 &&
reelsTab === "for_you" &&
(feedMode === "home" || feedMode === "explore")
? { seedPostId: initialPostId }
: {}),
...(apiReelsTab ? { reelsTab: apiReelsTab } : {}),
...(reelsTab === "near_me" && nearMeCoords
? {
lat: String(nearMeCoords.lat),
lng: String(nearMeCoords.lng),
}
: {}),
}
: {}),
},
token
),
queryFn: async ({ pageParam = 1 }) => {
if (feedMode === "bookmarks") {
const result = await fetchBookmarkPosts(
pageParam as number,
pageParam === 1 ? REELS_FIRST_PAGE_SIZE : REELS_PAGE_SIZE,
token
);
return {
posts: result.posts as unknown[],
totalItems: result.totalItems,
feedMeta: undefined,
};
}
return fetchPosts(
pageParam as number,
pageParam === 1 ? REELS_FIRST_PAGE_SIZE : REELS_PAGE_SIZE,
{
...queryFilters,
...(isReelsFeed
? {
feedMode: "reels" as const,
...(reelsMediaType ? { type: reelsMediaType } : {}),
...(pageParam === 1 &&
reelsTab === "for_you" &&
(feedMode === "home" || feedMode === "explore")
? { seedPostId: initialPostId }
: {}),
...(apiReelsTab ? { reelsTab: apiReelsTab } : {}),
...(reelsTab === "near_me" && nearMeCoords
? {
lat: String(nearMeCoords.lat),
lng: String(nearMeCoords.lng),
}
: {}),
}
: {}),
},
token
);
},
initialPageParam: 1,
getNextPageParam: (lastPage, pages) =>
lastPage.posts?.length ? pages.length + 1 : undefined,
@@ -673,6 +709,27 @@ export default function PostFeedView({
className="fixed right-4 top-[calc(1rem+env(safe-area-inset-top))] z-[100] bg-black/50 text-white"
/>
) : null}
{isOffline || isSyncing ? (
<div
className={cn(
"fixed left-1/2 top-[calc(1rem+env(safe-area-inset-top))] z-[100] -translate-x-1/2 rounded-full px-3 py-1.5 text-xs font-medium text-white backdrop-blur-md",
isOffline ? "header-offline" : "bg-black/50"
)}
>
{isSyncing ? (
<span className="inline-flex items-center gap-0.5">
{t("header.syncing")}
<span className="header-syncing-dots mr-0.5 inline-flex">
<span>.</span>
<span>.</span>
<span>.</span>
</span>
</span>
) : (
t("chats.offline")
)}
</div>
) : null}
{showReelsTabs ? (
<div
className={cn(

View File

@@ -24,6 +24,7 @@ import { useExploreReelsUi } from "@/contexts/ExploreReelsUiContext";
import ReelsPostOptionsMenu from "./ReelsPostOptionsMenu";
import BoldIcon from "@/components/ui/BoldIcon";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
interface ReelsPostActionsProps {
postData: Post;
@@ -33,6 +34,9 @@ interface ReelsPostActionsProps {
allowSave?: boolean;
sharingStory?: boolean;
onShareToStory?: () => void;
/** وقتی onCommentClick پاس داده نشه، این کامپوننت خودش CommentsModal رو مدیریت می‌کنه —
* والد (ReelsPostCard) برای مخفی‌کردن نوار پیشرفت ویدیو حین بازبودنِ این مودال باید بدونه */
onCommentsModalOpenChange?: (open: boolean) => void;
}
function ReelsProfileFollowButton({
@@ -134,6 +138,7 @@ export default function ReelsPostActions({
allowSave = true,
sharingStory = false,
onShareToStory,
onCommentsModalOpenChange,
}: ReelsPostActionsProps) {
const { t } = useTranslation("common");
const { user_id, user_name } = postData;
@@ -166,6 +171,12 @@ export default function ReelsPostActions({
return;
}
setShowCommentsModal(true);
onCommentsModalOpenChange?.(true);
};
const closeComments = () => {
setShowCommentsModal(false);
onCommentsModalOpenChange?.(false);
};
const postIconSize = "sm" as const;
@@ -367,7 +378,7 @@ export default function ReelsPostActions({
{showCommentsModal && !onCommentClick && (
<CommentsModal
isOpen={showCommentsModal}
onClose={() => setShowCommentsModal(false)}
onClose={closeComments}
userId={user_id}
postId={postData._id}
/>
@@ -393,40 +404,23 @@ export default function ReelsPostActions({
<div className={POST_ACTIONS_ROW_CLASS}>{postPageActions}</div>
</div>
) : (
<div className="absolute bottom-36 left-3 z-30 flex flex-col items-center gap-4">
/* عرضِ ستون ثابت (w-12، هم‌اندازه‌ی آواتار که پهن‌ترین آیتمشه) — وگرنه توی
حالتِ جمع‌شده که آواتار نیست، ستون به‌اندازه‌ی آیکونِ باریک‌تر جمع می‌شد و
دکمه‌ی جمع‌شونده (که items-center وسط‌چینش می‌کنه) چند پیکسل به چپ می‌رفت */
<div className="absolute bottom-36 left-3 z-30 flex w-12 flex-col items-center gap-4">
{onShareToStory ? (
<ReelsStoryShareButton
disabled={sharingStory}
onClick={onShareToStory}
/>
) : null}
<AnimatePresence mode="wait">
{!expanded ? (
<motion.div
key="collapsed"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.2 }}
className="flex flex-col items-center gap-3"
>
<button
type="button"
onClick={() => setExpanded(true)}
className="flex items-center justify-center"
aria-label={t("posts.showActions")}
>
<Image
width={28}
height={28}
alt=""
src="/images/icons/actions-toggle.svg"
className="drop-shadow-lg invert"
/>
</button>
{optionsMenu}
</motion.div>
) : (
{/* دکمه‌ی جمع‌شونده و منوی سه‌نقطه همیشه توی همین جای ثابت رندر می‌شن (مثل
اندروید) — قبلاً با AnimatePresence mode="wait" بین دو درختِ جدا سوییچ
می‌شد (جمع‌شده: [دکمه، منو] / بازشده: [آیکون‌ها، دکمه، منو]) که باعث می‌شد
جای دکمه و منو موقع باز/بسته‌کردن جابه‌جا بشه؛ الان فقط آیکون‌های اکسترا
بالای این دو تا اضافه/کم می‌شن، بدون جابه‌جایی */}
<AnimatePresence>
{expanded ? (
<motion.div
key="actions"
initial={{ opacity: 0, y: 12 }}
@@ -436,24 +430,24 @@ export default function ReelsPostActions({
className="flex flex-col items-center gap-4"
>
{reelsActions}
<button
type="button"
onClick={() => setExpanded(false)}
className="flex items-center justify-center"
aria-label={t("posts.close")}
>
<Image
width={28}
height={28}
alt=""
src="/images/icons/actions-toggle.svg"
className="drop-shadow-lg invert opacity-80"
/>
</button>
{optionsMenu}
</motion.div>
)}
) : null}
</AnimatePresence>
<button
type="button"
onClick={() => setExpanded(!expanded)}
className="flex items-center justify-center"
aria-label={expanded ? t("posts.close") : t("posts.showActions")}
>
<Image
width={28}
height={28}
alt=""
src="/images/icons/actions-toggle.svg"
className={cn("drop-shadow-lg invert", expanded && "opacity-80")}
/>
</button>
{optionsMenu}
</div>
)}
</>

View File

@@ -102,6 +102,12 @@ export default function ReelsPostCard({
const storyMeta = useStoryMetaForUser(user_id);
const profile_image = user?.profile_image;
const verify_badge = user?.verify_badge;
// مثل اندروید: شهر و تخصص جلوی نام‌کاربری، امتیاز و رتبه جلوی نام‌ونام‌خانوادگی
const metaTags = [user?.city?.name_fa || user?.city?.name, user?.expertise].filter(
(tag): tag is string => Boolean(tag)
);
const userRate = user?.rate ? Number(user.rate) : null;
const userScore = user?.user_score ?? null;
const [captionExpanded, setCaptionExpanded] = useState(false);
const orderedCaption = reorderCaptionLinksToEnd(caption || "");
@@ -113,6 +119,10 @@ export default function ReelsPostCard({
}, [postData._id]);
const [imageLoading, setImageLoading] = useState(() => (files?.length ?? 0) > 0);
const [commentsOpen, setCommentsOpen] = useState(false);
// وقتی postPage=true، کامنت‌ها توی CommentsModal جدا (نه شیت این کامپوننت) باز می‌شه
// و commentsOpen بالا هیچ‌وقت true نمی‌شه؛ این استیت همون حالت رو از ReelsPostActions
// منعکس می‌کنه تا نوار پیشرفت ویدیو حین بازبودنِ اون مودال هم مخفی بشه
const [commentsModalOpen, setCommentsModalOpen] = useState(false);
const [uiHidden, setUiHidden] = useState(false);
const [likeBurstKey, setLikeBurstKey] = useState<number | null>(null);
const [videoDuration, setVideoDuration] = useState(0);
@@ -139,8 +149,13 @@ export default function ReelsPostCard({
const allowSave = postData.author_allow_save !== false;
const useCommentsSheet = !postPage;
const anyCommentsOpen = commentsOpen || commentsModalOpen;
const showLongVideoBar =
type === "video" && videoDuration > LONG_VIDEO_SEC && isActive && !chromeHidden;
type === "video" &&
videoDuration > LONG_VIDEO_SEC &&
isActive &&
!chromeHidden &&
!anyCommentsOpen;
const mediaFiles =
files?.map((file) => ({
@@ -506,7 +521,10 @@ export default function ReelsPostCard({
</div>
{showLongVideoBar && (
<div className="pointer-events-none absolute inset-x-0 bottom-[calc(5.5rem+env(safe-area-inset-bottom))] z-30 px-3">
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-30 px-3 pb-1">
<p className="mb-1 text-right text-[10px] tabular-nums text-white/85">
{formatVideoTime(remaining)}
</p>
<div className="relative h-0.5 overflow-hidden rounded-full bg-white/25">
<div
className="absolute inset-y-0 left-0 rounded-full bg-white/35"
@@ -517,9 +535,6 @@ export default function ReelsPostCard({
style={{ width: `${progressPct}%` }}
/>
</div>
<p className="mt-1 text-right text-[10px] tabular-nums text-white/85">
{formatVideoTime(remaining)}
</p>
</div>
)}
@@ -563,14 +578,19 @@ export default function ReelsPostCard({
)}
<AnimatePresence>
{!chromeHidden && !commentsOpen && (
{!chromeHidden && !anyCommentsOpen && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className={cn(
"pointer-events-none absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black/90 via-black/60 to-transparent px-4 pb-[calc(1.25rem+env(safe-area-inset-bottom))] pt-16"
"pointer-events-none absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black/90 via-black/60 to-transparent px-4 pt-16",
// وقتی نوار پیشرفت ویدیو نمایش داده می‌شه، این بلوک باید بالای خطِ اون
// بشینه نه روش؛ وگرنه (تصویر/ویدیوی کوتاه) فقط فاصله‌ی پایینِ معمولی کافیه
showLongVideoBar
? "pb-[calc(2.75rem+env(safe-area-inset-bottom))]"
: "pb-[calc(1.25rem+env(safe-area-inset-bottom))]"
)}
>
<div className="pointer-events-auto flex items-center gap-3">
@@ -599,16 +619,36 @@ export default function ReelsPostCard({
</StoryAvatarOpen>
<Link
href={`/users/${user_name}`}
className="flex min-w-0 flex-1 items-center gap-2.5"
className="flex min-w-0 flex-1 flex-col gap-0.5"
>
<div className="min-w-0 flex-1 text-white">
<div className="flex items-center gap-1 truncate text-sm font-semibold">
<span className="truncate">{user_name}</span>
<VerificationBadge verifyBadge={verify_badge} />
</div>
<p className="truncate text-xs text-white/80">
<div className="flex min-w-0 items-center gap-1 text-sm font-semibold text-white">
<span className="truncate">{user_name}</span>
<VerificationBadge verifyBadge={verify_badge} />
{metaTags.map((tag) => (
<span
key={tag}
className="shrink-0 truncate text-xs font-normal text-white/70 before:ml-1 before:content-['·']"
>
{tag}
</span>
))}
</div>
<div className="flex min-w-0 items-center gap-2 text-xs text-white/80">
<span className="truncate">
{first_name} {last_name}
</p>
</span>
{userRate != null && (
<span className="inline-flex shrink-0 items-center gap-0.5">
<BoldIcon name="star-1" size={12} className="text-yellow-400" tinted />
{userRate.toFixed(1)}
</span>
)}
{userScore != null && (
<span className="inline-flex shrink-0 items-center gap-0.5">
<BoldIcon name="medal-star" size={12} className="text-yellow-400" tinted />
{userScore}
</span>
)}
</div>
</Link>
</div>
@@ -641,6 +681,7 @@ export default function ReelsPostCard({
onCommentClick={
useCommentsSheet ? () => setCommentsOpen(true) : undefined
}
onCommentsModalOpenChange={setCommentsModalOpen}
/>
)}
@@ -653,7 +694,7 @@ export default function ReelsPostCard({
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.32, ease: [0.25, 0.46, 0.45, 0.94] }}
className={cn(
"glass-modal-panel relative z-40 flex min-h-0 flex-col overflow-hidden rounded-t-3xl"
"glass-modal-panel relative z-40 flex min-h-0 flex-col overflow-hidden rounded-t-3xl !border-0"
)}
>
<PostCommentsContent

View File

@@ -1,12 +1,13 @@
"use client";
import React, { useMemo } from "react";
import React from "react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
export type ReelsTab = "for_you" | "following" | "saved" | "near_me";
const TAB_IDS: ReelsTab[] = ["for_you", "following", "saved", "near_me"];
// "نزدیک شما" مثل نسخه اندروید حذف شد — دیگه توی نوار تب نمایش داده نمی‌شه
const TAB_IDS: ReelsTab[] = ["for_you", "following", "saved"];
interface ReelsTabBarProps {
activeTab: ReelsTab;

View File

@@ -10,6 +10,23 @@ import { fetchProjects } from "@/api/fetchProjects";
import MainProjectCard from "./MainProjectCard";
import { Project } from "@/types/types";
import { useRouter } from "next/navigation";
import { Skeleton } from "@/components/ui/skeleton";
function ProjectCardSkeleton() {
return (
<div className="mb-4 flex flex-col gap-3 rounded-2xl border border-neutral-200/60 p-4 dark:border-neutral-800">
<div className="flex items-center gap-3">
<Skeleton className="h-10 w-10 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-3 w-1/3" />
<Skeleton className="h-3 w-1/4" />
</div>
</div>
<Skeleton className="h-3 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
);
}
interface InfiniteProjectsProps {
initialData: any;
@@ -40,13 +57,12 @@ export default function InfiniteProjects({
gender: filters.gender || ''
});
const {
data,
fetchNextPage,
hasNextPage,
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
refetch
} = useInfiniteQuery({
queryKey: ['projects', filterString],
queryFn: ({ pageParam }) => fetchProjects(pageParam, 10, filters, token),
@@ -66,10 +82,6 @@ export default function InfiniteProjects({
!isLoading
);
useEffect(() => {
refetch();
}, [filterString, refetch]);
useEffect(() => {
if (!observerRef.current || !hasNextPage) return;
@@ -96,7 +108,13 @@ export default function InfiniteProjects({
return (
<div>
<div className="mt-5">
{data?.pages?.some((page) => page?.projects?.length) ? (
{isLoading ? (
<>
<ProjectCardSkeleton />
<ProjectCardSkeleton />
<ProjectCardSkeleton />
</>
) : data?.pages?.some((page) => page?.projects?.length) ? (
data.pages.map((page, pageIndex) =>
page?.projects?.map((item: Project) => (
<div key={`${item._id}-${pageIndex}`}>

View File

@@ -1,4 +1,5 @@
import { pageSeo, SITE_NAME, SITE_URL } from "@/config/pageSeo";
import { safeJsonLdString } from "@/lib/seo/safeJsonLd";
/** JSON-LD برای موتورهای جستجو و سیستم‌های هوش مصنوعی */
export default function SiteJsonLd() {
@@ -37,7 +38,7 @@ export default function SiteJsonLd() {
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
dangerouslySetInnerHTML={{ __html: safeJsonLdString(schema) }}
/>
);
}

View File

@@ -1,7 +1,7 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useInfiniteQuery } from "@tanstack/react-query";
import { Post } from "@/types/types";
import { buildStorageUrl } from "@/components/main/BaseUrl";
@@ -13,6 +13,9 @@ import { formatLikeDisplay } from "@/lib/formatLikeDisplay";
import ExploreAuthor from "@/components/explore/ExploreAuthor";
import { fetchBookmarkPosts } from "@/api/fetchBookmarkPosts";
import { buildPostPath } from "@/lib/postSlug";
import { cacheReelsSeedPost } from "@/lib/reelsSeedPost";
import { navigateWithSavedScroll } from "@/lib/listScrollRestoration";
import { useListScrollRestoration } from "@/hooks/useListScrollRestoration";
import { useTranslation } from "react-i18next";
const ASPECT_RATIOS = [
@@ -53,8 +56,9 @@ function getPostMedia(post: Post): {
};
}
function FavoriteCard({ post }: { post: Post }) {
function FavoriteCard({ post, scrollKey }: { post: Post; scrollKey: string }) {
const { t } = useTranslation("common");
const router = useRouter();
const media = getPostMedia(post);
if (!media) return null;
@@ -64,11 +68,25 @@ function FavoriteCard({ post }: { post: Post }) {
post.user_name ||
t("common.user");
// عین ExploreGrid.tsx: seed پست رو کش می‌کنیم و با feed=bookmarks باز می‌کنیم تا
// اسکرول توی ریلز فقط بین پست‌های علاقه‌مندی جابجا بشه، نه فید کلی؛ برگشت هم دقیقاً
// به همون موقعیت اسکرول این گرید برمی‌گرده (navigateWithSavedScroll)
const openPost = () => {
cacheReelsSeedPost(post);
const params = new URLSearchParams({ feed: "bookmarks" });
navigateWithSavedScroll(
router,
scrollKey,
`${buildPostPath(post._id, post)}?${params.toString()}`
);
};
return (
<article className="mb-2 break-inside-avoid">
<Link
href={buildPostPath(post._id, post)}
className="gentle-transition block active:scale-[0.98]"
<button
type="button"
onClick={openPost}
className="gentle-transition block w-full text-right active:scale-[0.98]"
>
<div
className={`relative w-full overflow-hidden rounded-2xl bg-neutral-900 ${aspect}`}
@@ -80,7 +98,6 @@ function FavoriteCard({ post }: { post: Post }) {
fill
className="object-cover"
sizes="50vw"
unoptimized
/>
) : media.kind === "video" ? (
<video
@@ -97,7 +114,6 @@ function FavoriteCard({ post }: { post: Post }) {
fill
className="object-cover"
sizes="50vw"
unoptimized
/>
)}
{media.kind === "video" && (
@@ -106,7 +122,7 @@ function FavoriteCard({ post }: { post: Post }) {
</span>
)}
</div>
</Link>
</button>
<div className="flex items-center justify-between gap-2 px-0.5 py-1.5">
<ExploreAuthor
@@ -138,13 +154,15 @@ export default function FavoritesGrid() {
} = useInfiniteQuery({
queryKey: ["bookmark-posts"],
queryFn: ({ pageParam }) =>
fetchBookmarkPosts({ pageParam: pageParam as number, token }),
fetchBookmarkPosts(pageParam as number, 12, token),
initialPageParam: 1,
getNextPageParam: (last) => last.nextPage,
getNextPageParam: (last, pages) =>
last.hasMore ? pages.length + 1 : undefined,
enabled: Boolean(token),
});
const posts = data?.pages.flatMap((p) => p.posts) ?? [];
const scrollKey = useListScrollRestoration("favorites", undefined, !isLoading);
const onScroll = useCallback(() => {
if (!hasNextPage || isFetchingNextPage) return;
@@ -174,10 +192,12 @@ export default function FavoritesGrid() {
);
}
// بدون padding افقی مستقل — Container والد از قبل px-4 می‌ده که با فاصله‌ی
// منوبار پایین یکیه؛ px اضافه اینجا محتوا رو از منوبار باریک‌تر می‌کرد
return (
<div className="columns-2 gap-2 px-1 sm:columns-3">
<div className="columns-2 gap-2 sm:columns-3">
{posts.map((post) => (
<FavoriteCard key={post._id} post={post} />
<FavoriteCard key={post._id} post={post} scrollKey={scrollKey} />
))}
{isFetchingNextPage ? <PageLoader className="col-span-full py-4" /> : null}
</div>

View File

@@ -98,18 +98,18 @@ export default function AccountActionModals({
? t("settings.accountActions.deactivateWarning")
: t("settings.accountActions.deleteWarning")}
</p>
<div className="mt-5 flex gap-2">
<div className="mt-5 flex justify-center gap-2.5">
<button
type="button"
onClick={onClose}
className={cn(btnDefault, "flex-1 py-2.5 text-sm")}
className={cn(btnDefault, "w-40 py-2.5 text-sm")}
>
{t("settings.no")}
</button>
<button
type="button"
onClick={() => setStep("username")}
className="flex-1 rounded-3xl bg-red-500 py-2.5 text-sm font-semibold text-white"
className="w-40 rounded-2xl bg-red-500 py-2.5 text-sm font-semibold text-white"
>
{t("settings.yes")}
</button>
@@ -129,11 +129,11 @@ export default function AccountActionModals({
className="mt-3 w-full rounded-2xl border border-neutral-300 bg-transparent px-3 py-2.5 text-sm outline-none dark:border-neutral-700"
autoFocus
/>
<div className="mt-4 flex gap-2">
<div className="mt-4 flex justify-center gap-2.5">
<button
type="button"
onClick={() => setStep("confirm")}
className={cn(btnDefault, "flex-1 py-2.5 text-sm")}
className={cn(btnDefault, "w-40 py-2.5 text-sm")}
>
{t("common.back")}
</button>
@@ -141,7 +141,7 @@ export default function AccountActionModals({
type="button"
disabled={loading}
onClick={submit}
className={cn(btnPrimary, "flex-1 py-2.5 text-sm disabled:opacity-50")}
className={cn(btnPrimary, "w-40 py-2.5 text-sm disabled:opacity-50")}
>
{loading ? "…" : t("settings.confirm")}
</button>

View File

@@ -7,6 +7,8 @@ import { bestDiscountPercent } from "@/lib/shopDiscount";
import { IShopProductListing } from "@/types/types";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { navigateWithSavedScroll } from "@/lib/listScrollRestoration";
import { useListScrollRestoration } from "@/hooks/useListScrollRestoration";
import { useTranslation } from "react-i18next";
type ShopProductGridProps = {
@@ -74,10 +76,17 @@ export default function ShopProductGrid({
}: ShopProductGridProps) {
const { t } = useTranslation("common");
const router = useRouter();
// مثل ExploreGrid.tsx/FavoritesGrid.tsx: برگشت از صفحه‌ی محصول باید دقیقاً به همون
// موقعیتِ اسکرول این گرید برگرده
const scrollKey = useListScrollRestoration(
"shop-product-grid",
undefined,
!isLoading
);
if (isLoading) {
return (
<div className="px-2 pb-2">
<div className="pb-2">
<div className="columns-2 gap-2">
{Array.from({ length: SKELETON_COUNT }).map((_, index) => (
<ProductCardSkeleton key={index} aspect={ASPECT_RATIOS[index % ASPECT_RATIOS.length]} />
@@ -96,7 +105,7 @@ export default function ShopProductGrid({
}
return (
<div className="px-2 pb-2">
<div className="pb-2">
<div className="columns-2 gap-2">
{listings.map((listing) => {
const primaryImage =
@@ -113,7 +122,9 @@ export default function ShopProductGrid({
<article key={listing._id} className="mb-2 break-inside-avoid">
<button
type="button"
onClick={() => router.push(getHref(listing))}
onClick={() =>
navigateWithSavedScroll(router, scrollKey, getHref(listing))
}
className="gentle-transition block w-full text-right active:scale-[0.98]"
>
<div
@@ -126,7 +137,6 @@ export default function ShopProductGrid({
fill
className="object-cover"
sizes="50vw"
unoptimized
/>
) : null}
{listing.status !== "active" && (

View File

@@ -209,7 +209,7 @@ export default function ShopReelsView({
);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeIndex, listings]);
if (listings === null) {
@@ -352,10 +352,11 @@ export default function ShopReelsView({
>
{t("shops.viewProduct")}
</Link>
{/* عین ProductDetailScreen.tsx اندروید: سبد خرید با colors.info (#17337B) */}
<button
type="button"
onClick={() => handleAddToCart(listing)}
className="inline-block rounded-3xl bg-green-500 px-5 py-2 text-sm font-bold text-white"
className="inline-block rounded-2xl bg-[#17337B] px-5 py-2 text-sm font-bold text-white"
>
{t("shops.addToCartButton")}
</button>

View File

@@ -20,7 +20,7 @@ export default function ToggleSwitch({
onClick={() => onChange(!checked)}
dir="ltr"
className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors ${
checked ? "bg-blue-600" : "bg-neutral-300 dark:bg-neutral-600"
checked ? "bg-[#FC8EAC]" : "bg-neutral-300 dark:bg-neutral-600"
}`}
>
<span

View File

@@ -11,7 +11,8 @@ export type ExploreFilterId =
| "best_month"
| "near_me"
| "girls"
| "boys";
| "boys"
| "top_hashtags";
export type ExploreFilterItem = {
id: ExploreFilterId;
@@ -32,4 +33,5 @@ export const EXPLORE_FILTERS: ExploreFilterItem[] = [
{ id: "near_me", color: "#34d399" },
{ id: "girls", color: "#f472b6" },
{ id: "boys", color: "#3b82f6" },
{ id: "top_hashtags", color: "#facc15" },
];

View File

@@ -1,9 +1,4 @@
import {
decryptText,
deriveDmAesKey,
encryptText,
importPublicKeySpkiB64,
} from "./crypto";
import { deriveDmAesKey, importPublicKeySpkiB64 } from "./crypto";
import { E2EE_LOCKED_PREVIEW, isE2eeEnvelope } from "./envelope";
import { ensureIdentityKeys, fetchPeerPublicKey } from "./keys";
@@ -78,19 +73,17 @@ export function clearDmKeyCache(): void {
dmKeyCache.clear();
}
// E2EE موقتاً کاملاً غیرفعال شده (کلیدهای محلی که از بین می‌رفتن — پاک‌شدنِ
// دیتای مرورگر، نصبِ مجددِ اپ، دستگاهِ جدید — رمزگشایی رو برای همیشه می‌شکوندن،
// بدونِ راهِ بازیابی چون سرور خودِ متن رو نگه نمی‌داره). پیام‌های جدید همیشه
// متنِ ساده ارسال/دریافت می‌شن؛ توابعِ زیرِ کلید (getDmConversationKey و...)
// دست‌نخورده می‌مونن چون جاهای دیگه‌ای importشون می‌کنن، ولی دیگه از اینجا صدا زده نمی‌شن
export async function encryptDmContent(
myUserId: string,
peerUserId: string,
plaintext: string
): Promise<string | null> {
if (!plaintext) return plaintext;
if (isE2eeEnvelope(plaintext)) return plaintext;
const key = await getDmConversationKey(myUserId, peerUserId);
if (!key) return null;
const encrypted = await encryptText(key, plaintext);
rememberOutboundPlaintext(encrypted, plaintext);
return encrypted;
return plaintext;
}
export async function decryptDmContent(
@@ -99,37 +92,11 @@ export async function decryptDmContent(
content: string
): Promise<string> {
const raw = normalizeMessageContent(content);
if (!isE2eeEnvelope(raw)) return raw;
const remembered = recallOutboundPlaintext(raw);
if (remembered) return remembered;
try {
const key = await getDmConversationKey(myUserId, peerUserId);
if (key) {
const pt = await decryptText(key, raw);
if (pt != null) {
rememberOutboundPlaintext(raw, pt);
return pt;
}
}
// کلید کش‌شده ممکن است با کلید فعلی طرف مقابل هم‌خوان نباشد (مثلاً بعد از
// لینک/ریست دستگاه) — یک بار با دریافت مجدد کلید عمومی طرف مقابل تلاش کن
const freshKey = await getDmConversationKey(myUserId, peerUserId, true);
if (freshKey) {
const pt = await decryptText(freshKey, raw);
if (pt != null) {
rememberOutboundPlaintext(raw, pt);
return pt;
}
}
return E2EE_LOCKED_PREVIEW;
} catch {
// خطای گذرا در رمزگشایی یک پیام نباید بقیه پیام‌های صفحه را قفل نشان دهد
return E2EE_LOCKED_PREVIEW;
}
// فقط پیامِ قدیمیِ رمزنگاری‌شده (از قبلِ غیرفعال‌سازیِ E2EE) به این‌جا می‌رسه —
// چون کلیدِ لازم ممکنه دیگه در دسترس نباشه، به‌جایِ تلاشِ بی‌فایده برای
// رمزگشایی، همون‌جا پیامِ روشن نشون می‌دیم
if (isE2eeEnvelope(raw)) return E2EE_LOCKED_PREVIEW;
return raw;
}
export async function decryptDmMessageList<

View File

@@ -1,178 +1,24 @@
import Cookies from "js-cookie";
import { getApiBaseUrl } from "@/components/main/BaseUrl";
import {
decryptText,
encryptText,
generateRoomKeyRaw,
importPublicKeySpkiB64,
importRoomAesKey,
unwrapRoomKey,
wrapRoomKeyForRecipient,
} from "./crypto";
import { E2EE_LOCKED_PREVIEW, isE2eeEnvelope } from "./envelope";
import { ensureIdentityKeys } from "./keys";
import { b64ToBuf, bufToB64, idbGet, idbSet } from "./storage";
type RoomKeysResponse = {
e2ee_enabled: boolean;
myWrappedKey: string | null;
memberPublicKeys: { userId: string; publicKey: string | null }[];
missingWrapUserIds: string[];
};
const roomAesCache = new Map<string, CryptoKey>();
const roomRawCache = new Map<string, Uint8Array>();
function authHeaders(): HeadersInit {
const token = Cookies.get("token");
return {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
}
async function fetchRoomKeys(roomId: string): Promise<RoomKeysResponse | null> {
const base = getApiBaseUrl();
const res = await fetch(`${base}/chat-rooms/${roomId}/e2ee-keys`, {
headers: authHeaders(),
});
if (!res.ok) return null;
return (await res.json()) as RoomKeysResponse;
}
async function putRoomKeys(
roomId: string,
body: {
e2ee_enabled?: boolean;
wrapped_keys?: { userId: string; wrappedKey: string }[];
}
): Promise<void> {
const base = getApiBaseUrl();
await fetch(`${base}/chat-rooms/${roomId}/e2ee-keys`, {
method: "PUT",
headers: authHeaders(),
body: JSON.stringify(body),
});
}
async function loadCachedRoomRaw(
myUserId: string,
roomId: string
): Promise<Uint8Array | null> {
const mem = roomRawCache.get(roomId);
if (mem) return mem;
const b64 = await idbGet<string>(`roomkey:${myUserId}:${roomId}`);
if (!b64) return null;
const raw = new Uint8Array(b64ToBuf(b64));
roomRawCache.set(roomId, raw);
return raw;
}
async function persistRoomRaw(
myUserId: string,
roomId: string,
raw: Uint8Array
): Promise<CryptoKey> {
roomRawCache.set(roomId, raw);
await idbSet(`roomkey:${myUserId}:${roomId}`, bufToB64(raw));
const aes = await importRoomAesKey(raw);
roomAesCache.set(roomId, aes);
return aes;
}
import { isE2eeEnvelope, E2EE_LOCKED_PREVIEW } from "./envelope";
// E2EE برای روم‌ها هم موقتاً کاملاً غیرفعال شده. نکته‌ی مهم: ensureRoomAesKey
// قبلاً اگه روم هنوز e2ee_enabled نبود، خودش یه کلیدِ تازه می‌ساخت و با PUT به
// سرور، رمزنگاری رو *خودکار* روی روم فعال می‌کرد — پس همین‌جا کاملاً no-op
// شده تا هیچ‌جایی (حتی موقعِ ساختنِ روم جدید) دیگه رمزنگاری رو روشن نکنه
/* eslint-disable @typescript-eslint/no-unused-vars -- امضا برای سازگاری با فراخوان‌های موجود حفظ شده */
export async function ensureRoomAesKey(
myUserId: string,
roomId: string,
/** Bypass the in-memory/IndexedDB cache and re-unwrap from the server's current wrapped key. */
forceFresh = false
roomId: string
): Promise<CryptoKey | null> {
if (!forceFresh) {
const cached = roomAesCache.get(roomId);
if (cached) return cached;
}
const { privateKey } = await ensureIdentityKeys(myUserId);
const info = await fetchRoomKeys(roomId);
if (!info) return null;
let raw = forceFresh ? null : await loadCachedRoomRaw(myUserId, roomId);
if (!raw && info.myWrappedKey) {
try {
raw = await unwrapRoomKey(info.myWrappedKey, privateKey);
await persistRoomRaw(myUserId, roomId, raw);
} catch {
raw = null;
}
}
if (!raw && !info.e2ee_enabled) {
raw = await generateRoomKeyRaw();
await persistRoomRaw(myUserId, roomId, raw);
const wraps: { userId: string; wrappedKey: string }[] = [];
for (const m of info.memberPublicKeys) {
if (!m.publicKey) continue;
try {
const pub = await importPublicKeySpkiB64(m.publicKey);
wraps.push({
userId: m.userId,
wrappedKey: await wrapRoomKeyForRecipient(raw, pub),
});
} catch {
/* skip */
}
}
if (!wraps.some((w) => w.userId === myUserId)) {
const myPub = info.memberPublicKeys.find((m) => m.userId === myUserId);
if (myPub?.publicKey) {
const pub = await importPublicKeySpkiB64(myPub.publicKey);
wraps.push({
userId: myUserId,
wrappedKey: await wrapRoomKeyForRecipient(raw, pub),
});
}
}
await putRoomKeys(roomId, { e2ee_enabled: true, wrapped_keys: wraps });
return roomAesCache.get(roomId) || (await persistRoomRaw(myUserId, roomId, raw));
}
if (raw) {
// Sync wraps for members missing keys
if (info.missingWrapUserIds?.length) {
const wraps: { userId: string; wrappedKey: string }[] = [];
for (const uid of info.missingWrapUserIds) {
const m = info.memberPublicKeys.find((x) => x.userId === uid);
if (!m?.publicKey) continue;
try {
const pub = await importPublicKeySpkiB64(m.publicKey);
wraps.push({
userId: uid,
wrappedKey: await wrapRoomKeyForRecipient(raw, pub),
});
} catch {
/* skip */
}
}
if (wraps.length) {
await putRoomKeys(roomId, { e2ee_enabled: true, wrapped_keys: wraps });
}
}
return persistRoomRaw(myUserId, roomId, raw);
}
return null;
}
/* eslint-enable @typescript-eslint/no-unused-vars */
export async function encryptRoomContent(
myUserId: string,
roomId: string,
plaintext: string
): Promise<string | null> {
if (!plaintext) return plaintext;
if (isE2eeEnvelope(plaintext)) return plaintext;
const key = await ensureRoomAesKey(myUserId, roomId);
if (!key) return null;
return encryptText(key, plaintext);
return plaintext;
}
export async function decryptRoomContent(
@@ -180,22 +26,9 @@ export async function decryptRoomContent(
roomId: string,
content: string
): Promise<string> {
if (!isE2eeEnvelope(content)) return content;
const key = await ensureRoomAesKey(myUserId, roomId);
if (key) {
const pt = await decryptText(key, content);
if (pt != null) return pt;
}
// کلید کش‌شده ممکن است دیگر با wrapped key فعلی سرور هم‌خوان نباشد — یک بار تازه امتحان کن
const freshKey = await ensureRoomAesKey(myUserId, roomId, true);
if (freshKey) {
const pt = await decryptText(freshKey, content);
if (pt != null) return pt;
}
return E2EE_LOCKED_PREVIEW;
// فقط پیامِ قدیمیِ رمزنگاری‌شده (از قبلِ غیرفعال‌سازیِ E2EE) به این‌جا می‌رسه
if (isE2eeEnvelope(content)) return E2EE_LOCKED_PREVIEW;
return content;
}
export async function decryptRoomMessageList<
@@ -208,24 +41,14 @@ export async function decryptRoomMessageList<
roomId: string,
messages: T[]
): Promise<T[]> {
await ensureRoomAesKey(myUserId, roomId);
const out: T[] = [];
for (const msg of messages) {
let next = msg;
if (msg.content && isE2eeEnvelope(msg.content)) {
const decrypted = await decryptRoomContent(myUserId, roomId, msg.content);
next = { ...next, content: decrypted };
next = { ...next, content: E2EE_LOCKED_PREVIEW };
}
if (next.replyTo?.content && isE2eeEnvelope(next.replyTo.content)) {
const replyContent = await decryptRoomContent(
myUserId,
roomId,
next.replyTo.content
);
next = {
...next,
replyTo: { ...next.replyTo, content: replyContent },
};
next = { ...next, replyTo: { ...next.replyTo, content: E2EE_LOCKED_PREVIEW } };
}
out.push(next);
}

View File

@@ -1,4 +1,4 @@
import { optimizeImageToWebP, optimizeImages } from "./optimizeImage";
import { optimizeImageToWebP, optimizeImages, isImageAspectRatioAllowed } from "./optimizeImage";
import { optimizeVideoToMp4 } from "./optimizeVideo";
export async function optimizeMediaFile(
@@ -14,4 +14,4 @@ export async function optimizeMediaFile(
return file;
}
export { optimizeImageToWebP, optimizeImages, optimizeVideoToMp4 };
export { optimizeImageToWebP, optimizeImages, optimizeVideoToMp4, isImageAspectRatioAllowed };

View File

@@ -2,6 +2,27 @@
const MAX_EDGE = 1920;
const WEBP_QUALITY = 0.85;
// حداکثر کشیدگیِ مجاز برای عکسِ آپلودی توی پست/دوره — هیچ طرفی نباید بیشتر از ۴ برابرِ
// طرفِ دیگه باشه (یعنی حداقل نسبت ۱:۴)، وگرنه کارت‌های گرید/فید با این عکس به‌هم می‌ریزن —
// دقیقاً همون قانونِ isImageAspectRatioAllowed توی shared/lib/media.ts اندروید
export const MAX_UPLOAD_ASPECT_RATIO = 4;
/** true یعنی مجازه. فایل‌های غیرعکسی یا خراب رو رد نمی‌کنه (فقط وقتی واقعاً می‌تونیم
* ابعادش رو بخونیم چک می‌کنیم) */
export async function isImageAspectRatioAllowed(file: File): Promise<boolean> {
if (!file.type.startsWith("image/")) return true;
try {
const bitmap = await createImageBitmap(file);
const { width, height } = bitmap;
bitmap.close();
if (!width || !height) return true;
const ratio = Math.max(width, height) / Math.min(width, height);
return ratio <= MAX_UPLOAD_ASPECT_RATIO;
} catch {
return true;
}
}
export async function optimizeImageToWebP(file: File): Promise<File> {
if (!file.type.startsWith("image/")) return file;
if (file.type === "image/webp" && file.size < 2 * 1024 * 1024) return file;

12
src/lib/seo/safeJsonLd.ts Normal file
View File

@@ -0,0 +1,12 @@
/**
* JSON.stringify فقط کوتیشن/بک‌اسلش رو escape می‌کنه، نه `<` رو — پس اگه هر فیلدی
* که از کاربر میاد (بایو، توضیحاتِ فروشگاه/کالا و...) توی جیسون-الدی قرار بگیره،
* یه مقدار مثل `</script><script>...</script>` تگِ اسکریپتِ جیسون-الدی رو زودتر
* می‌بنده و باقیِ محتوا رو به‌عنوان اسکریپتِ واقعی اجرا می‌کنه (XSS ذخیره‌شده، چون
* توکنِ auth این اپ عمداً httpOnly نیست و از جاوااسکریپت قابل‌خوندنه). escapeِ
* کاراکترِ `<` (استانداردترین راهِ رفعِ این کلاس از حمله برای JSON-LD) این مسیر رو
* می‌بنده، بدون اینکه ساختارِ خودِ جیسون رو خراب کنه.
*/
export function safeJsonLdString(data: unknown): string {
return JSON.stringify(data).replace(/</g, "\\u003c");
}

View File

@@ -2776,6 +2776,10 @@
"cancelOrderConfirmTitle": "هل ترغب في إلغاء فاتورتك؟",
"cancelOrderConfirmDesc": "في حال الإلغاء، سيتم تحويل المبلغ المدفوع إلى محفظتك",
"orderCancelled": "تم إلغاء الطلب وإعادة المبلغ إلى محفظتك",
"shopNotFoundTitle": "المتجر غير موجود",
"productNotFoundTitle": "المنتج غير موجود",
"inPerson": "حضوري",
"online": "عبر الإنترنت",
"receiptConfirmed": "تم تأكيد الاستلام",
"rateShop": "تقييم ومراجعة",
"ratingCommentPlaceholder": "رأيك في المتجر (اختياري)",

View File

@@ -319,7 +319,15 @@
"types": {
"offer": "Collaboration request",
"advertising": "Advertising",
"advertising-republish": "Advertising renewal",
"shop_order": "Shop purchase",
"create": "Project creation",
"installment": "Project installment",
"gift": "Gift",
"booking_deposit": "Booking deposit",
"wallet_withdrawal": "Wallet withdrawal",
"pro": "Pro course purchase",
"course": "Course purchase",
"other": "Other"
},
"statuses": {
@@ -1130,6 +1138,7 @@
"closeSuggestions": "Close suggestions",
"suggestedCreators": "Suggested creators",
"suggestedCreatorsHint": "Follow them to see their latest posts.",
"suggestedCreatorsSwipeHint": "Swipe to browse between users",
"prevSuggestion": "Previous suggestion",
"nextSuggestion": "Next suggestion",
"viewFollowingPosts": "View following feed",
@@ -1143,6 +1152,7 @@
"modeStory": "Story",
"videoOnly": "Video only",
"imageOnly": "Images only",
"aspectRatioTooExtreme": "This image is too elongated (more than a 1:4 ratio) — pick a different image",
"loginToPost": "Please sign in",
"storyMediaRequired": "Choose a photo or video for your story",
"publishingStory": "Publishing story…",
@@ -1588,7 +1598,8 @@
"best_month": "Best this month",
"near_me": "Near me",
"girls": "Girls",
"boys": "Boys"
"boys": "Boys",
"top_hashtags": "Top hashtags this month"
},
"empty": {
"default": "Nothing to show yet.",
@@ -1599,7 +1610,8 @@
"predict_trends": "No rising posts to forecast yet.",
"best_month": "No top posts this month.",
"hashtag": "No posts with #{{tag}}.",
"query": "No results for “{{query}}”."
"query": "No results for “{{query}}”.",
"top_hashtags": "No trending hashtags yet"
}
},
"reels": {
@@ -1619,7 +1631,7 @@
"title": "Find nearby friends",
"desc": "People within about 5 km",
"back": "Back",
"radiusHint": "Showing users within 5 km",
"radiusHintValue": "Showing users within {{km}} km",
"checking": "Checking location settings…",
"locating": "Getting your location…",
"locationOffTitle": "Location sharing is off",
@@ -1634,7 +1646,9 @@
"apiUnavailable": "Nearby friends is not available on the API server yet. Deploy the updated backend.",
"empty": "No one nearby right now.",
"found": "{{count}} nearby",
"refresh": "Refresh",
"refresh": "Refresh location",
"showOnMap": "Show on map",
"mapPrivacyHint": "For privacy, other users' positions on the map are approximate, not exact",
"distanceMeters": "About {{count}} m away",
"distanceKm": "About {{count}} km away",
"you": "You"
@@ -1699,6 +1713,7 @@
"online": "Online",
"offline": "Offline",
"onlineCount": "{{count}} online",
"groupStatusLine": "Group chat · {{count}} online",
"memberCount": "{{count}} members",
"user": "User",
"you": "You",
@@ -1842,6 +1857,10 @@
"allExpertise": "All expertise",
"publicRoomHint": "If you only enter a name, the room is visible to everyone. Filter by province, city, or expertise to limit visibility.",
"addMembersTitle": "Add users to room",
"membersTitle": "Group members",
"addMemberRow": "Add member",
"noMembers": "No members found",
"memberFallbackName": "User",
"forwardTo": "Forward to ({{selected}}/50)",
"photoPreview": "Photo preview",
"photosSelected": "{{count}} photos selected",
@@ -2835,6 +2854,10 @@
"cancelOrderConfirmTitle": "Do you want to cancel your invoice?",
"cancelOrderConfirmDesc": "If you cancel, the amount you paid will be transferred to your wallet",
"orderCancelled": "Order cancelled and the amount was refunded to your wallet",
"shopNotFoundTitle": "Shop not found",
"productNotFoundTitle": "Product not found",
"inPerson": "In person",
"online": "Online",
"receiptConfirmed": "Receipt confirmed",
"rateShop": "Rate & review",
"ratingCommentPlaceholder": "Your review of the shop (optional)",

View File

@@ -319,7 +319,15 @@
"types": {
"offer": "درخواست همکاری",
"advertising": "تبلیغات",
"advertising-republish": "تمدید تبلیغات",
"shop_order": "خرید از فروشگاه",
"create": "ایجاد پروژه",
"installment": "قسط پروژه",
"gift": "هدیه",
"booking_deposit": "بیعانه رزرو",
"wallet_withdrawal": "برداشت از کیف‌پول",
"pro": "خرید دوره پرو",
"course": "خرید دوره",
"other": "سایر"
},
"statuses": {
@@ -1130,6 +1138,7 @@
"closeSuggestions": "بستن پیشنهادها",
"suggestedCreators": "محتواسازان پیشنهادی",
"suggestedCreatorsHint": "برای دیدن جدیدترین پست‌ها، آن‌ها را دنبال کنید.",
"suggestedCreatorsSwipeHint": "می‌توانید با سوایپ بین کاربرها جابه‌جا شوید",
"prevSuggestion": "پیشنهاد قبلی",
"nextSuggestion": "پیشنهاد بعدی",
"viewFollowingPosts": "مشاهده پست‌های دنبال‌شوندگان",
@@ -1143,6 +1152,7 @@
"modeStory": "استوری",
"videoOnly": "فقط ویدئو مجاز است",
"imageOnly": "فقط تصویر مجاز است",
"aspectRatioTooExtreme": "این عکس خیلی کشیده است (بیشتر از نسبت ۱:۴) — عکس دیگری انتخاب کنید",
"loginToPost": "لطفاً وارد حساب کاربری شوید",
"storyMediaRequired": "انتخاب عکس یا ویدئو برای استوری الزامی است",
"publishingStory": "در حال انتشار استوری…",
@@ -1588,7 +1598,8 @@
"best_month": "بهترین‌های این ماه",
"near_me": "نزدیک من",
"girls": "دخترانه",
"boys": "پسرانه"
"boys": "پسرانه",
"top_hashtags": "هشتگ‌های برتر ماه"
},
"empty": {
"default": "محتوایی برای نمایش وجود ندارد.",
@@ -1599,7 +1610,8 @@
"predict_trends": "پستی با پتانسیل ترند شدن یافت نشد.",
"best_month": "پست برتر این ماه یافت نشد.",
"hashtag": "پستی با هشتگ #{{tag}} یافت نشد.",
"query": "نتیجه‌ای برای «{{query}}» یافت نشد."
"query": "نتیجه‌ای برای «{{query}}» یافت نشد.",
"top_hashtags": "هنوز هشتگ پربازدیدی ثبت نشده"
}
},
"reels": {
@@ -1619,7 +1631,7 @@
"title": "یافتن دوستان نزدیک",
"desc": "کاربران در شعاع حدود ۵ کیلومتر",
"back": "بازگشت",
"radiusHint": "نمایش کاربران تا شعاع ۵ کیلومتر",
"radiusHintValue": "نمایش کاربران تا شعاع {{km}} کیلومتر",
"checking": "در حال بررسی تنظیمات موقعیت…",
"locating": "در حال دریافت موقعیت شما…",
"locationOffTitle": "نمایش موقعیت خاموش است",
@@ -1634,7 +1646,9 @@
"apiUnavailable": "سرویس دوستان نزدیک روی سرور در دسترس نیست. بک‌اند را به‌روز کنید.",
"empty": "فعلاً کسی در این محدوده نیست.",
"found": "{{count}} نفر نزدیک",
"refresh": "بروزرسانی",
"refresh": "بروزرسانی موقعیت",
"showOnMap": "نمایش روی نقشه",
"mapPrivacyHint": "به‌خاطر حریم خصوصی، موقعیت کاربران دیگر روی نقشه تقریبی است، نه دقیق",
"distanceMeters": "حدود {{count}} متر فاصله",
"distanceKm": "حدود {{count}} کیلومتر فاصله",
"you": "شما"
@@ -1699,6 +1713,7 @@
"online": "آنلاین",
"offline": "آفلاین",
"onlineCount": "{{count}} آنلاین",
"groupStatusLine": "گفتگوی گروهی · {{count}} آنلاین",
"memberCount": "{{count}} عضو",
"user": "کاربر",
"you": "شما",
@@ -1841,6 +1856,10 @@
"allExpertise": "همه تخصص‌ها",
"publicRoomHint": "اگر فقط نام را وارد کنید، اتاق برای همه نمایش داده می‌شود. با انتخاب استان، شهر یا تخصص، فقط کاربران منطبق آن را می‌بینند.",
"addMembersTitle": "افزودن کاربر به اتاق",
"membersTitle": "اعضای گروه",
"addMemberRow": "افزودن عضو",
"noMembers": "عضوی یافت نشد",
"memberFallbackName": "کاربر",
"forwardTo": "فوروارد به ({{selected}}/50)",
"photoPreview": "پیش‌نمایش عکس",
"photosSelected": "{{count}} عکس انتخاب شده",
@@ -2840,6 +2859,10 @@
"cancelOrderConfirmTitle": "آیا مایلید فاکتور خود را کنسل کنید؟",
"cancelOrderConfirmDesc": "در صورت کنسل کردن، مبلغ پرداختی شما به کیف پول شما منتقل خواهد شد",
"orderCancelled": "سفارش کنسل شد و مبلغ به کیف پول شما بازگشت داده شد",
"shopNotFoundTitle": "فروشگاه یافت نشد",
"productNotFoundTitle": "کالا یافت نشد",
"inPerson": "حضوری",
"online": "اینترنتی",
"receiptConfirmed": "دریافت کالا ثبت شد",
"rateShop": "ثبت نظر و امتیاز",
"ratingCommentPlaceholder": "نظر شما درباره فروشگاه (اختیاری)",

View File

@@ -1,18 +1,59 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import React, { ReactNode } from "react";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
},
},
});
export function ReactQueryProvider({ children }: { children: ReactNode }) {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}
"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 (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}
return (
<PersistQueryClientProvider
client={queryClient}
persistOptions={{
persister,
maxAge: ONE_DAY_MS,
dehydrateOptions: {
shouldDehydrateQuery: (query) => query.state.status === "success",
},
}}
>
{children}
</PersistQueryClientProvider>
);
}