check
This commit is contained in:
18
src/api/fetchTopHashtags.ts
Normal file
18
src/api/fetchTopHashtags.ts
Normal 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 [];
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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] || "#";
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
114
src/components/chat/ChatRoomMembersModal.tsx
Normal file
114
src/components/chat/ChatRoomMembersModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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" ? (
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`}>
|
||||
|
||||
@@ -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) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" },
|
||||
];
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
12
src/lib/seo/safeJsonLd.ts
Normal 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");
|
||||
}
|
||||
@@ -2776,6 +2776,10 @@
|
||||
"cancelOrderConfirmTitle": "هل ترغب في إلغاء فاتورتك؟",
|
||||
"cancelOrderConfirmDesc": "في حال الإلغاء، سيتم تحويل المبلغ المدفوع إلى محفظتك",
|
||||
"orderCancelled": "تم إلغاء الطلب وإعادة المبلغ إلى محفظتك",
|
||||
"shopNotFoundTitle": "المتجر غير موجود",
|
||||
"productNotFoundTitle": "المنتج غير موجود",
|
||||
"inPerson": "حضوري",
|
||||
"online": "عبر الإنترنت",
|
||||
"receiptConfirmed": "تم تأكيد الاستلام",
|
||||
"rateShop": "تقييم ومراجعة",
|
||||
"ratingCommentPlaceholder": "رأيك في المتجر (اختياري)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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": "نظر شما درباره فروشگاه (اختیاری)",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user