This commit is contained in:
payacom
2026-09-24 20:56:23 +03:30
parent 3b89bbfec3
commit 200b387504
154 changed files with 4984 additions and 2169 deletions

View File

@@ -1,11 +0,0 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "modstagram-next-dev",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 3004
}
]
}

Submodule .claude/worktrees/agent-af1dfc8fe9a6cb198 added at 3b89bbfec3

View File

@@ -1,24 +0,0 @@
"use client";
import { useState } from "react";
import Step1 from "./Step1";
import Step2 from "./Step2";
import Step3 from "./Step3";
import Step4 from "./Step4";
import Step5 from "./Step5";
const MultiStepForm = () => {
const [step, setStep] = useState(1);
return (
<div className="p-4 flex flex-col items-center w-full">
{step === 1 && <Step1 nextStep={() => setStep(2)} />}
{step === 2 && <Step2 nextStep={() => setStep(3)} />}
{step === 3 && <Step3 nextStep={() => setStep(4)} />}
{step === 4 && <Step4 nextStep={() => setStep(5)} />}
{step === 5 && <Step5 />}
</div>
);
};
export default MultiStepForm;

View File

@@ -1,151 +0,0 @@
"use client";
import RoundedButton from "@/components/elements/RoundedButton";
import { useFormik } from "formik";
import * as Yup from "yup";
import LocationSelector from "./step1/LocationSelector";
import { useBillboardForm } from "@/contexts/BillboardFormContext";
import { useEffect, useMemo, useState } from "react";
import useAxios from "@/hooks/useAxios";
import SelectBox from "@/components/elements/SelectBox";
import { IAdvertisingCategory } from "@/types/types";
import RoundedInput from "@/components/elements/RoundedInput";
import ImageUploader from "./step1/ImageUploader";
import { useTranslation } from "react-i18next";
const Step1 = ({ nextStep }: { nextStep: () => void }) => {
const { t } = useTranslation("common");
const { formData, updateForm } = useBillboardForm();
const [categories, setCategories] = useState<IAdvertisingCategory[]>([]);
const { request } = useAxios();
const validationSchema = useMemo(
() =>
Yup.object({
stateId: Yup.string().required(t("billboards.form.validation.provinceRequired")),
cityId: Yup.string().required(t("billboards.form.validation.cityRequired")),
categoryId: Yup.string().required(t("billboards.form.validation.categoryRequired")),
adsTitle: Yup.string().required(t("billboards.form.validation.titleRequired")),
description: Yup.string().required(t("billboards.form.validation.descriptionRequired")),
neighbourhood: Yup.string().required(t("billboards.form.validation.neighbourhoodRequired")),
address: Yup.string().required(t("billboards.form.validation.addressRequired")),
images: Yup.array()
.min(1, t("billboards.form.validation.minImages"))
.max(5, t("billboards.form.validation.maxImages"))
.required(t("billboards.form.validation.imagesRequired")),
markerCoordinate: Yup.array()
.of(Yup.number().required())
.length(2, t("billboards.form.validation.mapLocationRequired"))
.required(t("billboards.form.validation.mapLocationRequired")),
}),
[t]
);
useEffect(() => {
const fetchData = async () => {
try {
const response = await request<{ categories: IAdvertisingCategory[] }>(
"GET",
"/advertising/categories"
);
setCategories(response?.categories);
} catch (err) {
console.log(err);
}
};
fetchData();
}, [request]);
const formik = useFormik({
initialValues: {
cityId: formData.cityId || "",
stateId: formData.stateId || "",
adsTitle: formData.adsTitle || "",
categoryId: formData.categoryId || "",
images: formData.images || [],
address: formData.address || "",
neighbourhood: formData.neighbourhood || "",
description: formData.description || "",
markerCoordinate: formData.markerCoordinate || null,
},
validationSchema,
onSubmit: (values) => {
updateForm(values);
nextStep();
},
});
return (
<form
onSubmit={formik.handleSubmit}
className="flex flex-col gap-2 w-full items-center text-sm"
>
<h6 className="font-bold text-xl mb-2">{t("billboards.createTitle")}</h6>
<SelectBox
className={`max-w-full bg-secondary-light dark:bg-secondary-dark ${
formik.touched.categoryId && formik.errors.categoryId
? "border-red-500 dark:border-red-500"
: ""
}`}
value={formik.values.categoryId}
onChange={(e) => formik.setFieldValue("categoryId", e.target.value)}
>
<option className="bg-secondary-light dark:bg-secondary-dark" disabled value="">
{t("billboards.category")}
</option>
{categories?.map((item: IAdvertisingCategory) => (
<option
className="bg-secondary-light dark:bg-secondary-dark"
key={item._id}
value={item.title}
>
{item?.title}
</option>
))}
</SelectBox>
{formik.touched.categoryId && formik.errors.categoryId && (
<p className="text-red-500 text-xs">{formik.errors.categoryId}</p>
)}
<hr />
<RoundedInput
className={`max-w-full ${
formik.touched.stateId && formik.errors.stateId
? "border-red-500 dark:border-red-500"
: ""
}`}
type="text"
placeholder={t("billboards.title")}
{...formik.getFieldProps("adsTitle")}
/>
{formik.touched.adsTitle && formik.errors.adsTitle && (
<p className="text-red-500 text-xs">{formik.errors.adsTitle}</p>
)}
<textarea
className={`w-full p-4 mt-2 h-24 rounded-3xl border
${
formik.touched.description && formik.errors.description
? "border-red-500 dark:border-red-500"
: ""
}
border-neutral-950 text-neutral-900 dark:text-neutral-50 dark:border-neutral-400 border font-medium`}
placeholder={t("billboards.description")}
{...formik.getFieldProps("description")}
/>
{formik.touched.description && formik.errors.description && (
<p className="text-red-500 text-xs">{formik.errors.description}</p>
)}
<hr className="w-full my-2" />
<LocationSelector formik={formik} />
<ImageUploader formik={formik} />
{formik.touched.images && formik.errors.images && (
<p className="text-red-500 text-xs">{formik.errors.images}</p>
)}
<RoundedButton type="submit" variant="primary" className="mt-4 px-8 py-2">
{t("billboards.submitContinue")}
</RoundedButton>
</form>
);
};
export default Step1;

View File

@@ -1,89 +0,0 @@
"use client";
import RoundedButton from "@/components/elements/RoundedButton";
import { useFormik } from "formik";
import * as Yup from "yup";
import { useBillboardForm } from "@/contexts/BillboardFormContext";
import { useMemo, useState } from "react";
import { Service } from "@/types/types";
import ServiceItem from "@/components/main/Services/ServiceItem";
import AddServiceModal from "@/components/main/Services/AddServiceModal";
import { useTranslation } from "react-i18next";
const Step2 = ({ nextStep }: { nextStep: () => void }) => {
const { t } = useTranslation("common");
const { formData, updateForm } = useBillboardForm();
const [isModalOpen, setModalOpen] = useState(false);
const validationSchema = useMemo(
() =>
Yup.object({
services: Yup.array().min(1, t("billboards.form.validation.minServices")),
}),
[t]
);
const formik = useFormik({
initialValues: {
services: formData.services || [],
},
validationSchema,
onSubmit: (values) => {
updateForm(values);
nextStep();
},
});
const services = formik.values.services;
const handleAddService = (newService: Service) => {
formik.setFieldValue("services", [...services, newService]);
};
const handleDeleteService = (id: string) => {
formik.setFieldValue(
"services",
services.filter((service) => service.id !== id)
);
};
return (
<form
onSubmit={formik.handleSubmit}
className="flex flex-col gap-2 w-full items-center text-sm pb-28"
>
<h6 className="font-bold text-xl mb-2">{t("billboards.createTitle")}</h6>
<div className="gap-4 min-h-96 w-full">
{services.map((service) => (
<ServiceItem
onDelete={handleDeleteService}
key={service.id}
service={service}
/>
))}
</div>
<hr className="w-full" />
<div className="sticky bottom-[calc(5.5rem+env(safe-area-inset-bottom))] z-20 flex w-full justify-center gap-4 bg-background/95 py-3 backdrop-blur-sm">
<RoundedButton type="submit" variant="primary" className="h-9 w-32">
{t("billboards.submitContinue")}
</RoundedButton>
<RoundedButton
onClick={() => setModalOpen(true)}
className="!text-[#0066FF] !border-[#0066FF] w-32 h-9"
type="button"
>
{t("billboards.add")}
</RoundedButton>
</div>
{isModalOpen && (
<AddServiceModal
onClose={() => setModalOpen(false)}
onAdd={handleAddService}
isModalOpen={isModalOpen}
/>
)}
</form>
);
};
export default Step2;

View File

@@ -1,176 +0,0 @@
"use client";
import RoundedButton from "@/components/elements/RoundedButton";
import { useFormik } from "formik";
import * as Yup from "yup";
import { useBillboardForm } from "@/contexts/BillboardFormContext";
import useAxios from "@/hooks/useAxios";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
export interface IFeatureFetch {
title?: string;
value: boolean;
_id: string;
}
const Step3 = ({ nextStep }: { nextStep: () => void }) => {
const { t } = useTranslation("common");
const { formData, updateForm } = useBillboardForm();
const { request } = useAxios();
const [features, setFeatures] = useState<IFeatureFetch[]>([]);
const validationSchema = useMemo(
() =>
Yup.object({
selectedFeatures: Yup.array().min(1, t("billboards.form.validation.minFeatures")),
}),
[t]
);
useEffect(() => {
const fetchData = async () => {
try {
const response = await request<{ features: IFeatureFetch[] }>(
"GET",
"/advertising/features"
);
setFeatures(response.features);
} catch (err) {
console.log(err);
}
};
fetchData();
}, [request]);
const formik = useFormik({
initialValues: {
selectedFeatures: formData.selectedFeatures || [],
},
validationSchema,
onSubmit: (values) => {
updateForm(values);
nextStep();
},
enableReinitialize: true,
});
const handleSelect = (
featureId: string,
value: boolean,
title: string | undefined
) => {
formik.setFieldValue("selectedFeatures", [
...formik.values.selectedFeatures.filter(
(feature) => feature._id !== featureId
),
{ _id: featureId, value, title: title },
]);
};
return (
<form
onSubmit={formik.handleSubmit}
className="flex flex-col gap-4 w-full items-center text-sm"
>
<h6 className="font-bold text-xl mb-2">{t("billboards.createTitle")}</h6>
<div className="flex flex-col w-full gap-3">
{features.map((feature: IFeatureFetch) => {
const isSelected = formik.values.selectedFeatures.some(
(featureItem) =>
featureItem._id === feature._id && featureItem.value === true
);
const isDeselected = formik.values.selectedFeatures.some(
(featureItem) =>
featureItem._id === feature._id && featureItem.value === false
);
return (
<div
key={feature._id}
className="flex justify-between items-center w-full p-4 border-b border-gray-300"
>
<span className="text-lg">{feature.title}</span>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name={`feature-${feature._id}`}
value="true"
checked={isSelected}
onChange={() =>
handleSelect(feature._id, true, feature?.title)
}
className="hidden"
/>
<span>{t("billboards.form.has")}</span>
<div
className={`w-6 h-6 border-2 rounded-full flex items-center justify-center border-green-500`}
>
{isSelected && (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="#01A168"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M20 6L9 17l-5-5" />
</svg>
)}
</div>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name={`feature-${feature._id}`}
value="false"
checked={isDeselected}
onChange={() =>
handleSelect(feature._id, false, feature?.title)
}
className="hidden"
/>
<span>{t("billboards.form.hasNot")}</span>
<div
className={`w-6 h-6 border-2 rounded-full flex items-center justify-center border-red-500`}
>
{isDeselected && (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="#FF0000"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M20 6L9 17l-5-5" />
</svg>
)}
</div>
</label>
</div>
</div>
);
})}
</div>
{formik.touched.selectedFeatures &&
formik.errors.selectedFeatures &&
typeof formik.errors.selectedFeatures === "string" && (
<p className="text-red-500">{formik.errors.selectedFeatures}</p>
)}
<RoundedButton type="submit" variant="primary" className="mt-4 h-9 w-32">
{t("billboards.submitContinue")}
</RoundedButton>
</form>
);
};
export default Step3;

View File

@@ -1,193 +0,0 @@
"use client";
import RoundedButton from "@/components/elements/RoundedButton";
import { useFormik } from "formik";
import * as Yup from "yup";
import { useBillboardForm } from "@/contexts/BillboardFormContext";
import { useEffect, useState } from "react";
import useAxios from "@/hooks/useAxios";
import RoundedInput from "@/components/elements/RoundedInput";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
const validationSchema = Yup.object({
landline: Yup.string(),
mobile: Yup.string(),
telegram: Yup.string(),
whatsapp: Yup.string(),
instagram: Yup.string(),
saveData: Yup.boolean(),
});
interface ContactInfo {
landline?: string;
mobile?: string;
telegram?: string;
whatsapp?: string;
instagram?: string;
saveData?: boolean;
}
interface ApiResponse {
data: {
contactInfo: ContactInfo;
};
}
const Step4 = ({ nextStep }: { nextStep: () => void }) => {
const { t } = useTranslation("common");
const { formData, updateForm } = useBillboardForm();
const { request } = useAxios();
const [isCheckedOne, setIsCheckedOne] = useState<boolean>(false);
const formik = useFormik({
initialValues: {
landline: formData.landline || "",
mobile: formData.mobile || "",
telegram: formData.telegram || "",
whatsapp: formData.whatsapp || "",
instagram: formData.instagram || "",
saveData: formData.saveData || false,
},
validationSchema,
onSubmit: (values) => {
if (
!formik?.values?.landline &&
!formik?.values?.mobile &&
!formik?.values?.telegram &&
!formik?.values?.whatsapp &&
!formik?.values?.instagram
) {
toast.error(t("billboards.form.contactRequired"));
} else {
updateForm(values);
nextStep();
}
},
});
useEffect(() => {
const fetchData = async () => {
try {
const response = await request<ApiResponse>(
"GET",
"/users/contact-info"
);
const contactInfo = response?.data?.contactInfo;
if (contactInfo) {
formik.setFieldValue("landline", contactInfo.landline || "");
formik.setFieldValue("mobile", contactInfo.mobile || "");
formik.setFieldValue("telegram", contactInfo.telegram || "");
formik.setFieldValue("whatsapp", contactInfo.whatsapp || "");
formik.setFieldValue("instagram", contactInfo.instagram || "");
formik.setFieldValue("saveData", contactInfo.saveData || false);
}
} catch (err) {
console.error("Error fetching contact info:", err);
}
};
fetchData();
}, [request]);
const toggleCheckBox = () => {
formik.setFieldValue("saveData", !isCheckedOne);
setIsCheckedOne(!isCheckedOne);
};
return (
<form
onSubmit={formik.handleSubmit}
className="flex flex-col gap-2 w-full items-center text-sm"
>
<h6 className="font-bold text-xl mb-2">{t("billboards.createTitle")}</h6>
<RoundedInput
className={`max-w-full mt-2 ${
formik.touched.landline && formik.errors.landline
? "border-red-500 dark:border-red-500"
: "border-gray-300"
}`}
type="text"
maxLength={11}
placeholder={t("billboards.form.landline")}
{...formik.getFieldProps("landline")}
/>
{formik.touched.landline && formik.errors.landline && (
<p className="text-red-500 text-xs">{formik.errors.landline}</p>
)}
<RoundedInput
className={`max-w-full mt-2 ${
formik.touched.mobile && formik.errors.mobile
? "border-red-500 dark:border-red-500"
: "border-gray-300"
}`}
type="text"
maxLength={11}
placeholder={t("billboards.form.mobile")}
{...formik.getFieldProps("mobile")}
/>
{formik.touched.mobile && formik.errors.mobile && (
<p className="text-red-500 text-xs">{formik.errors.mobile}</p>
)}
<RoundedInput
className={`max-w-full mt-2 ${
formik.touched.telegram && formik.errors.telegram
? "border-red-500 dark:border-red-500"
: "border-gray-300"
}`}
type="text"
placeholder={t("billboards.form.telegram")}
{...formik.getFieldProps("telegram")}
/>
{formik.touched.telegram && formik.errors.telegram && (
<p className="text-red-500 text-xs">{formik.errors.telegram}</p>
)}
<RoundedInput
className={`max-w-full mt-2 ${
formik.touched.whatsapp && formik.errors.whatsapp
? "border-red-500 dark:border-red-500"
: "border-gray-300"
}`}
type="text"
placeholder={t("billboards.form.whatsapp")}
maxLength={11}
{...formik.getFieldProps("whatsapp")}
/>
{formik.touched.whatsapp && formik.errors.whatsapp && (
<p className="text-red-500 text-xs">{formik.errors.whatsapp}</p>
)}
<RoundedInput
className={`max-w-full mt-2 ${
formik.touched.instagram && formik.errors.instagram
? "border-red-500 dark:border-red-500"
: "border-gray-300"
}`}
type="text"
placeholder={t("billboards.form.instagram")}
{...formik.getFieldProps("instagram")}
/>
{formik.touched.instagram && formik.errors.instagram && (
<p className="text-red-500 text-xs">{formik.errors.instagram}</p>
)}
<label className="flex items-center gap-2 justify-start w-full pr-4 mt-4">
<input
{...formik.getFieldProps("stateId")}
className="scale-125"
type="checkbox"
onChange={toggleCheckBox}
/>
<span>{t("billboards.form.saveContactInfo")}</span>
</label>
<hr />
<RoundedButton type="submit" variant="primary" className="mt-4 px-8 py-2">
{t("billboards.submitContinue")}
</RoundedButton>
</form>
);
};
export default Step4;

View File

@@ -1,268 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
"use client";
import RoundedButton from "@/components/elements/RoundedButton";
import { useFormik } from "formik";
import * as Yup from "yup";
import { useEffect, useMemo, useState } from "react";
import useAxios from "@/hooks/useAxios";
import { IAdvertisingType } from "@/types/types";
import { sampleAds, sampleAdsHighlight, sampleAdsSpecial } from "@/constants";
import RoundedDiv from "@/components/elements/RoundedDiv";
import { selectionCardClass } from "@/lib/ui/buttonStyles";
import { useRouter } from "next/navigation";
import MainBillboardCard from "@/components/billboards/MainBillboardCard/MainBillboardCard";
import { dataURLtoBlob } from "@/helpers/helpers";
import { useBillboardForm } from "@/contexts/BillboardFormContext";
import { getBillboardDisplayTypeOptionLabel } from "@/lib/billboards/displayTypeLabel";
import { useTranslation } from "react-i18next";
interface CreateAdvertisingResponse {
success: boolean;
message: string;
data: {
id: string;
};
}
const Step5 = () => {
const { t } = useTranslation("common");
const router = useRouter();
const { request } = useAxios();
const { formData, updateForm, isEditing } = useBillboardForm();
const [selectedType, setSelectedType] = useState("normal");
const [showDiscount, setShowDiscount] = useState<boolean>(false);
const [typeList, setTypeList] = useState<IAdvertisingType[] | null>(null);
const validationSchema = useMemo(
() =>
Yup.object({
selectedType: Yup.string().required(
t("billboards.form.validation.displayTypeRequired")
),
}),
[t]
);
const {
adsTitle,
categoryId,
description,
stateId,
cityId,
neighbourhood,
address,
markerCoordinate,
services,
selectedFeatures,
landline,
mobile,
telegram,
whatsapp,
instagram,
saveData,
images,
} = formData;
const toggleCheckBox = () => {
setShowDiscount(!showDiscount);
};
const fetchStates = async () => {
try {
const response = await request<{ advertisingTypes: IAdvertisingType[] }>(
"GET",
"/advertising/types"
);
setTypeList(response?.advertisingTypes || null);
} catch (err) {
console.log(err);
}
};
useEffect(() => {
fetchStates();
}, []);
const formik = useFormik({
initialValues: {
selectedType: formData.selectedType || "",
},
validationSchema,
onSubmit: async (values) => {
updateForm(values);
const data = new FormData();
data.append("title", adsTitle);
data.append("category", categoryId);
if (description) {
data.append("description", description);
}
data.append("province", stateId ? stateId : "");
data.append("city", cityId ? cityId : "");
if (neighbourhood) {
data.append("neighbourhood", neighbourhood);
}
if (address) {
data.append("address", address);
}
if (markerCoordinate) {
data.append("lat", markerCoordinate[0]);
data.append("lng", markerCoordinate[1]);
}
data.append("services", JSON.stringify(services));
data.append("features", JSON.stringify(selectedFeatures));
if (landline) {
data.append("contactInfo[phone]", landline);
}
if (mobile) {
data.append("contactInfo[mobile]", mobile);
}
if (telegram) {
data.append("contactInfo[telegramLink]", telegram);
}
if (whatsapp) {
data.append("contactInfo[whatsappNumber]", whatsapp);
}
if (instagram) {
data.append("contactInfo[instagramLink]", instagram);
}
if (saveData !== null && typeof saveData === "boolean") {
data.append("contactInfo[saveInfoForNextAds]", String(saveData));
}
if (showDiscount !== null && typeof showDiscount === "boolean") {
data.append("showDiscount", String(showDiscount));
}
data.append("type", selectedType);
const mostDiscountPercentage =
services.length > 0
? services.reduce<number>((max, service) => {
const discount =
typeof service.discountPercentage === "number"
? service.discountPercentage
: 0;
return discount > max ? discount : max;
}, 0)
: 0;
if (mostDiscountPercentage) {
data.append("mostDiscountPercentage", String(mostDiscountPercentage));
}
images?.forEach((image) => {
if (typeof image === "string" && image.startsWith("/advertising/")) {
data.append("existingImages[]", image);
} else {
const blob = dataURLtoBlob(image);
data.append("images", blob);
}
});
services.forEach((service) => {
if (
typeof service.image === "string" &&
service.image.startsWith("/services/")
) {
data.append(`existingServiceImages[${service.id}]`, service.image);
} else if (service.image) {
const blob = dataURLtoBlob(service.image);
data.append(
`serviceImages[${service.id}]`,
blob,
`service-${service.id}.jpg`
);
}
});
try {
if (isEditing) {
await request<{ id: string; message: string }>(
"POST",
`/advertising/edit/${formData?._id}`,
data
);
localStorage.removeItem("billboardForm");
router.push(`/settings/my-billboards`);
} else {
const response = await request<CreateAdvertisingResponse>(
"POST",
"/advertising/create",
data
);
localStorage.removeItem("billboardForm");
router.push(`/billboards/new/${response.data.id}`);
}
} catch (err: any) {
console.log("Unhandled error:", err?.message);
}
},
});
useEffect(() => {
if (isEditing && formik.values.selectedType) {
formik.submitForm();
}
}, [isEditing, formik.values.selectedType]);
return (
<form
onSubmit={formik.handleSubmit}
className="flex flex-col gap-2 w-full items-center text-sm"
>
<h6 className="font-bold text-xl mb-2">{t("billboards.displayTypeTitle")}</h6>
<p>{t("billboards.displayTypeHint")}</p>
<div className="flex items-center gap-2 mt-10">
<input
className="scale-125"
type="checkbox"
onChange={toggleCheckBox}
/>
<span className="text-xs font-bold">{t("billboards.showDiscount")}</span>
</div>
{typeList?.map((item: IAdvertisingType) => {
return (
<div
onClick={() => {
setSelectedType(item?.name);
formik.setFieldValue("selectedType", item?.name);
}}
className="w-full flex flex-col items-center"
key={item?._id}
>
<MainBillboardCard
adStatus=""
billboard={
item?.name == "special"
? sampleAdsSpecial
: item?.name == "highlight"
? sampleAdsHighlight
: sampleAds
}
/>
<RoundedDiv
className={selectionCardClass(selectedType == item?.name)}
>
{getBillboardDisplayTypeOptionLabel(t, item.name)}:
{item.price !== 0
? !showDiscount
? Number(item.price).toLocaleString() + t("settings.toman")
: (Number(item.price) + 20000).toLocaleString() +
t("settings.toman")
: ` ${t("billboards.free")}`}
</RoundedDiv>
</div>
);
})}
{formik.touched.selectedType && formik.errors.selectedType && (
<p className="text-red-500">{formik.errors.selectedType}</p>
)}
<RoundedButton type="submit" variant="primary" className="mt-4 px-8 py-2">
{t("billboards.submitRequest")}
</RoundedButton>
</form>
);
};
export default Step5;

View File

@@ -1,90 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
"use client";
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
import { useState } from "react";
import { useTranslation } from "react-i18next";
type ImageUploaderProps = {
formik: any;
};
const ImageUploader: React.FC<ImageUploaderProps> = ({ formik }) => {
const { t } = useTranslation("common");
const [images, setImages] = useState<string[]>(formik.values.images || []);
const handleImageUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
if (!event.target.files) return;
const files = Array.from(event.target.files);
if (images.length + files.length > 5) {
alert(t("billboards.form.maxImagesAlert"));
return;
}
const newImages: string[] = [];
files.forEach((file) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => {
if (typeof reader.result === "string") {
newImages.push(reader.result);
if (newImages.length === files.length) {
const updatedImages = [...images, ...newImages];
setImages(updatedImages);
formik.setFieldValue("images", updatedImages);
}
}
};
});
};
const handleRemoveImage = (index: number) => {
const newImages = images.filter((_, i) => i !== index);
setImages(newImages);
formik.setFieldValue("images", newImages);
};
return (
<div className="flex flex-wrap gap-2 w-full mt-5">
{images.map((image, index) => (
<div
key={crypto.randomUUID()}
className="relative w-24 h-24 rounded-lg overflow-hidden border"
>
<img
src={
images[index].slice(0, 12) === "/advertising"
? IMAGE_BASE_URL + image
: image
}
alt="uploaded"
className="w-full h-full object-cover"
/>
<button
type="button"
className="absolute top-1 right-1 bg-black bg-opacity-50 p-1 px-2.5 rounded-full text-white"
onClick={() => handleRemoveImage(index)}
>
x
</button>
</div>
))}
{images.length < 5 && (
<label className="w-24 h-24 flex items-center justify-center border-dashed border-2 cursor-pointer rounded-lg">
<input
type="file"
multiple
accept="image/*"
className="hidden"
onChange={handleImageUpload}
/>
+
</label>
)}
</div>
);
};
export default ImageUploader;

View File

@@ -1,202 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
"use client";
import { ICity, IProvince } from "@/types/types";
import SelectBox from "@/components/elements/SelectBox";
import { useState, useEffect } from "react";
import useAxios from "@/hooks/useAxios";
import RoundedInput from "@/components/elements/RoundedInput";
import Map, { GeolocateControl, Marker } from "react-map-gl";
import "mapbox-gl/dist/mapbox-gl.css";
import Image from "next/image";
import { useBillboardForm } from "@/contexts/BillboardFormContext";
import { useTranslation } from "react-i18next";
interface LocationSelectorProps {
formik: any;
}
const LocationSelector: React.FC<LocationSelectorProps> = ({ formik }) => {
const { t } = useTranslation("common");
const { formData } = useBillboardForm();
const { request } = useAxios();
const [allStates, setAllStates] = useState<IProvince[] | null>(null);
const [cities, setCities] = useState<ICity[] | null>(null);
const [selectedLocation, setSelectedLocation] = useState<{
lat: number;
lng: number;
} | null>(null);
const fetchStates = async () => {
try {
const response = await request<{ provinces: IProvince[] }>(
"GET",
"/provinces"
);
setAllStates(response?.provinces || null);
} catch (err) {
console.log(err);
}
};
const fetchCities = async (provinceId: string) => {
try {
const response = await request<{ cities: ICity[] }>(
"GET",
`/cities/${provinceId}`
);
setCities(response?.cities || []);
} catch (err) {
console.log(err);
}
};
useEffect(() => {
fetchStates();
}, []);
useEffect(() => {
if (formik.values.stateId) {
fetchCities(formik.values.stateId);
}
if (formData?.markerCoordinate && formData?.markerCoordinate[1]) {
setSelectedLocation({
lat: Number(formData?.markerCoordinate[1]),
lng: Number(formData?.markerCoordinate[0]),
});
}
}, [formik?.values?.markerCoordinate]);
const handleProvinceChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const selectedProvinceId = e.target.value;
formik.setFieldValue("stateId", selectedProvinceId);
fetchCities(selectedProvinceId);
};
const handleMapClick = (event: any) => {
const { lngLat } = event;
setSelectedLocation({
lat: lngLat.lat,
lng: lngLat.lng,
});
formik.setFieldValue("markerCoordinate", [lngLat.lng, lngLat.lat]);
};
return (
<>
<SelectBox
className={`max-w-full ${
formik.touched.stateId && formik.errors.stateId
? "border-red-500 dark:border-red-500"
: ""
}`}
value={formik.values.stateId}
onChange={handleProvinceChange}
>
<option disabled value="">
{t("billboards.province")}
</option>
{allStates?.map((item: IProvince) => (
<option key={item.id} value={item.id}>
{item?.name}
</option>
))}
</SelectBox>
{formik.touched.stateId && formik.errors.stateId && (
<small className="text-red-500 block text-center">
{formik.errors.stateId}
</small>
)}
<SelectBox
className={`max-w-full ${
formik.touched.cityId && formik.errors.cityId
? "border-red-500 dark:border-red-500"
: "border-gray-300"
}`}
value={formik.values.cityId}
onChange={(e) => formik.setFieldValue("cityId", e.target.value)}
>
<option disabled value="">
{t("billboards.city")}
</option>
{cities?.map((city: ICity) => (
<option key={city.id} value={city.id}>
{city.name}
</option>
))}
</SelectBox>
{formik.touched.cityId && formik.errors.cityId && (
<small className="text-red-500 block text-center">
{formik.errors.cityId}
</small>
)}
<RoundedInput
className={`max-w-full ${
formik.touched.neighbourhood && formik.errors.neighbourhood
? "border-red-500 dark:border-red-500"
: ""
}`}
type="text"
placeholder={t("billboards.neighbourhood")}
{...formik.getFieldProps("neighbourhood")}
/>
{formik.touched.neighbourhood && formik.errors.neighbourhood && (
<p className="text-red-500">{formik.errors.neighbourhood}</p>
)}
<textarea
className={`w-full p-4 mt-2 h-24 rounded-3xl border
${
formik.touched.address && formik.errors.address
? "border-red-500 dark:border-red-500"
: "border-border-primary-light dark:border-border-primary-dark"
}
border-neutral-950 text-neutral-900 dark:text-neutral-50 dark:border-neutral-400 font-medium`}
placeholder={t("billboards.address")}
{...formik.getFieldProps("address")}
></textarea>
{formik.touched.address && formik.errors.address && (
<p className="text-red-500">{formik.errors.address}</p>
)}
<div className="w-full rounded-2xl overflow-hidden mt-2">
<Map
style={{ height: "240px" }}
initialViewState={{
longitude: 51.375433528216654,
latitude: 35.73356434056531,
zoom: 11,
}}
mapboxAccessToken="pk.eyJ1IjoiYWxkZjkyIiwiYSI6ImNscHh5ZG56dTByMXAycnJ2cDNmdDQ5M3YifQ.a_sUt1rLFMfA0jHrETcM7A"
mapStyle="mapbox://styles/mapbox/streets-v11"
onClick={handleMapClick}
>
<GeolocateControl />
{selectedLocation && (
<Marker
latitude={selectedLocation.lat}
longitude={selectedLocation.lng}
>
<Image
alt="location icon"
className="-mt-5"
width={25}
height={25}
src={"/images/icons/location.svg"}
/>
</Marker>
)}
</Map>
{formik.touched.markerCoordinate && formik.errors.markerCoordinate && (
<p className="text-red-500 text-xs">
{formik.errors.markerCoordinate as string}
</p>
)}
</div>
</>
);
};
export default LocationSelector;

View File

@@ -66,25 +66,31 @@ const nextConfig = {
minimumCacheTTL: 31536000,
},
// ۳. ریدایرکت دائمی دامنه قدیمی .ir به دامنه اصلی .com
// ۳. ریدایرکت دائمی دامنه قدیمی .ir و نسخه بدون www به دامنه اصلی www.modstagram.com
async redirects() {
return [
{
source: '/:path*',
has: [{ type: 'host', value: 'modstagram.ir' }],
destination: 'https://modstagram.com/:path*',
destination: 'https://www.modstagram.com/:path*',
permanent: true,
},
{
source: '/:path*',
has: [{ type: 'host', value: 'www.modstagram.ir' }],
destination: 'https://modstagram.com/:path*',
destination: 'https://www.modstagram.com/:path*',
permanent: true,
},
{
source: '/:path*',
has: [{ type: 'host', value: 'app.modstagram.ir' }],
destination: 'https://modstagram.com/:path*',
destination: 'https://www.modstagram.com/:path*',
permanent: true,
},
{
source: '/:path*',
has: [{ type: 'host', value: 'modstagram.com' }],
destination: 'https://www.modstagram.com/:path*',
permanent: true,
},
];

BIN
public/images/og-image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

View File

@@ -27,6 +27,9 @@ Disallow: /*/payment/
Disallow: /projects/payment/
Disallow: /billboards/payment/
Disallow: /academy/payment/
Disallow: /shops/new
Disallow: /shops/checkout
Disallow: /shops/orders
Host: https://modstagram.com
Sitemap: https://modstagram.com/sitemap.xml
Host: https://www.modstagram.com
Sitemap: https://www.modstagram.com/sitemap.xml

View File

@@ -22,10 +22,18 @@ const messaging = firebase.messaging.isSupported() ? firebase.messaging() : null
if (messaging) {
messaging.onBackgroundMessage((payload) => {
const title = payload.notification?.title || "modstagram";
// عکسِ فرستنده (یا بنرِ دستیِ ادمین برای admin_broadcast) — قبلاً همیشه لوگوی ثابتِ
// اپ بود، صرف‌نظر از اینکه نوتیف مربوط به کدوم کاربره؛ notification.image رو
// utils/pushNotification.js بک‌اند از قبل می‌فرسته (فقط این‌جا نادیده گرفته می‌شد)
const avatarOrBanner = payload.notification?.image;
self.registration.showNotification(title, {
body: payload.notification?.body,
icon: "/images/icons/192x192.png",
icon: avatarOrBanner || "/images/icons/192x192.png",
badge: "/images/icons/192x192.png",
// بنرِ بزرگِ زیرِ متن (برای admin_broadcast که واقعاً قراره «بنر» باشه)، غیر از
// آیکونِ کوچیکِ گردِ کنارِ عنوان — مرورگرهایی که image رو ساپورت نکنن سرشون این رو
// نادیده می‌گیرن، پس بی‌خطره
image: avatarOrBanner || undefined,
data: payload.data || {},
});
});
@@ -33,7 +41,10 @@ if (messaging) {
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const url = "/settings/notifications";
// بک‌اند وقتی خودش دقیقاً می‌دونه مقصد کجاست (پیام چت، تگِ پست و...) یه مسیرِ نسبیِ
// آماده می‌فرسته (نگاه کن به utils/notifyUser.js/chatHelpers.js: data.link) — وگرنه
// مثلِ قبل می‌ریم صفحه‌ی عمومیِ اعلان‌ها
const url = event.notification.data?.link || "/settings/notifications";
event.waitUntil(
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clientsArr) => {
const existing = clientsArr.find((c) => c.url.includes(url));

View File

@@ -15,7 +15,7 @@ export async function fetchPostById(
try {
const res = await fetch(primaryUrl, {
cache: "no-store",
next: { revalidate: 45 },
headers,
});
if (res.ok) {
@@ -35,7 +35,7 @@ export async function fetchPostById(
for (const url of fallbackUrls) {
try {
const res = await fetch(url, {
cache: "no-store",
next: { revalidate: 45 },
headers,
});
if (!res.ok) continue;

View File

@@ -20,8 +20,6 @@ export async function fetchPosts(
seedPostId?: string;
sort?: "latest";
reelsTab?: "for_you" | "following" | "saved" | "near_me";
lat?: string;
lng?: string;
heightMin?: string;
heightMax?: string;
weightMin?: string;

View File

@@ -0,0 +1,177 @@
"use client";
import React, { useEffect, useState, useCallback } from "react";
import AuthPageLayout, {
AuthFormFooter,
AuthPageContent,
} from "@/components/auth/AuthPageLayout";
import AuthNextButton from "@/components/auth/AuthNextButton";
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
import useAxios from "@/hooks/useAxios";
import { IProfileData } from "@/types/types";
import Image from "next/image";
import { useRouter } from "next/navigation";
import toast from "react-hot-toast";
import Cropper from "react-easy-crop";
import getCroppedImg from "@/helpers/cropImage";
import { Area } from "react-easy-crop";
import { useTranslation } from "react-i18next";
import LocalePageShell from "@/components/i18n/LocalePageShell";
// عین verify/avatar/page.tsx (ویزارد تایید هویت)، با این تفاوت که این مرحله
// زودتر آورده شده: همین بعد از نام/نام‌خانوادگی (register/fullname) و قبل از
// صفحه‌ی انتخابِ «ادامه ثبت‌نام / ورود به برنامه» (register/complete) — چون عکس
// همین‌جا آپلود می‌شه، دیگه لازم نیست ویزارد تایید هویت دوباره همین قدم رو بپرسه
// (verify/avatar/page.tsx دیگه از fullname/complete لینک نمی‌شه)
function RegisterAvatarPage() {
const { t } = useTranslation("common");
const router = useRouter();
const { request } = useAxios();
const [avatar, setAvatar] = useState<string | null>(null);
const [crop, setCrop] = useState({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
const [loadingUpload, setLoadingUpload] = useState(false);
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null);
const mobile =
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
const fetchData = async () => {
try {
const response = await request<{ user: IProfileData }>("GET", "/profile");
setAvatar(response?.user?.profile_image || null);
} catch (err) {
console.log(err);
}
};
useEffect(() => {
fetchData();
}, []);
const selectImage = async (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.files && event.target.files[0]) {
const file = event.target.files[0];
const reader = new FileReader();
reader.onload = (e) => {
const imageDataUrl = e.target?.result as string;
setAvatar(imageDataUrl);
};
reader.readAsDataURL(file);
}
};
const onCropComplete = useCallback(
(_croppedArea: Area, croppedPixels: Area) => {
setCroppedAreaPixels(croppedPixels);
},
[]
);
const createCroppedImage = async () => {
if (!avatar || !croppedAreaPixels) return null;
const croppedImage = await getCroppedImg(avatar, croppedAreaPixels);
return croppedImage;
};
const goNext = () => router.push("/register/complete");
const uploadImage = async () => {
if (!avatar) {
toast.error(t("auth.selectProfileImage"));
return;
}
setLoadingUpload(true);
const croppedBlob = await createCroppedImage();
if (!croppedBlob) {
toast.error(t("auth.cropError"));
setLoadingUpload(false);
return;
}
const formData = new FormData();
formData.append("profile_image", croppedBlob, "avatar.jpg");
formData.append("mobile", mobile as string);
try {
await request("POST", "/verify/profile_image", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
toast.success(t("auth.imageSavedSuccess"));
goNext();
} catch (err) {
console.log("Upload error:", err);
toast.error(t("auth.imageUploadError"));
setLoadingUpload(false);
}
};
return (
<LocalePageShell>
<AuthPageLayout>
<AuthPageContent>
<span className="text-xl font-bold mb-4">
{t("auth.profileImageTitle")}
</span>
<div className="relative aspect-square w-[250px] bg-gray-200 rounded-3xl overflow-hidden mb-4">
{avatar ? (
<Cropper
image={
avatar.startsWith("data:") ? avatar : IMAGE_BASE_URL + avatar
}
crop={crop}
zoom={zoom}
aspect={1}
onCropChange={setCrop}
onZoomChange={setZoom}
onCropComplete={onCropComplete}
cropShape="rect"
showGrid={false}
objectFit="contain"
/>
) : (
<label
htmlFor="fileInput"
className="cursor-pointer w-full h-full flex items-center justify-center"
>
<Image
src="/images/icons/gallery-add.svg"
width={120}
height={120}
alt="add profile icon"
/>
</label>
)}
</div>
<input
id="fileInput"
type="file"
accept="image/*"
className="hidden"
onChange={selectImage}
/>
<AuthNextButton className="mt-4">
<label className="cursor-pointer w-full h-full" htmlFor="fileInput">
{t("auth.selectEditImage")}
</label>
</AuthNextButton>
</AuthPageContent>
<AuthFormFooter onSkip={goNext} skipDisabled={loadingUpload}>
<AuthNextButton
onClick={uploadImage}
loading={loadingUpload}
disabled={loadingUpload}
>
{t("auth.saveAndContinue")}
</AuthNextButton>
</AuthFormFooter>
</AuthPageLayout>
</LocalePageShell>
);
}
export default RegisterAvatarPage;

View File

@@ -14,6 +14,7 @@ import toast from "react-hot-toast";
import { getSafeRedirectPath } from "@/lib/auth/postLogin";
import { useTranslation } from "react-i18next";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import { IProfileData } from "@/types/types";
function RegisterCompletePage() {
const { t } = useTranslation("common");
@@ -21,6 +22,22 @@ function RegisterCompletePage() {
const { request, loading } = useAxios();
const [action, setAction] = useState<"app" | "continue" | null>(null);
// مطابق درخواست: عکس پروفایل حالا زودتر، توی خودِ صفحه‌ی register/avatar گرفته
// می‌شه (قبل از این صفحه) — پس دیگه لازم نیست ویزارد تایید هویت با verify/avatar
// شروع بشه؛ مستقیم می‌ره سراغ قدم بعدی (عین navigateHandler توی verify/avatar/page.tsx)
const goToVerifyWizard = async () => {
try {
const response = await request<{ user: IProfileData }>("GET", "/profile");
if (response?.user?.user_type === "user") {
router.push("/verify/expertise");
return;
}
} catch (err) {
console.log(err);
}
router.push("/verify/gender");
};
const finishRegistration = async (next: "app" | "continue") => {
setAction(next);
try {
@@ -30,7 +47,7 @@ function RegisterCompletePage() {
localStorage.setItem("usertype", "user");
if (next === "continue") {
router.push("/verify/avatar");
await goToVerifyWizard();
} else {
router.push(getSafeRedirectPath());
}
@@ -59,7 +76,7 @@ function RegisterCompletePage() {
<AuthNextButton
type="button"
onClick={() => finishRegistration("continue")}
className=" w-full sm:w-auto"
className="!border-[#16a34a] !text-[#16a34a] w-full sm:w-auto"
disabled={loading}
loading={loading && action === "continue"}
>

View File

@@ -14,7 +14,6 @@ import * as yup from "yup";
import { useRouter } from "next/navigation";
import AuthNextButton from "@/components/auth/AuthNextButton";
import toast from "react-hot-toast";
import { getSafeRedirectPath } from "@/lib/auth/postLogin";
import { useTranslation } from "react-i18next";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import Image from "next/image";
@@ -32,9 +31,6 @@ function FullNamePage() {
const router = useRouter();
const { request, loading } = useAxios();
const [nameSaved, setNameSaved] = useState(false);
const [choiceLoading, setChoiceLoading] = useState<"app" | "continue" | null>(
null
);
const [avatar, setAvatar] = useState<string | null>(null);
const isInstagramProvider =
@@ -68,25 +64,18 @@ function FullNamePage() {
[t]
);
const finishRegistration = async (action: "app" | "continue") => {
setChoiceLoading(action);
// فقط برای ثبت‌نامِ اینستاگرام صدا زده می‌شه (پایین‌تر) — چون اون کاربرها از قبل
// عکس دارن، همون ویزارد تایید هویت (verify/avatar) رو می‌بینن، نه صفحه‌ی جدیدِ
// register/avatar. بقیه‌ی کاربرها بعد از ثبت نام مستقیم به register/avatar می‌رن
const finishRegistrationInstagram = async () => {
try {
await request("POST", "/register/user_type", {
user_type: "user",
});
await request("POST", "/register/user_type", { user_type: "user" });
localStorage.setItem("usertype", "user");
if (action === "continue") {
router.push("/verify/avatar");
} else {
router.push(getSafeRedirectPath());
}
router.push("/verify/avatar");
} catch (err: any) {
toast.error(
err?.response?.data?.message || t("auth.registrationFinishFailed")
);
} finally {
setChoiceLoading(null);
}
};
@@ -117,7 +106,11 @@ function FullNamePage() {
// no manual "enter app or continue" choice, matching the phone/Google flow's
// choice screen being skipped only for this provider.
if (isInstagramProvider) {
await finishRegistration("continue");
await finishRegistrationInstagram();
} else {
// بقیه‌ی کاربرها: به‌جای دکمه‌های «ادامه/ورود به برنامه» زیر همین فرم،
// اول مرحله‌ی آپلود عکس پروفایل میاد
router.push("/register/avatar");
}
} catch (err: any) {
toast.error(err?.response?.data?.message || t("auth.saveNameFailed"));
@@ -196,48 +189,17 @@ function FullNamePage() {
{formik.errors.family}
</small>
)}
{nameSaved && (
<p className="text-sm text-green-600 text-center mt-4">
{isInstagramProvider
? t("auth.instagramContinuingToProfile")
: t("auth.nameSavedChoose")}
</p>
)}
</div>
</AuthPageContent>
<AuthFormFooter>
{!nameSaved ? (
<AuthNextButton
type="submit"
disabled={loading || !formik.values.name.trim()}
loading={loading}
>
{t("auth.saveAndContinue")}
</AuthNextButton>
) : !isInstagramProvider ? (
<div className="flex flex-col sm:flex-row gap-3 w-full max-w-sm justify-center items-center">
<AuthNextButton
type="button"
onClick={() => finishRegistration("continue")}
className=" w-full sm:w-auto"
disabled={Boolean(choiceLoading)}
loading={choiceLoading === "continue"}
>
{t("auth.continueRegistration")}
</AuthNextButton>
<AuthNextButton
type="button"
onClick={() => finishRegistration("app")}
className="!border-[#FF0000] !text-[#FF0000] w-full sm:w-auto"
disabled={Boolean(choiceLoading)}
loading={choiceLoading === "app"}
>
{t("auth.enterApp")}
</AuthNextButton>
</div>
) : null}
<AuthNextButton
type="submit"
disabled={nameSaved || loading || !formik.values.name.trim()}
loading={loading}
>
{t("auth.saveAndContinue")}
</AuthNextButton>
</AuthFormFooter>
</form>
</AuthPageLayout>

View File

@@ -46,12 +46,17 @@ function Register() {
validationSchema: schema,
onSubmit: async (values) => {
const mobile = normalizeMobileInput(values.mobile);
// سیستم «معرفی به دوستان»: اگه کاربر قبل از این از یه لینکِ معرفی
// (/users/username?ref=CODE) اومده، ReferralCapture.tsx همین‌جا توی
// localStorage نگهش داشته — بک‌اند (registerController.js) این کد رو به
// referred_by وصل می‌کنه، پاداش بعد از تکمیلِ کاملِ ثبت‌نام پرداخت می‌شه
const referralCode = localStorage.getItem("referral_code");
try {
await request(
"POST",
"/register",
{ mobile },
{ mobile, ...(referralCode ? { referral_code: referralCode } : {}) },
{ noToast: true }
);
localStorage.setItem("mobile", mobile);

View File

@@ -101,7 +101,7 @@ function Sizes() {
className={`p-1 rounded-2xl w-1/3 border text-sm flex items-center justify-center gap-1 ${
selectedButton === 1
? "btn-modern--selected text-white"
: "bg-tertiary-light dark:bg-tertiary-dark text-black border-gray-400"
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
}`}
onClick={() => handleButtonPress(1)}
>

View File

@@ -30,6 +30,7 @@ import { usePathname } from "next/navigation";
import Cookies from "js-cookie";
import { trackExploreInteraction } from "@/api/trackExploreInteraction";
import BillboardContactInfoModal from "@/components/billboards/BillboardPage/BillboardContactInfoModal";
import WalletUsagePrompt from "@/components/payment/WalletUsagePrompt";
import { AcademyCourseDetailSkeleton } from "@/components/academy/AcademySkeletons";
import { useTranslation } from "react-i18next";
import BackButton from "@/components/ui/BackButton";
@@ -85,17 +86,28 @@ interface CourseDetailProps {
initialIsPurchased?: boolean;
}
interface MediaFile {
id: string;
title: string;
file: File | null;
preview?: string;
/** شکلِ واقعیِ ویدیوهایی که از /academy/academy/course/content میان و توی
* لیستِ سرفصل‌ها + پلیر رندر می‌شن (نه فرم‌آپلود — MOCK_VIDEOS[0] هم همین شکله) */
interface CourseVideo {
_id: string;
file_name: string;
course_video: string;
type: "image" | "video";
isFree?: boolean;
is_free?: boolean;
is_free: boolean;
type: string;
duration: string;
}
/** این اندپوینت‌ها ساختارهای مختلفی برمی‌گردونن (بعضاً حتی خودِ کد چند شکلِ
* احتمالی رو با || پشتِ‌سرِ‌هم چک می‌کنه) — به‌جای برگردوندنِ generic هرکدوم به
* یه interface دقیقِ جدا (که نیاز به rewrite بک‌اند برای یکدست‌کردن داشت)، یه
* تایپِ عمومیِ صریح که همون فیلدهایی که کد واقعاً استفاده می‌کنه رو پوشش می‌ده */
type ApiResponse = {
success?: boolean;
message?: string;
data?: any;
[key: string]: any;
};
export default function CourseDetail({
initialIsPurchased = false,
}: CourseDetailProps) {
@@ -105,6 +117,8 @@ export default function CourseDetail({
const [purchasing, setPurchasing] = useState(false);
const [showContactInfoModal, setShowContactInfoModal] = useState(false);
const [course] = useState(MOCK_COURSE);
const [walletBalance, setWalletBalance] = useState(0);
const [showWalletPrompt, setShowWalletPrompt] = useState(false);
const pathname = usePathname();
const match = pathname.match(/\/academy\/([^\/]+)/);
@@ -116,6 +130,13 @@ export default function CourseDetail({
const _id = courses[0]?._id;
useEffect(() => {
request<{ charged?: { available?: number } }>("GET", "/wallet/summary")
.then((res) => setWalletBalance(res?.charged?.available || 0))
.catch(() => setWalletBalance(0));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const [comments, setComments] = useState<Comments[]>([]);
const [totalComments, setTotalComments] = useState(0);
const [averageRate, setAverageRate] = useState(0);
@@ -126,7 +147,7 @@ export default function CourseDetail({
const [sortBy, setSortBy] = useState<string>("createdAt");
const [sortOrder, setSortOrder] = useState<string>("desc");
const [usersCache, setUsersCache] = useState<Record<string, any>>({});
const [videoFields, setVideoFields] = useState<MediaFile[]>([]);
const [videoFields, setVideoFields] = useState<CourseVideo[]>([]);
const [rating, setRating] = useState<number>(0);
@@ -138,11 +159,11 @@ export default function CourseDetail({
try {
const getAcademyCourses = (await request(
const getAcademyCourses = await request<ApiResponse>(
"GET",
`/academy/academy/course/content?courseId=${id}&page=1&limit=20`,
{}
)) as MediaFile;
);
setVideoFields(getAcademyCourses.data.courses);
setSelectedVideo(getAcademyCourses.data.courses[0])
@@ -158,7 +179,7 @@ export default function CourseDetail({
useEffect(() => {
const getAcademyCourses = async () => {
try {
const getCoursespayment = await request(
const getCoursespayment = await request<ApiResponse>(
"GET", // تغییر به GET
`/academy/course/checkCoursePurchase/${id}` // ارسال ID در مسیر
);
@@ -187,7 +208,7 @@ export default function CourseDetail({
}
try {
const response = await request("GET", `/profile/${userId}`);
const response = await request<ApiResponse>("GET", `/profile/${userId}`);
if (response?.user) {
const userData = response.user;
setUsersCache((prev) => ({ ...prev, [userId]: userData }));
@@ -204,7 +225,7 @@ export default function CourseDetail({
if (!_id) return;
try {
const response = await request(
const response = await request<ApiResponse>(
"GET",
`/academy/course/${_id}/comments/count`
);
@@ -223,15 +244,15 @@ export default function CourseDetail({
setLoading(true);
try {
const response = await request(
const response = await request<ApiResponse>(
"GET",
`/academy/course/${_id}/comments?page=${pageNum}&limit=20&status=${statusFilter}&sortBy=${sortBy}&sortOrder=${sortOrder}`
);
let sum = 0;
let count = 0;
response.data.stats.rateDistribution.forEach(element => {
response.data.stats.rateDistribution.forEach((element: number) => {
sum += Number(element);
count++;
});
@@ -242,7 +263,7 @@ export default function CourseDetail({
setRating(roundedRating);
if (response?.success) {
let newComments = response.data.comments;
let newComments: any[] = response.data.comments;
const pagination = response.data.pagination;
if (newComments && newComments.length > 0) {
@@ -340,7 +361,7 @@ export default function CourseDetail({
const getAcademyCourses = async () => {
setCourseLoading(true);
try {
const response = await request(
const response = await request<ApiResponse>(
"GET",
`/academy/academy/get/curses?page=1&limit=20&status=accept&_id=${id}`
);
@@ -387,46 +408,7 @@ export default function CourseDetail({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [courses[0]?._id]);
// خرید دوره
// const handlePurchase = async () => {
// setPurchasing(true);
// try {
// const response = await request(
// "POST",
// "/academy/academy/course/payment-web",
// {
// courseId: id, // یا _id که از courses[0]?._id می‌آید
// }
// );
// console.log("پاسخ کامل سرور:", response);
// // بررسی موفقیت آمیز بودن درخواست
// if (response?.success && response?.paymentUrl) {
// // هدایت کاربر به درگاه پرداخت زرین‌پال
// window.location.href = response.paymentUrl;
// } else {
// // نمایش خطا به کاربر
// toast.error(response?.message || "خطا در اتصال به درگاه پرداخت");
// setPurchasing(false);
// }
// } catch (error) {
// console.error("خطا در خرید:", error);
// toast.error("خطا در ارتباط با سرور");
// setPurchasing(false);
// }
// };
const handleBuyCourse = async () => {
const token = Cookies.get("token");
if (!token) {
toast.error(t("academy.course.loginToBuy"));
return;
}
const startPayment = async (useWallet: boolean) => {
setPurchasing(true);
const loadingToastId = toast.loading(
courses[0]?.is_free
@@ -435,8 +417,9 @@ export default function CourseDetail({
);
try {
const response = await request("POST", "/academy/academy/course/payment-web", {
const response = await request<ApiResponse>("POST", "/academy/academy/course/payment-web", {
courseId: id,
useWallet,
});
toast.dismiss(loadingToastId);
@@ -447,6 +430,12 @@ export default function CourseDetail({
return;
}
if (response?.paidFromWallet) {
setIsPurchased(true);
toast.success(t("wallet.paidFromWalletSuccess"));
return;
}
const paymentUrl =
response?.paymentUrl ||
response?.data?.paymentUrl ||
@@ -475,98 +464,23 @@ export default function CourseDetail({
}
};
const handlePurchase = async (plan: {
duration: string;
label: string;
price: number;
discount?: number;
}) => {
let loadingToastId = null;
try {
// عین صفحه‌ی درخواست همکاری/سفارش فروشگاه: قبل پرداخت، اگه موجودی کیف پول داره
// بپرسه استفاده کنه یا نه
const handleBuyCourse = async () => {
const token = Cookies.get("token");
const token = Cookies.get("token");
if (!token) {
toast.error(t("academy.course.loginToBuy"));
return
}
// نمایش لودینگ
loadingToastId = toast.loading(t("academy.course.connectingPayment"));
const response = await request(
"POST",
"/academy/academy/course/payment-web",
{
courseId: id, // یا _id که از courses[0]?._id می‌آید
}
);
// بستن لودینگ
toast.dismiss(loadingToastId);
// پردازش پاسخ (ساختارهای مختلف احتمالی)
let authority = null;
let paymentUrl = null;
if (response?.data?.authority) {
authority = response.data.authority;
paymentUrl = `https://www.zarinpal.com/pg/StartPay/${authority}`;
} else if (response?.authority) {
authority = response.authority;
paymentUrl = `https://www.zarinpal.com/pg/StartPay/${authority}`;
} else if (response?.data?.paymentUrl) {
paymentUrl = response.data.paymentUrl;
} else if (response?.paymentUrl) {
paymentUrl = response.paymentUrl;
}
if (paymentUrl) {
// window.open بعدِ یه await در سافاری (دسکتاپ و خصوصاً iOS) پاپ‌آپ‌بلاک می‌شه —
// چون دیگه synchronously به همون کلیکِ کاربر متصل نیست. window.location.href هم
// مطمئن‌تره، هم با بقیه‌ی مسیرهای پرداختِ این اپ (booking، پروژه، بیلبورد) یکی می‌مونه
window.location.href = paymentUrl;
return;
} else {
console.error("ساختار پاسخ نامعتبر:", response);
toast.error(
response?.data?.message ||
response?.message ||
t("academy.course.paymentStartError")
);
}
} catch (err: any) {
// بستن لودینگ در صورت خطا
if (loadingToastId) {
toast.dismiss(loadingToastId);
}
console.error("خطا در شروع پرداخت:", err);
// استخراج پیام خطا
let errorMessage = t("academy.course.paymentStartError");
if (err?.response?.data?.message) {
errorMessage = err.response.data.message;
} else if (err?.response?.data?.error) {
errorMessage = err.response.data.error;
} else if (err?.message) {
errorMessage = err.message;
}
if (errorMessage.includes("amount") || errorMessage.includes("مبلغ")) {
errorMessage = t("academy.course.invalidAmount");
} else if (errorMessage.includes("merchant")) {
errorMessage = t("academy.course.gatewayConfigError");
}
toast.error(errorMessage);
if (!token) {
toast.error(t("academy.course.loginToBuy"));
return;
}
};
if (!courses[0]?.is_free && walletBalance > 0) {
setShowWalletPrompt(true);
return;
}
await startPayment(false);
};
// پخش ویدیو
const handlePlayVideo = (video: (typeof MOCK_VIDEOS)[0]) => {
@@ -591,7 +505,7 @@ export default function CourseDetail({
const finalPrice = courses[0]?.is_free
? 0
: courses[0]?.offerNumber > 0
? courses[0]?.price * (1 - courses[0]?.offerNumber / 100)
? Number(courses[0]?.price) * (1 - courses[0]?.offerNumber / 100)
: course.price;
const courseContactInfo = courses[0]?.contactInfo;
@@ -756,8 +670,8 @@ useEffect(() => {
{/* آواتار کاربر */}
<div className="shrink-0">
<ProfileAvatar
src={item?.user_id?.profile_image}
alt={item?.user_id?.user_name || t("common.user")}
src={typeof item?.user_id === "object" ? item.user_id?.profile_image : undefined}
alt={(typeof item?.user_id === "object" ? item.user_id?.user_name : undefined) || t("common.user")}
size="chat"
rounded="full"
fallback="/images/default-avatar.png"
@@ -770,8 +684,8 @@ useEffect(() => {
{/* اطلاعات کاربر و امتیاز */}
<div className="flex items-center gap-2 flex-wrap mb-2">
<span className="font-semibold text-sm dark:text-white inline-flex items-center gap-1">
{item?.user_id?.user_name || t("common.user")}
<VerificationBadge verifyBadge={item?.user_id?.verify_badge} />
{(typeof item?.user_id === "object" ? item.user_id?.user_name : undefined) || t("common.user")}
<VerificationBadge verifyBadge={typeof item?.user_id === "object" ? item.user_id?.verify_badge : undefined} />
</span>
{item?.rate > 0 && (
@@ -855,12 +769,12 @@ useEffect(() => {
<div className="flex items-center gap-3 flex-wrap">
<span className="text-3xl font-bold text-[#FF107D]">
{Math.round(
courses[0]?.price * (1 - courses[0]?.offerNumber / 100)
Number(courses[0]?.price) * (1 - courses[0]?.offerNumber / 100)
).toLocaleString()}{" "}
{t("settings.toman")}
</span>
<span className="text-sm line-through text-gray-400">
{Math.round(courses[0]?.price).toLocaleString()}{" "}
{Math.round(Number(courses[0]?.price)).toLocaleString()}{" "}
{t("settings.toman")}
</span>
<span className="bg-red-500 text-white text-xs px-2 py-1 rounded-full flex items-center gap-1">
@@ -873,7 +787,7 @@ useEffect(() => {
</div>
) : (
<span className="text-3xl font-bold text-[#FF107D]">
{Math.round(courses[0]?.price || course.price).toLocaleString()}{" "}
{Math.round(Number(courses[0]?.price) || course.price).toLocaleString()}{" "}
{t("settings.toman")}
</span>
)}
@@ -1046,6 +960,16 @@ useEffect(() => {
}}
/>
)}
<WalletUsagePrompt
isOpen={showWalletPrompt}
amount={Math.round(finalPrice) || 0}
walletBalance={walletBalance}
onAnswer={(useWallet) => {
setShowWalletPrompt(false);
void startPayment(useWallet);
}}
/>
</div>
);
}

View File

@@ -1,8 +1,11 @@
import { Metadata } from "next";
import { cache } from "react";
import { notFound } from "next/navigation";
import CourseDetail from "./CourseClient";
import { generatePageMetadata } from "@/utils/generatePageMetadata";
import { getApiBaseUrl } from "@/components/main/BaseUrl";
import { buildCourseDetailSeo } from "@/lib/buildCourseDetailSeo";
import { buildCourseDetailSeo, buildCourseJsonLd } from "@/lib/buildCourseDetailSeo";
import { safeJsonLdString } from "@/lib/seo/safeJsonLd";
import { getServerLanguage } from "@/lib/i18n/server";
import { translateCommon } from "@/lib/i18n/translate";
@@ -10,20 +13,33 @@ type Props = {
params: Promise<{ id: string; title: string }>;
};
// Deduped per request: generateMetadata() and the page body's JSON-LD both need
// this course — cache() avoids firing the fetch to the backend twice.
const loadCourse = cache(async (id: string) => {
try {
const res = await fetch(
`${getApiBaseUrl()}/academy/academy/get/curses?page=1&limit=20&status=accept&_id=${id}`,
{ next: { revalidate: 45 } }
);
const response = await res.json();
return (
response?.data?.courses?.[0] ||
response?.courses?.[0] ||
response?.[0] ||
null
);
} catch (error) {
console.error("خطا در دریافت اطلاعات دوره:", error);
return null;
}
});
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { id, title: slugTitle } = await params;
try {
const res = await fetch(
`${getApiBaseUrl()}/academy/academy/get/curses?page=1&limit=20&status=accept&_id=${id}`,
{ cache: "no-store" }
);
const response = await res.json();
const course =
response?.data?.courses?.[0] ||
response?.courses?.[0] ||
response?.[0];
const course = await loadCourse(id);
if (course) {
const lang = await getServerLanguage();
@@ -53,5 +69,22 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
export default async function CoursePage({ params }: Props) {
const { id } = await params;
return <CourseDetail courseId={id} />;
const lang = await getServerLanguage();
const course = await loadCourse(id);
if (!course) notFound();
return (
<>
{course && (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: safeJsonLdString(buildCourseJsonLd(course, id, lang)),
}}
/>
)}
<CourseDetail courseId={id} />
</>
);
}

View File

@@ -39,6 +39,15 @@ interface AcademyOwner {
user_score?: string | number;
}
/** این اندپوینت‌ها ساختارهای مختلفی برمی‌گردونن — یه تایپِ عمومیِ صریح که همون
* فیلدهایی که کد واقعاً استفاده می‌کنه رو پوشش می‌ده (بجای {} ضمنی) */
type ApiResponse = {
academy?: any;
courses?: any[];
data?: any;
[key: string]: any;
};
interface AcademyData {
_id: string;
userId?: string;
@@ -138,7 +147,7 @@ export default function AcademyProfileClient() {
setIsLoading(true);
try {
const response = await request("POST", "/academy/findAcademyById", {
const response = await request<ApiResponse>("POST", "/academy/findAcademyById", {
_id: academyId,
});
const academyData =
@@ -159,7 +168,7 @@ export default function AcademyProfileClient() {
if (!academyId) return;
setIsLoadingCourses(true);
try {
const response = await request(
const response = await request<ApiResponse>(
"GET",
`/academy/academy/get/getAcademyCourse/${academyId}?page=1&limit=50&status=accept`
);

View File

@@ -1,5 +1,5 @@
import { Metadata } from "next";
import { Suspense } from "react";
import { Suspense, cache } from "react";
import { cookies } from "next/headers";
import ProjectDetailClient from "./ProjectDetailClient";
import PageLoader from "@/components/ui/PageLoader";
@@ -15,13 +15,16 @@ type ProjectDetailPageProps = {
params: Promise<{ id: string; title: string }>;
};
// Deduped per request — see loadUser in users/[username]/page.tsx for rationale.
const loadProject = cache((id: string, token: string) => fetchProject(id, token));
export async function generateMetadata({
params,
}: ProjectDetailPageProps): Promise<Metadata> {
const { id, title } = await params;
const token = (await cookies()).get("token")?.value || "";
const lang = await getServerLanguage();
const project = await fetchProject(id, token);
const project = await loadProject(id, token);
if (project) {
const seo = buildProjectDetailSeo(project, lang);
@@ -50,11 +53,20 @@ export async function generateMetadata({
export default async function ProjectDetailPage({
params,
}: ProjectDetailPageProps) {
const { id } = await params;
const { id, title } = await params;
const token = (await cookies()).get("token")?.value || "";
const lang = await getServerLanguage();
const project = await loadProject(id, token);
const h1 = project
? buildProjectDetailSeo(project, lang).title
: buildProjectDetailFallbackSeo(decodeURIComponent(title || ""), id, lang).title;
return (
<Suspense fallback={<PageLoader className="min-h-[50vh]" />}>
<ProjectDetailClient id={id} />
</Suspense>
<>
<h1 className="sr-only">{h1}</h1>
<Suspense fallback={<PageLoader className="min-h-[50vh]" />}>
<ProjectDetailClient id={id} />
</Suspense>
</>
);
}

View File

@@ -1,13 +1,15 @@
import { BASE_URL } from "@/components/main/BaseUrl";
import { BASE_URL, IMAGE_BASE_URL } from "@/components/main/BaseUrl";
import BillboardDetailContent from "@/components/billboards/BillboardDetailContent";
import BillboardNotFound from "@/components/billboards/BillboardNotFound";
import { IAdvertising, IRate } from "@/types/types";
import { cookies } from "next/headers";
import { cache } from "react";
import { notFound } from "next/navigation";
import { Metadata } from "next";
import { generatePageMetadata } from "@/utils/generatePageMetadata";
import { getLocaleBundle } from "@/lib/i18n/resources";
import { getServerLanguage } from "@/lib/i18n/server";
import { safeJsonLdString } from "@/lib/seo/safeJsonLd";
import { SITE_URL } from "@/config/site";
interface IBillboardProps {
params: Promise<{ id: string; title: string }>;
@@ -78,6 +80,39 @@ export async function generateMetadata({
});
}
function buildBillboardJsonLd(billboard: IAdvertising, id: string) {
const address = [
billboard.province?.name,
billboard.city?.name,
billboard.neighbourhood,
]
.filter(Boolean)
.join(" - ");
const url = `${SITE_URL}/billboards/${id}`;
return {
"@context": "https://schema.org",
"@type": "LocalBusiness",
"@id": url,
url,
name: billboard.title,
description: billboard.description || billboard.title,
image: billboard.images?.map((path) => `${IMAGE_BASE_URL}${path}`),
address: address
? { "@type": "PostalAddress", addressLocality: address, addressCountry: "IR" }
: undefined,
...(billboard.averageRating && billboard.ratingsCount
? {
aggregateRating: {
"@type": "AggregateRating",
ratingValue: billboard.averageRating,
reviewCount: billboard.ratingsCount,
},
}
: {}),
};
}
async function BillboardPage({ params }: IBillboardProps) {
const { id } = await params;
const token = (await cookies()).get("token")?.value || "";
@@ -85,13 +120,23 @@ async function BillboardPage({ params }: IBillboardProps) {
const data = await getBillboardData(id, token);
if (!data || !data.advertising) {
return <BillboardNotFound />;
notFound();
}
const billboard = data.advertising as IAdvertising;
const rate = data.rate as IRate;
return <BillboardDetailContent billboard={billboard} rate={rate} />;
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: safeJsonLdString(buildBillboardJsonLd(billboard, id)),
}}
/>
<BillboardDetailContent billboard={billboard} rate={rate} />
</>
);
}
export default BillboardPage;

View File

@@ -1,11 +1,12 @@
import { BASE_URL, IMAGE_BASE_URL } from "@/components/main/BaseUrl";
import AdvertisingProfileContent from "@/components/billboards/AdvertisingProfileContent";
import BillboardAdNotFound from "@/components/billboards/BillboardAdNotFound";
import { IAdvertisingProfile } from "@/types/types";
import { Metadata } from "next";
import { cache } from "react";
import { notFound } from "next/navigation";
import { getLocaleBundle } from "@/lib/i18n/resources";
import { getServerLanguage } from "@/lib/i18n/server";
import { SITE_URL } from "@/config/site";
interface IUserProps {
params: Promise<{ id: string[] }>;
@@ -57,14 +58,14 @@ export async function generateMetadata({ params }: IUserProps): Promise<Metadata
openGraph: {
title: finalTitle,
description,
url: `https://modstagram.com/billboards/${id}`,
url: `${SITE_URL}/billboards/${id}`,
siteName: "Modstagram",
images: [{ url: image }],
locale: lang === "fa" ? "fa_IR" : "en_US",
type: "article",
},
alternates: {
canonical: `https://modstagram.com/billboards/${id}`,
canonical: `${SITE_URL}/billboards/${id}`,
},
};
}
@@ -74,7 +75,7 @@ export default async function AdvertisingProfilePage({ params }: IUserProps) {
const id = idSegments?.[0] || "";
const profile = await getProfile(id);
if (!profile) return <BillboardAdNotFound />;
if (!profile) notFound();
return <AdvertisingProfileContent profile={profile} id={id} />;
}

View File

@@ -1,11 +1,12 @@
import { BASE_URL, IMAGE_BASE_URL } from "@/components/main/BaseUrl";
import AdvertisingProfileContent from "@/components/billboards/AdvertisingProfileContent";
import BillboardAdNotFound from "@/components/billboards/BillboardAdNotFound";
import { IAdvertisingProfile } from "@/types/types";
import { Metadata } from "next";
import { cache } from "react";
import { notFound } from "next/navigation";
import { getLocaleBundle } from "@/lib/i18n/resources";
import { getServerLanguage } from "@/lib/i18n/server";
import { SITE_URL } from "@/config/site";
interface IUserProps {
params: Promise<{ id: string }>;
@@ -56,14 +57,14 @@ export async function generateMetadata({ params }: IUserProps): Promise<Metadata
openGraph: {
title: finalTitle,
description,
url: `https://modstagram.com/billboards/${id}`,
url: `${SITE_URL}/billboards/${id}`,
siteName: "Modstagram",
images: [{ url: image }],
locale: lang === "fa" ? "fa_IR" : "en_US",
type: "article",
},
alternates: {
canonical: `https://modstagram.com/billboards/${id}`,
canonical: `${SITE_URL}/billboards/${id}`,
},
};
}
@@ -72,7 +73,7 @@ export default async function AdvertisingProfilePage({ params }: IUserProps) {
const { id } = await params;
const profile = await getProfile(id);
if (!profile) return <BillboardAdNotFound />;
if (!profile) notFound();
return <AdvertisingProfileContent profile={profile} id={id} />;
}

View File

@@ -0,0 +1,16 @@
import type { Metadata } from "next";
// این صفحه یه فرم رزرو شخصی‌شده برای هر کاربره — محتوای یکتا و قابل‌رتبه‌بندی
// نداره (خودِ پروفایل کاربر جای درستیه که ایندکس بشه)، پس noindex می‌کنیم تا
// ایندکس گوگل با صدها صفحه‌ی تقریباً یکسان پر نشه.
export const metadata: Metadata = {
robots: { index: false, follow: true },
};
export default function BookingUserLayout({
children,
}: {
children: React.ReactNode;
}) {
return children;
}

View File

@@ -11,6 +11,7 @@ import { User } from "@/types/types";
import React, { useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import WalletUsagePrompt from "@/components/payment/WalletUsagePrompt";
const PRESET_AMOUNTS = [2000, 5000, 10000, 15000, 20000, 50000, 100000];
const CUSTOM_MIN = 5000;
@@ -33,6 +34,8 @@ function GiftPage({ params }: GiftPageProps) {
const [isCustom, setIsCustom] = useState(false);
const [message, setMessage] = useState("");
const [submitting, setSubmitting] = useState(false);
const [walletBalance, setWalletBalance] = useState(0);
const [showWalletPrompt, setShowWalletPrompt] = useState(false);
useEffect(() => {
const fetchUser = async () => {
@@ -43,6 +46,10 @@ function GiftPage({ params }: GiftPageProps) {
setUserDetail(response?.user);
};
void fetchUser();
request<{ charged?: { available?: number } }>("GET", "/wallet/summary")
.then((res) => setWalletBalance(res?.charged?.available || 0))
.catch(() => setWalletBalance(0));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [userId, request]);
const finalAmount = useMemo(() => {
@@ -56,15 +63,24 @@ function GiftPage({ params }: GiftPageProps) {
const canSubmit = finalAmount > 0 && isCustomValid && !submitting;
const handleSubmit = async () => {
const handleSubmit = async (useWallet: boolean) => {
if (!canSubmit) return;
setSubmitting(true);
try {
const response = await request<{ authority?: string; paymentUrl?: string }>(
"POST",
"/gifts/initiate",
{ receiverId: userId, amount: finalAmount, message }
);
const response = await request<{
authority?: string;
paymentUrl?: string;
paidFromWallet?: boolean;
}>("POST", "/gifts/initiate", {
receiverId: userId,
amount: finalAmount,
message,
useWallet,
});
if (response?.paidFromWallet) {
toast.success(t("wallet.paidFromWalletSuccess"));
return;
}
// لینک خارجی — router.push رفتارِ تضمین‌شده‌ای نداره؛ window.location.href
// مطمئن‌تره (خصوصاً سافاری)
if (response?.paymentUrl) {
@@ -84,6 +100,15 @@ function GiftPage({ params }: GiftPageProps) {
}
};
const handlePayClick = () => {
if (!canSubmit) return;
if (walletBalance > 0) {
setShowWalletPrompt(true);
return;
}
void handleSubmit(false);
};
return (
<Container>
<div className="mx-3 mb-10">
@@ -167,11 +192,21 @@ function GiftPage({ params }: GiftPageProps) {
variant="primary"
className="px-8 py-2"
disabled={!canSubmit}
onClick={() => void handleSubmit()}
onClick={handlePayClick}
>
{t("giftPage.payButton")}
</RoundedButton>
</div>
<WalletUsagePrompt
isOpen={showWalletPrompt}
amount={finalAmount}
walletBalance={walletBalance}
onAnswer={(useWallet) => {
setShowWalletPrompt(false);
void handleSubmit(useWallet);
}}
/>
</div>
</Container>
);

View File

@@ -1,6 +1,13 @@
import type { Metadata } from "next";
import Header from "@/components/main/Header";
import TabNavigation from "@/components/TabNavigation";
// فرم ارسال هدیه به یه کاربر خاص — محتوای یکتای قابل‌رتبه‌بندی نداره (خودِ
// پروفایل کاربر جای درستیه که ایندکس بشه)، پس noindex می‌کنیم.
export const metadata: Metadata = {
robots: { index: false, follow: true },
};
export default function GiftLayout({
children,
}: {

View File

@@ -28,13 +28,20 @@ function GiftPaymentFailed() {
/>
<span>{t("giftPage.paymentFailed.message")}</span>
</div>
{receiverId ? (
<Link className="mt-8 flex flex-col items-center" href={`/gift/${receiverId}`}>
<div className="mt-8 grid grid-cols-2 gap-4">
{receiverId ? (
<Link href={`/gift/${receiverId}`}>
<RoundedButton variant="primary" className="h-9 w-32">
{t("giftPage.paymentFailed.retry")}
</RoundedButton>
</Link>
) : null}
<Link href="/">
<RoundedButton variant="primary" className="h-9 w-32">
{t("giftPage.paymentFailed.retry")}
{t("giftPage.paymentFailed.back")}
</RoundedButton>
</Link>
) : null}
</div>
</Container>
</LocalePageShell>
);

View File

@@ -32,16 +32,15 @@ function GiftPaymentSuccess() {
</p>
</div>
<div className="mt-6 flex justify-center gap-4">
{receiverId && receiverUsername ? (
<Link href={`/settings/chats/${receiverUsername}/${receiverId}`}>
<RoundedButton variant="primary" className="h-9 w-36">
{t("giftPage.paymentSuccess.sendMessage")}
</RoundedButton>
</Link>
) : null}
<Link href="/">
<RoundedButton variant="primary" className="h-9 w-36">
{t("giftPage.paymentSuccess.later")}
<Link
href={
receiverId && receiverUsername
? `/settings/chats/${receiverUsername}/${receiverId}`
: "/"
}
>
<RoundedButton variant="primary" className="h-9 w-40">
{t("giftPage.paymentSuccess.backToChat")}
</RoundedButton>
</Link>
</div>

View File

@@ -1,7 +1,8 @@
import type { Metadata, Viewport } from "next";
import localFont from "next/font/local";
import { defaultSEOConfig } from "@/config/seoConfig";
import { getSiteSeoMeta, getSeoPage } from "@/lib/i18n/seo";
import { SITE_URL } from "@/config/site";
import { getSiteSeoMeta, getSeoPage, buildHreflangAlternates } from "@/lib/i18n/seo";
import { getLanguageDefinition } from "@/lib/i18n/registry";
import { getServerLanguage } from "@/lib/i18n/server";
import SiteJsonLd from "@/components/seo/SiteJsonLd";
@@ -10,6 +11,7 @@ import "./globals.css";
import Layout from "@/components/Layout";
import RegisterSW from "@/components/RegisterSW";
import PushNotificationListener from "@/components/PushNotificationListener";
import VpnWarningGate from "@/components/VpnWarningGate";
import ClientErrorBoundary from "@/components/ClientErrorBoundary";
const iranSansFont = localFont({
@@ -29,10 +31,11 @@ export async function generateMetadata(): Promise<Metadata> {
template: `%s | ${site.titleSuffix}`,
},
description: home.description,
metadataBase: new URL("https://modstagram.com"),
metadataBase: new URL(SITE_URL),
keywords: site.keywords,
authors: [{ name: "Modstagram", url: "https://modstagram.com" }],
authors: [{ name: "Modstagram", url: SITE_URL }],
creator: "Modstagram",
alternates: buildHreflangAlternates(home.path || "/", lang),
manifest: "/manifest.webmanifest",
applicationName: site.name,
icons: {
@@ -99,6 +102,7 @@ export default async function RootLayout({
<SiteJsonLd />
<RegisterSW />
<PushNotificationListener />
<VpnWarningGate />
<ClientErrorBoundary>
<Layout initialLanguage={lang}>{children}</Layout>
</ClientErrorBoundary>

View File

@@ -733,6 +733,7 @@ export default function NewPostPage() {
onShopsChange={setSelectedShops}
onShopProductsChange={setSelectedShopProducts}
onBookingChange={setSelectedBooking}
taggedUserIds={taggedUsers.map((u) => u._id)}
/>
<div className="mt-4">

25
src/app/not-found.tsx Normal file
View File

@@ -0,0 +1,25 @@
import Link from "next/link";
import Container from "@/components/elements/Container";
import { getServerLanguage } from "@/lib/i18n/server";
import { translateCommon } from "@/lib/i18n/translate";
export default async function NotFound() {
const lang = await getServerLanguage();
return (
<Container className="flex min-h-[70vh] flex-col items-center justify-center gap-4 py-20 text-center">
<h1 className="text-2xl font-bold text-foreground">
{translateCommon(lang, "notFoundPage.title")}
</h1>
<p className="max-w-sm text-sm text-neutral-500 dark:text-neutral-400">
{translateCommon(lang, "notFoundPage.description")}
</p>
<Link
href="/"
className="mt-2 rounded-full bg-primary px-6 py-2 text-sm font-medium text-white"
>
{translateCommon(lang, "notFoundPage.backHome")}
</Link>
</Container>
);
}

View File

@@ -20,6 +20,7 @@ import Cookies from "js-cookie";
import * as Yup from "yup";
import { useTranslation } from "react-i18next";
import { useUser } from "@/hooks/useUser";
import WalletUsagePrompt from "@/components/payment/WalletUsagePrompt";
type OfferTypeRow = IOfferType & {
disabled?: boolean;
unavailableReason?: string | null;
@@ -59,6 +60,8 @@ function TicketChat({ params }: ITicketChatProps) {
const [selectedType, setSelectedType] = useState("normal");
const [typeList, setTypeList] = useState<OfferTypeRow[] | null>(null);
const [hasUsedFreeOffer, setHasUsedFreeOffer] = useState(false);
const [walletBalance, setWalletBalance] = useState(0);
const [showWalletPrompt, setShowWalletPrompt] = useState(false);
useEffect(() => {
const fetchUser = async () => {
@@ -69,6 +72,10 @@ function TicketChat({ params }: ITicketChatProps) {
setUserDetail(response?.user);
};
void fetchUser();
request<{ charged?: { available?: number } }>("GET", "/wallet/summary")
.then((res) => setWalletBalance(res?.charged?.available || 0))
.catch(() => setWalletBalance(0));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id, request]);
useEffect(() => {
@@ -106,6 +113,67 @@ function TicketChat({ params }: ITicketChatProps) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const getToken = (): string => {
if (typeof window !== "undefined") {
const token = Cookies.get("token");
return token ? `Bearer ${token}` : "";
}
return "";
};
const startPayment = async (offerType: string, useWallet: boolean) => {
try {
const response = await axios.post(
`${BASE_URL}/offers/initiate-payment-web`,
{
offerType,
receiverId: id,
useWallet,
},
{
headers: {
Authorization: getToken(),
"Content-Type": "application/json",
},
}
);
const data: {
authority?: string;
paymentUrl?: string;
type?: string;
paidFromWallet?: boolean;
} = response.data;
if (offerType === "free" || data.type === "free") {
toast.success(t("offerPage.freeSuccess"));
setHasUsedFreeOffer(true);
router.push(`/offer/payment/success?userId=${id}`);
} else if (data.paidFromWallet) {
toast.success(t("wallet.paidFromWalletSuccess"));
router.push(`/offer/payment/success?userId=${id}`);
} else if (data.paymentUrl) {
// window.open بعدِ await در سافاری (دسکتاپ/iOS) پاپ‌آپ‌بلاک می‌شه چون دیگه
// synchronously به کلیکِ کاربر متصل نیست؛ ضمناً بازگشتِ ZarinPal (از طریق
// offer/payment-web/route.js) کلِ همون تب رو به "/" ریدایرکت می‌کنه، نه تبِ
// اصلی — پس با _blank دو تب باز می‌موند. window.location.href هم امن‌تره
// هم با بقیه‌ی مسیرهای پرداختِ این اپ یکی می‌مونه
window.location.href = data.paymentUrl;
} else if (data.authority) {
window.location.href = `https://www.zarinpal.com/pg/StartPay/${data.authority}`;
} else {
toast.error(t("offerPage.paymentInfoError"));
}
} catch (err: any) {
console.error("Payment initiation error:", err);
toast.error(
err?.response?.data?.error ||
err?.response?.data?.message ||
t("offerPage.paymentStartError")
);
}
};
const formik = useFormik({
initialValues: {
selectedType: "normal",
@@ -124,58 +192,12 @@ function TicketChat({ params }: ITicketChatProps) {
return;
}
const getToken = (): string => {
if (typeof window !== "undefined") {
const token = Cookies.get("token");
return token ? `Bearer ${token}` : "";
}
return "";
};
try {
const response = await axios.post(
`${BASE_URL}/offers/initiate-payment-web`,
{
offerType: values.selectedType,
receiverId: id,
},
{
headers: {
Authorization: getToken(),
"Content-Type": "application/json",
},
}
);
const data: {
authority?: string;
paymentUrl?: string;
type?: string;
} = response.data;
if (values.selectedType === "free" || data.type === "free") {
toast.success(t("offerPage.freeSuccess"));
setHasUsedFreeOffer(true);
router.push(`/offer/payment/success?userId=${id}`);
} else if (data.paymentUrl) {
// window.open بعدِ await در سافاری (دسکتاپ/iOS) پاپ‌آپ‌بلاک می‌شه چون دیگه
// synchronously به کلیکِ کاربر متصل نیست؛ ضمناً بازگشتِ ZarinPal (از طریق
// offer/payment-web/route.js) کلِ همون تب رو به "/" ریدایرکت می‌کنه، نه تبِ
// اصلی — پس با _blank دو تب باز می‌موند. window.location.href هم امن‌تره
// هم با بقیه‌ی مسیرهای پرداختِ این اپ یکی می‌مونه
window.location.href = data.paymentUrl;
} else if (data.authority) {
window.location.href = `https://www.zarinpal.com/pg/StartPay/${data.authority}`;
} else {
toast.error(t("offerPage.paymentInfoError"));
if (values.selectedType !== "free" && walletBalance > 0) {
setShowWalletPrompt(true);
return;
}
} catch (err: any) {
console.error("Payment initiation error:", err);
toast.error(
err?.response?.data?.error ||
err?.response?.data?.message ||
t("offerPage.paymentStartError")
);
await startPayment(values.selectedType, false);
} finally {
setSubmitting(false);
}
@@ -272,6 +294,20 @@ function TicketChat({ params }: ITicketChatProps) {
{t("offerPage.submitRequest")}
</RoundedButton>
</form>
<WalletUsagePrompt
isOpen={showWalletPrompt}
amount={
Number(
displayTypes.find((item) => item.name === selectedType)?.price
) || 0
}
walletBalance={walletBalance}
onAnswer={(useWallet) => {
setShowWalletPrompt(false);
void startPayment(selectedType, useWallet);
}}
/>
</div>
</Container>
);

View File

@@ -29,14 +29,18 @@ function FailedBillboard() {
/>
<span>{t("offerPage.paymentFailed.message")}</span>
</div>
<Link
className="flex items-center flex-col mt-8"
href={`/offer/${userId}`}
>
<RoundedButton variant="primary" className="w-32 h-9">
{t("offerPage.paymentFailed.retry")}
</RoundedButton>
</Link>
<div className="grid grid-cols-2 gap-4 mt-8">
<Link href={`/offer/${userId}`}>
<RoundedButton variant="primary" className="w-32 h-9">
{t("offerPage.paymentFailed.retry")}
</RoundedButton>
</Link>
<Link href="/">
<RoundedButton variant="primary" className="w-32 h-9">
{t("offerPage.paymentFailed.back")}
</RoundedButton>
</Link>
</div>
</Container>
</LocalePageShell>
);

View File

@@ -50,18 +50,11 @@ function SuccessBillboard() {
username: userDetail?.user_name ?? "",
})}
</p>
<div className="grid grid-cols-2 gap-4">
<Link href={`/settings/chats/${userDetail?.user_name}/${userId}`}>
<RoundedButton variant="primary" className="w-32 h-9">
{t("offerPage.paymentSuccess.sendMessage")}
</RoundedButton>
</Link>
<Link href={"/"}>
<RoundedButton variant="primary" className="w-32 h-9">
{t("offerPage.paymentSuccess.later")}
</RoundedButton>
</Link>
</div>
<Link href={`/settings/chats/${userDetail?.user_name}/${userId}`}>
<RoundedButton variant="primary" className="w-40 h-9">
{t("offerPage.paymentSuccess.sendMessage")}
</RoundedButton>
</Link>
</div>
</div>
</Container>

View File

@@ -1,17 +1,24 @@
import { Metadata } from "next";
import { cache } from "react";
import { fetchPostById } from "@/api/fetchPostById";
import PostFeedView, { PostFeedSearchParams } from "@/components/posts/PostFeedView";
import { buildStorageUrl } from "@/components/main/BaseUrl";
import { generatePageMetadata } from "@/utils/generatePageMetadata";
import {
buildPostPath,
buildPostJsonLd,
buildPostSeoDescription,
buildPostSeoTitle,
} from "@/lib/postSlug";
import { safeJsonLdString } from "@/lib/seo/safeJsonLd";
import { getSiteSeoMeta } from "@/lib/i18n/seo";
import { getServerLanguage } from "@/lib/i18n/server";
import { translateCommon } from "@/lib/i18n/translate";
// Deduped per request: generateMetadata() and the page body's JSON-LD both need
// this post — cache() avoids firing the fetch to the backend twice.
const loadPost = cache((id: string) => fetchPostById(id));
type Props = {
params: Promise<{ id: string; slug?: string[] }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
@@ -50,7 +57,7 @@ function toFeedSearchParams(
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { id } = await params;
const lang = await getServerLanguage();
const post = await fetchPostById(id);
const post = await loadPost(id);
const title = post
? buildPostSeoTitle(post, lang)
: translateCommon(lang, "postsMeta.defaultTitle");
@@ -84,6 +91,10 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
].filter(Boolean),
publishedTime: post?.createdAt,
modifiedTime: post?.updatedAt,
// پست‌های private/followers-only بدون توکن null برمی‌گردن ولی برای کاربر
// واقعی توی PostFeedView (کلاینتی) درست لود می‌شن — پس نباید notFound() کنیم،
// فقط نباید این حالت رو به گوگل به‌عنوان محتوای معتبر ایندکس بدیم
index: Boolean(post),
lang,
});
}
@@ -93,9 +104,22 @@ export default async function PostPage({ params, searchParams }: Props) {
const { id } = await params;
const sp = await searchParams;
const feedSearchParams = toFeedSearchParams(sp);
const lang = await getServerLanguage();
const post = await loadPost(id);
return (
<main className="min-h-[100dvh]">
{post && (
<>
<h1 className="sr-only">{buildPostSeoTitle(post, lang)}</h1>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: safeJsonLdString(buildPostJsonLd(post, id, lang)),
}}
/>
</>
)}
<PostFeedView
initialPostId={id}
showClose

View File

@@ -34,6 +34,9 @@ export default function robots(): MetadataRoute.Robots {
"/projects/payment/",
"/billboards/payment/",
"/academy/payment/",
"/shops/new",
"/shops/checkout",
"/shops/orders",
],
},
],

View File

@@ -1,4 +1,4 @@
import Title from "@/Components/ui/Title";
import Title from "@/components/ui/Title";
import React, { ReactNode } from "react";
interface BoxProps {

View File

@@ -1,5 +1,5 @@
"use client";
import { Separator } from "@/Components/ui/separator";
import { Separator } from "@/components/ui/separator";
import React from "react";
import BoldIcon from "@/components/ui/BoldIcon";
import { useTranslation } from "react-i18next";

View File

@@ -14,6 +14,14 @@ import { useTranslation } from "react-i18next";
import BackButton from "@/components/ui/BackButton";
import Link from "next/link";
/** این اندپوینت ساختارهای مختلفی برمی‌گردونه — یه تایپِ عمومیِ صریح که همون
* فیلدهایی که کد واقعاً استفاده می‌کنه رو پوشش می‌ده (بجای {} ضمنی) */
type ApiResponse = {
success?: boolean;
data?: any;
[key: string]: any;
};
interface PaginationData {
page: number;
limit: number;
@@ -41,7 +49,7 @@ export default function PurchasedCoursesPage() {
const fetchCourses = async (page: number) => {
setIsLoading(true);
try {
const response = await request(
const response = await request<ApiResponse>(
"GET",
`/academy/course/getUserPurchasedCoursesWithPopulate?page=${page}&limit=6`
);

View File

@@ -59,6 +59,13 @@ interface Payment {
};
}
/** این اندپوینت ساختارهای مختلفی برمی‌گردونه — یه تایپِ عمومیِ صریح که همون
* فیلدهایی که کد واقعاً استفاده می‌کنه رو پوشش می‌ده (بجای {} ضمنی) */
type ApiResponse = {
data?: { payments?: Payment[]; [key: string]: any };
[key: string]: any;
};
interface Transaction {
id: string;
type: "deposit" | "withdraw" | "expense";
@@ -86,7 +93,7 @@ const WalletDashboard = () => {
// دریافت پرداخت‌های موفق (فروش‌های انجام شده)
const fetchSuccessfulPayments = async () => {
try {
const response = await request(
const response = await request<ApiResponse>(
"GET",
`/academy/course/getAllAcademyPayments?status=success`
);
@@ -123,7 +130,7 @@ const WalletDashboard = () => {
// دریافت پرداخت‌های تسویه شده
const fetchSettledPayments = async () => {
try {
const response = await request(
const response = await request<ApiResponse>(
"GET",
`/academy/course/getAllAcademyPayments?status=settled`
);

View File

@@ -0,0 +1,135 @@
"use client";
import Container from "@/components/elements/Container";
import PageTitle from "@/components/settings/PageTitle";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import useAxios from "@/hooks/useAxios";
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import { useTranslation } from "react-i18next";
type SponsoredContent = { title: string; image?: string | null };
type AdDetail = {
_id: string;
ad_type: "first_post" | "first_story" | "notification" | "project" | "product" | "shop" | "billboard";
effective_status: "pending_payment" | "active" | "expired" | "failed";
price: number;
views_count: number;
clicks_count: number;
notification_title?: string;
notification_body?: string;
target_gender: "female" | "male" | "any";
target_expertise?: string | null;
target_sub_expertise?: string[];
started_at?: string;
expires_at?: string;
};
const STATUS_COLOR: Record<string, string> = {
pending_payment: "text-amber-600",
active: "text-green-600",
expired: "text-neutral-400",
failed: "text-red-500",
};
export default function AdDetailPage() {
const { t } = useTranslation("common");
const { request } = useAxios();
const params = useParams<{ id: string }>();
const [ad, setAd] = useState<AdDetail | null>(null);
const [content, setContent] = useState<SponsoredContent | null>(null);
useEffect(() => {
if (!params?.id) return;
request<{ ad?: AdDetail; content?: SponsoredContent }>("GET", `/ads/${params.id}`, null, {
noToast: true,
})
.then((res) => {
setAd(res?.ad ?? null);
setContent(res?.content ?? null);
})
.catch(() => setAd(null));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [params?.id]);
const isLinkedContentType = ad
? (["project", "product", "shop", "billboard"] as const).includes(
ad.ad_type as "project" | "product" | "shop" | "billboard"
)
: false;
const ctr =
ad && ad.views_count > 0 ? Math.round((ad.clicks_count / ad.views_count) * 1000) / 10 : 0;
return (
<LocalePageShell>
<Container>
<PageTitle>{t("ads.detailTitle")}</PageTitle>
{!ad ? (
<p className="py-10 text-center text-gray-500">{t("common.loading")}</p>
) : (
<div className="mt-6 space-y-4 text-xs md:text-sm">
<div className="flex items-center justify-between rounded-2xl border border-neutral-200 px-4 py-3 dark:border-neutral-800">
<span className="font-semibold">{t(`ads.type.${ad.ad_type}`)}</span>
<span className={`font-semibold ${STATUS_COLOR[ad.effective_status]}`}>
{t(`ads.status.${ad.effective_status}`)}
</span>
</div>
{ad.ad_type === "notification" && (
<div className="rounded-2xl border border-neutral-200 px-4 py-3 dark:border-neutral-800">
<p className="font-semibold">{ad.notification_title}</p>
<p className="mt-1 text-neutral-500">{ad.notification_body}</p>
</div>
)}
{isLinkedContentType && content && (
<div className="flex items-center gap-3 rounded-2xl border border-neutral-200 px-4 py-3 dark:border-neutral-800">
{content.image && (
<div className="h-14 w-14 shrink-0 overflow-hidden rounded-xl bg-neutral-100 dark:bg-neutral-800">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={IMAGE_BASE_URL + content.image} alt="" className="h-full w-full object-cover" />
</div>
)}
<span className="font-semibold">{content.title}</span>
</div>
)}
<div className="grid grid-cols-3 gap-3">
<div className="rounded-2xl border border-neutral-200 px-3 py-4 text-center dark:border-neutral-800">
<p className="text-lg font-bold">{ad.views_count}</p>
<p className="text-neutral-500">{t("ads.viewsLabel")}</p>
</div>
<div className="rounded-2xl border border-neutral-200 px-3 py-4 text-center dark:border-neutral-800">
<p className="text-lg font-bold">{ad.clicks_count}</p>
<p className="text-neutral-500">{t("ads.clicksLabel")}</p>
</div>
<div className="rounded-2xl border border-neutral-200 px-3 py-4 text-center dark:border-neutral-800">
<p className="text-lg font-bold">{ctr}%</p>
<p className="text-neutral-500">{t("ads.ctrLabel")}</p>
</div>
</div>
<div className="rounded-2xl border border-neutral-200 px-4 py-3 dark:border-neutral-800 space-y-1">
<p className="font-semibold">{t("ads.targetingTitle")}</p>
<p className="text-neutral-500">
{t("ads.targetingGender")}:{" "}
{ad.target_gender === "any" ? t("ads.genderAny") : t(`ads.gender.${ad.target_gender}`)}
</p>
<p className="text-neutral-500">
{t("ads.targetingExpertise")}: {ad.target_expertise || t("ads.genderAny")}
</p>
</div>
<p className="text-neutral-400">
{Number(ad.price).toLocaleString()} {t("common.toman")}
</p>
</div>
)}
</Container>
</LocalePageShell>
);
}

View File

@@ -0,0 +1,765 @@
"use client";
import Container from "@/components/elements/Container";
import PageTitle from "@/components/settings/PageTitle";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import RoundedButton from "@/components/elements/RoundedButton";
import RoundedInput from "@/components/elements/RoundedInput";
import CountryProvinceCitySelect, {
type GeoSelection,
} from "@/components/ui/CountryProvinceCitySelect";
import WalletUsagePrompt from "@/components/payment/WalletUsagePrompt";
import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl";
import useAxios from "@/hooks/useAxios";
import { useUser } from "@/hooks/useUser";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { toggleBtnClass } from "@/lib/ui/buttonStyles";
type AdType =
| "first_post"
| "first_story"
| "notification"
| "project"
| "product"
| "shop"
| "billboard";
type MyPost = {
_id: string;
post_images?: string[];
post_image?: string;
post_video?: string;
type?: "image" | "video";
files?: { path: string; type: "image" | "video" }[];
caption?: string;
};
type MyStory = {
_id: string;
media_path: string;
media_type?: string;
};
type Expertise = {
_id: string;
expertise: string;
sub_expertise?: { _id: string; name: string }[];
};
type MyProject = { _id: string; title?: string };
type MyShop = { _id: string; name: string; logo?: string | null };
type MyShopProduct = { _id: string; title: string; images?: string[]; primaryImageIndex?: number };
type MyBillboard = { _id: string; title?: string; images?: string[] };
const STEPS = ["type", "content", "location", "expertise", "gender", "review"] as const;
type Step = (typeof STEPS)[number];
/** تصویر بندانگشتی پست — post_images قدیمی + files جدید (و ویدیو) */
function postThumb(post: MyPost): { src: string; isVideo: boolean } | null {
// ۱) تصاویر قدیمی
if (post.post_images?.[0]) {
return { src: buildStorageUrl(post.post_images[0]), isVideo: false };
}
// ۲) فایل‌های جدید — اول تصویر، بعد هر فایلی
const imageFile = post.files?.find((f) => f.type === "image" && f.path);
if (imageFile?.path) {
return { src: buildStorageUrl(imageFile.path), isVideo: false };
}
const anyFile = post.files?.find((f) => f.path);
if (anyFile?.path) {
return {
src: buildStorageUrl(anyFile.path),
isVideo: anyFile.type === "video",
};
}
// ۳) فیلدهای قدیمی تکی
if (post.post_image) {
return { src: buildStorageUrl(post.post_image), isVideo: false };
}
if (post.post_video) {
return { src: buildStorageUrl(post.post_video), isVideo: true };
}
return null;
}
export default function NewAdPage() {
const { t } = useTranslation("common");
const { request } = useAxios();
const router = useRouter();
const user = useUser();
const searchParams = useSearchParams();
// از منوی ۳نقطه‌ی ریلز («این پست رو آگهی کن») — نوع و پست از قبل معلومه، مستقیم
// می‌ره سراغ مرحله‌ی لوکیشن (index دوم توی STEPS)
const presetPostId = searchParams.get("postId") || "";
const presetAdType = searchParams.get("type") === "first_post" ? "first_post" : "";
const [stepIndex, setStepIndex] = useState(presetPostId ? 2 : 0);
const step: Step = STEPS[stepIndex];
const [adType, setAdType] = useState<AdType | "">(presetAdType as AdType | "");
const [selectedPostId, setSelectedPostId] = useState(presetPostId);
const [selectedStoryId, setSelectedStoryId] = useState("");
const [notifTitle, setNotifTitle] = useState("");
const [notifBody, setNotifBody] = useState("");
const [selectedProjectId, setSelectedProjectId] = useState("");
const [selectedShopId, setSelectedShopId] = useState("");
const [selectedBillboardId, setSelectedBillboardId] = useState("");
const [productShopId, setProductShopId] = useState("");
const [selectedShopProductId, setSelectedShopProductId] = useState("");
const [geo, setGeo] = useState<GeoSelection>({ countryId: null, provinceId: null, cityId: null });
const [expertise, setExpertise] = useState("");
const [subExpertise, setSubExpertise] = useState<string[]>([]);
const [gender, setGender] = useState<"female" | "male" | "any">("any");
const [myPosts, setMyPosts] = useState<MyPost[] | null>(null);
const [myStories, setMyStories] = useState<MyStory[] | null>(null);
const [expertiseList, setExpertiseList] = useState<Expertise[]>([]);
const [myProjects, setMyProjects] = useState<MyProject[] | null>(null);
const [myShops, setMyShops] = useState<MyShop[] | null>(null);
const [myBillboards, setMyBillboards] = useState<MyBillboard[] | null>(null);
const [shopProducts, setShopProducts] = useState<MyShopProduct[] | null>(null);
const [price, setPrice] = useState<number | null>(null);
const [priceLoading, setPriceLoading] = useState(false);
const [walletBalance, setWalletBalance] = useState(0);
const [showWalletPrompt, setShowWalletPrompt] = useState(false);
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
request<{ charged?: { available?: number } }>("GET", "/wallet/summary", null, { noToast: true })
.then((res) => setWalletBalance(res?.charged?.available || 0))
.catch(() => setWalletBalance(0));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (adType === "first_post" && user?._id && myPosts === null) {
request<{ posts?: MyPost[] }>("GET", `/posts/user-posts/web?user_id=${user._id}&page=1&limit=30`, null, {
noToast: true,
})
.then((res) => setMyPosts(res?.posts ?? []))
.catch(() => setMyPosts([]));
}
if (adType === "first_story" && myStories === null) {
request<{ my_story?: { stories?: MyStory[] } }>("GET", "/stories/feed", null, { noToast: true })
.then((res) => setMyStories(res?.my_story?.stories ?? []))
.catch(() => setMyStories([]));
}
if (adType === "project" && user?._id && myProjects === null) {
request<{ data?: { projects?: MyProject[] }; projects?: MyProject[] }>(
"GET",
`/projects/user-projects/web?user_id=${user._id}&page=1&limit=50&linkable=1`,
null,
{ noToast: true }
)
.then((res) => setMyProjects(res?.data?.projects ?? res?.projects ?? []))
.catch(() => setMyProjects([]));
}
if ((adType === "shop" || adType === "product") && myShops === null) {
request<{ shops?: MyShop[] }>("GET", "/shops/mine", null, { noToast: true })
.then((res) => setMyShops(res?.shops ?? []))
.catch(() => setMyShops([]));
}
if (adType === "billboard" && myBillboards === null) {
request<{ advertisings?: MyBillboard[]; data?: { advertisings?: MyBillboard[] } }>(
"GET",
"/advertising/user-advertising?page=1&limit=50",
null,
{ noToast: true }
)
.then((res) => setMyBillboards(res?.advertisings ?? res?.data?.advertisings ?? []))
.catch(() => setMyBillboards([]));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [adType, user?._id]);
useEffect(() => {
if (!productShopId) return;
setShopProducts(null);
request<{ docs?: MyShopProduct[] }>("GET", `/shop-products?shopId=${productShopId}&limit=100`, null, {
noToast: true,
})
.then((res) => setShopProducts(res?.docs ?? []))
.catch(() => setShopProducts([]));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [productShopId]);
useEffect(() => {
request<{ expertises?: Expertise[] }>("GET", "/expertise", null, { noToast: true })
.then((res) => setExpertiseList(res?.expertises ?? []))
.catch(() => setExpertiseList([]));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// قیمتِ زنده — با هر تغییری توی نوع/هدف‌گذاری دوباره از سرور می‌گیره (utils/adPricing.js)
useEffect(() => {
if (!adType) return;
setPriceLoading(true);
const params = new URLSearchParams({ ad_type: adType });
if (geo.provinceId != null) params.set("target_province_id", String(geo.provinceId));
if (gender !== "any") params.set("target_gender", gender);
if (expertise) params.set("target_expertise", expertise);
if (subExpertise.length) params.set("target_sub_expertise", subExpertise.join(","));
request<{ price: number | null }>("GET", `/ads/pricing?${params.toString()}`, null, { noToast: true })
.then((res) => setPrice(res?.price ?? null))
.catch(() => setPrice(null))
.finally(() => setPriceLoading(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [adType, geo.provinceId, gender, expertise, subExpertise]);
const canGoNext = useMemo(() => {
if (step === "type") return Boolean(adType);
if (step === "content") {
if (adType === "first_post") return Boolean(selectedPostId);
if (adType === "first_story") return Boolean(selectedStoryId);
if (adType === "notification") return notifTitle.trim().length > 0 && notifBody.trim().length > 0;
if (adType === "project") return Boolean(selectedProjectId);
if (adType === "shop") return Boolean(selectedShopId);
if (adType === "product") return Boolean(selectedShopProductId);
if (adType === "billboard") return Boolean(selectedBillboardId);
return false;
}
return true;
}, [
step,
adType,
selectedPostId,
selectedStoryId,
notifTitle,
notifBody,
selectedProjectId,
selectedShopId,
selectedShopProductId,
selectedBillboardId,
]);
const goNext = () => setStepIndex((i) => Math.min(i + 1, STEPS.length - 1));
const goBack = () => setStepIndex((i) => Math.max(i - 1, 0));
const buildPayload = (useWallet: boolean) => ({
ad_type: adType,
content_post_id: adType === "first_post" ? selectedPostId : undefined,
content_story_id: adType === "first_story" ? selectedStoryId : undefined,
notification_title: adType === "notification" ? notifTitle.trim() : undefined,
notification_body: adType === "notification" ? notifBody.trim() : undefined,
content_project_id: adType === "project" ? selectedProjectId : undefined,
content_shop_id: adType === "shop" ? selectedShopId : undefined,
content_shop_product_id: adType === "product" ? selectedShopProductId : undefined,
content_billboard_id: adType === "billboard" ? selectedBillboardId : undefined,
target_country_id: geo.countryId ?? undefined,
target_province_id: geo.provinceId ?? undefined,
target_city_id: geo.cityId ?? undefined,
target_expertise: expertise || undefined,
target_sub_expertise: subExpertise,
target_gender: gender,
useWallet,
});
const submit = async (useWallet: boolean) => {
setSubmitting(true);
try {
const res = await request<{
paidFromWallet?: boolean;
paymentUrl?: string;
authority?: string;
}>("POST", "/ads", buildPayload(useWallet));
if (res?.paidFromWallet) {
toast.success(t("wallet.paidFromWalletSuccess"));
router.push("/settings/ads");
return;
}
const payUrl = res?.paymentUrl || (res?.authority ? `https://www.zarinpal.com/pg/StartPay/${res.authority}` : null);
if (payUrl) {
window.location.href = payUrl;
} else {
toast.error(t("settings.paymentLinkNotReceived"));
}
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
t("common.error");
toast.error(msg);
} finally {
setSubmitting(false);
}
};
const handleSubmitClick = () => {
if (!price) {
toast.error(t("ads.priceUnavailable"));
return;
}
if (walletBalance > 0) {
setShowWalletPrompt(true);
return;
}
void submit(false);
};
return (
<LocalePageShell>
<Container>
<PageTitle>{t("ads.newTitle")}</PageTitle>
<div className="mt-4 space-y-6 text-xs md:text-sm">
{step === "type" && (
<div className="space-y-3">
<p className="font-semibold">{t("ads.chooseTypeTitle")}</p>
<div className="grid grid-cols-3 gap-2">
{(
// "shop" فعلاً کامنت شده — طبق خواسته‌ی کاربر، صفحه‌ی عمومیِ گشت‌وگذارِ
// فروشگاه‌ها وجود نداره، پس فعلاً فقط "product" (کالا) قابل‌انتخابه
[
"first_post",
"first_story",
"notification",
"project",
"product",
// "shop",
"billboard",
] as AdType[]
).map((type) => (
<button
key={type}
type="button"
onClick={() => setAdType(type)}
className={toggleBtnClass(
adType === type,
"min-h-10 p-2 text-xs leading-tight text-center"
)}
>
{t(`ads.type.${type}`)}
</button>
))}
</div>
</div>
)}
{step === "content" && adType === "first_post" && (
<div className="space-y-3">
<p className="font-semibold">{t("ads.choosePostTitle")}</p>
{myPosts === null ? (
<p className="text-neutral-500">{t("common.loading")}</p>
) : myPosts.length === 0 ? (
<p className="text-neutral-500">{t("ads.noPostsYet")}</p>
) : (
<div className="grid grid-cols-3 gap-2">
{myPosts.map((post) => {
const thumb = postThumb(post);
return (
<button
key={post._id}
type="button"
onClick={() => setSelectedPostId(post._id)}
className={cn(
"relative aspect-square overflow-hidden rounded-xl border-2 bg-neutral-100 dark:bg-neutral-800",
selectedPostId === post._id ? "border-pink-500" : "border-transparent"
)}
>
{thumb?.isVideo ? (
<video
src={thumb.src}
className="h-full w-full object-cover"
muted
playsInline
preload="metadata"
/>
) : thumb?.src ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={thumb.src}
alt=""
className="h-full w-full object-cover"
/>
) : null}
</button>
);
})}
</div>
)}
</div>
)}
{step === "content" && adType === "first_story" && (
<div className="space-y-3">
<p className="font-semibold">{t("ads.chooseStoryTitle")}</p>
{myStories === null ? (
<p className="text-neutral-500">{t("common.loading")}</p>
) : myStories.length === 0 ? (
<p className="text-neutral-500">{t("ads.noStoriesYet")}</p>
) : (
<div className="grid grid-cols-3 gap-2">
{myStories.map((story) => (
<button
key={story._id}
type="button"
onClick={() => setSelectedStoryId(story._id)}
className={cn(
"aspect-[9/16] overflow-hidden rounded-xl border-2 bg-neutral-100 dark:bg-neutral-800",
selectedStoryId === story._id ? "border-pink-500" : "border-transparent"
)}
>
{story.media_type !== "video" ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={
story.media_path.startsWith("http")
? story.media_path
: `https://api.modstagram.com${story.media_path}`
}
alt=""
className="h-full w-full object-cover"
/>
) : null}
</button>
))}
</div>
)}
</div>
)}
{step === "content" && adType === "notification" && (
<div className="space-y-3">
<p className="font-semibold">{t("ads.notificationContentTitle")}</p>
<RoundedInput
value={notifTitle}
onChange={(e) => setNotifTitle(e.target.value)}
placeholder={t("ads.notificationTitlePlaceholder")}
/>
<textarea
value={notifBody}
onChange={(e) => setNotifBody(e.target.value)}
placeholder={t("ads.notificationBodyPlaceholder")}
rows={3}
className="w-full resize-none rounded-2xl border border-neutral-200 bg-transparent px-4 py-3 outline-none dark:border-neutral-700"
/>
</div>
)}
{step === "content" && adType === "project" && (
<div className="space-y-3">
<p className="font-semibold">{t("ads.chooseProjectTitle")}</p>
{myProjects === null ? (
<p className="text-neutral-500">{t("common.loading")}</p>
) : myProjects.length === 0 ? (
<p className="text-neutral-500">{t("ads.noProjectsYet")}</p>
) : (
<div className="space-y-2">
{myProjects.map((p) => (
<button
key={p._id}
type="button"
onClick={() => setSelectedProjectId(p._id)}
className={cn(
"w-full rounded-2xl border px-4 py-3 text-right",
selectedProjectId === p._id
? "border-pink-500 bg-pink-50 dark:bg-pink-950/30"
: "border-neutral-200 dark:border-neutral-700"
)}
>
{p.title || t("ads.untitled")}
</button>
))}
</div>
)}
</div>
)}
{step === "content" && adType === "shop" && (
<div className="space-y-3">
<p className="font-semibold">{t("ads.chooseShopTitle")}</p>
{myShops === null ? (
<p className="text-neutral-500">{t("common.loading")}</p>
) : myShops.length === 0 ? (
<p className="text-neutral-500">{t("ads.noShopsYet")}</p>
) : (
<div className="grid grid-cols-3 gap-2">
{myShops.map((shop) => (
<button
key={shop._id}
type="button"
onClick={() => setSelectedShopId(shop._id)}
className={cn(
"flex flex-col items-center gap-1 rounded-xl border-2 p-2",
selectedShopId === shop._id ? "border-pink-500" : "border-transparent"
)}
>
<div className="h-16 w-16 overflow-hidden rounded-full bg-neutral-100 dark:bg-neutral-800">
{shop.logo ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={IMAGE_BASE_URL + shop.logo} alt="" className="h-full w-full object-cover" />
) : null}
</div>
<span className="line-clamp-1">{shop.name}</span>
</button>
))}
</div>
)}
</div>
)}
{step === "content" && adType === "product" && (
<div className="space-y-3">
<p className="font-semibold">{t("ads.chooseShopTitle")}</p>
{myShops === null ? (
<p className="text-neutral-500">{t("common.loading")}</p>
) : (
<div className="flex flex-wrap gap-2">
{myShops.map((shop) => (
<button
key={shop._id}
type="button"
onClick={() => {
setProductShopId(shop._id);
setSelectedShopProductId("");
}}
className={cn(
"rounded-full border px-3 py-1.5",
productShopId === shop._id
? "border-pink-500 bg-pink-50 dark:bg-pink-950/30"
: "border-neutral-200 dark:border-neutral-700"
)}
>
{shop.name}
</button>
))}
</div>
)}
{productShopId && (
<>
<p className="font-semibold">{t("ads.chooseProductTitle")}</p>
{shopProducts === null ? (
<p className="text-neutral-500">{t("common.loading")}</p>
) : shopProducts.length === 0 ? (
<p className="text-neutral-500">{t("ads.noProductsYet")}</p>
) : (
<div className="grid grid-cols-3 gap-2">
{shopProducts.map((product) => {
const img = product.images?.[product.primaryImageIndex ?? 0] ?? product.images?.[0];
return (
<button
key={product._id}
type="button"
onClick={() => setSelectedShopProductId(product._id)}
className={cn(
"aspect-square overflow-hidden rounded-xl border-2 bg-neutral-100 dark:bg-neutral-800",
selectedShopProductId === product._id ? "border-pink-500" : "border-transparent"
)}
>
{img ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={IMAGE_BASE_URL + img} alt="" className="h-full w-full object-cover" />
) : null}
</button>
);
})}
</div>
)}
</>
)}
</div>
)}
{step === "content" && adType === "billboard" && (
<div className="space-y-3">
<p className="font-semibold">{t("ads.chooseBillboardTitle")}</p>
{myBillboards === null ? (
<p className="text-neutral-500">{t("common.loading")}</p>
) : myBillboards.length === 0 ? (
<p className="text-neutral-500">{t("ads.noBillboardsYet")}</p>
) : (
<div className="space-y-2">
{myBillboards.map((b) => (
<button
key={b._id}
type="button"
onClick={() => setSelectedBillboardId(b._id)}
className={cn(
"w-full rounded-2xl border px-4 py-3 text-right",
selectedBillboardId === b._id
? "border-pink-500 bg-pink-50 dark:bg-pink-950/30"
: "border-neutral-200 dark:border-neutral-700"
)}
>
{b.title || t("ads.untitled")}
</button>
))}
</div>
)}
</div>
)}
{step === "location" && (
<div className="space-y-3">
<p className="font-semibold">{t("ads.chooseLocationTitle")}</p>
<CountryProvinceCitySelect value={geo} onChange={setGeo} />
</div>
)}
{step === "expertise" && (
<div className="space-y-3">
<p className="font-semibold">{t("ads.chooseExpertiseTitle")}</p>
<div className="grid grid-cols-3 gap-2">
<button
type="button"
onClick={() => {
setExpertise("");
setSubExpertise([]);
}}
className={toggleBtnClass(
!expertise,
"min-h-10 p-2 text-xs leading-tight text-center"
)}
>
{t("ads.genderAny")}
</button>
{expertiseList.map((exp) => (
<button
key={exp._id}
type="button"
onClick={() => {
setExpertise(exp.expertise);
setSubExpertise([]);
}}
className={toggleBtnClass(
expertise === exp.expertise,
"min-h-10 p-2 text-xs leading-tight text-center"
)}
>
{exp.expertise}
</button>
))}
</div>
{expertise &&
(expertiseList.find((e) => e.expertise === expertise)?.sub_expertise?.length ?? 0) > 0 && (
<div className="space-y-2">
<p className="text-neutral-500">{t("ads.chooseSubExpertiseTitle")}</p>
<div className="grid grid-cols-3 gap-2">
{(expertiseList.find((e) => e.expertise === expertise)?.sub_expertise ?? []).map(
(sub) => (
<button
key={sub._id}
type="button"
onClick={() =>
setSubExpertise((prev) =>
prev.includes(sub.name)
? prev.filter((s) => s !== sub.name)
: [...prev, sub.name]
)
}
className={toggleBtnClass(
subExpertise.includes(sub.name),
"min-h-10 p-2 text-xs leading-tight text-center"
)}
>
{sub.name}
</button>
)
)}
</div>
</div>
)}
</div>
)}
{step === "gender" && (
<div className="space-y-3">
<p className="font-semibold">{t("ads.chooseGenderTitle")}</p>
<div className="grid grid-cols-3 gap-2">
{(["female", "male", "any"] as const).map((g) => (
<button
key={g}
type="button"
onClick={() => setGender(g)}
className={toggleBtnClass(
gender === g,
"min-h-10 p-2 text-xs leading-tight text-center"
)}
>
{g === "any" ? t("ads.genderAny") : t(`ads.gender.${g}`)}
</button>
))}
</div>
</div>
)}
{step === "review" && (
<div className="space-y-4">
<p className="font-semibold">{t("ads.reviewTitle")}</p>
<div className="rounded-2xl border border-neutral-200 px-4 py-3 dark:border-neutral-700">
<p>{t(`ads.type.${adType}`)}</p>
</div>
<div className="rounded-2xl border border-neutral-200 px-4 py-3 text-center dark:border-neutral-700">
<p className="text-neutral-500">{t("ads.priceLabel")}</p>
<p className="text-xl font-bold">
{priceLoading
? "..."
: price != null
? `${price.toLocaleString()} ${t("common.toman")}`
: t("ads.priceUnavailable")}
</p>
</div>
{/* dir=ltr تا در صفحه RTL هم قبلی سمت چپ فیزیکی و پرداخت سمت راست بماند */}
<div className="flex items-center justify-between gap-3 pt-2" dir="ltr">
<RoundedButton
className="h-11 w-28 shrink-0"
disabled={stepIndex === 0}
onClick={goBack}
>
{t("ads.backAction")}
</RoundedButton>
<RoundedButton
variant="primary"
className="h-11 min-w-0 flex-1"
disabled={submitting || !price}
onClick={handleSubmitClick}
>
{submitting ? t("common.loading") : t("ads.payAndPublish")}
</RoundedButton>
</div>
</div>
)}
{step !== "review" && (
<div className="flex justify-between pt-4">
<RoundedButton
variant="primary"
className="h-10 w-28"
disabled={!canGoNext}
onClick={goNext}
>
{t("ads.nextAction")}
</RoundedButton>
<RoundedButton
className="h-10 w-28"
disabled={stepIndex === 0}
onClick={goBack}
>
{t("ads.backAction")}
</RoundedButton>
</div>
)}
</div>
</Container>
<WalletUsagePrompt
isOpen={showWalletPrompt}
amount={price ?? 0}
walletBalance={walletBalance}
onAnswer={(useWallet) => {
setShowWalletPrompt(false);
void submit(useWallet);
}}
/>
</LocalePageShell>
);
}

View File

@@ -0,0 +1,97 @@
"use client";
import Container from "@/components/elements/Container";
import PageTitle from "@/components/settings/PageTitle";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import EmptyState from "@/components/elements/EmptyState";
import { staticIconUrl } from "@/components/main/BaseUrl";
import useAxios from "@/hooks/useAxios";
import Image from "next/image";
import Link from "next/link";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
type AdRow = {
_id: string;
ad_type: "first_post" | "first_story" | "notification" | "project" | "product" | "shop" | "billboard";
effective_status: "pending_payment" | "active" | "expired" | "failed";
price: number;
views_count: number;
clicks_count: number;
createdAt: string;
};
const STATUS_COLOR: Record<string, string> = {
pending_payment: "text-amber-600",
active: "text-green-600",
expired: "text-neutral-400",
failed: "text-red-500",
};
export default function AdsSettingsPage() {
const { t } = useTranslation("common");
const { request } = useAxios();
const [ads, setAds] = useState<AdRow[] | null>(null);
useEffect(() => {
request<{ ads?: AdRow[] }>("GET", "/ads/mine", null, { noToast: true })
.then((res) => setAds(res?.ads ?? []))
.catch(() => setAds([]));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<LocalePageShell>
<Container>
<PageTitle
action={
<Link href="/settings/ads/new" aria-label={t("ads.addNew")} className="p-2">
<Image
width={24}
height={24}
alt=""
src={staticIconUrl("/images/icons/add.svg")}
className="dark:brightness-0 dark:invert"
/>
</Link>
}
>
{t("settings.nav.ads")}
</PageTitle>
<div className="mt-6 space-y-3 text-xs md:text-sm">
{ads === null ? (
<p className="py-10 text-center text-gray-500">{t("common.loading")}</p>
) : ads.length === 0 ? (
<EmptyState
icon="chart-2"
title={t("ads.emptyTitle")}
subtitle={t("ads.emptyHint")}
/>
) : (
ads.map((ad) => (
<Link
key={ad._id}
href={`/settings/ads/${ad._id}`}
className="flex items-center justify-between rounded-2xl border border-neutral-200 px-4 py-3 dark:border-neutral-800"
>
<div>
<p className="font-semibold">{t(`ads.type.${ad.ad_type}`)}</p>
<p className="text-neutral-500">
{t("ads.viewsClicks", { views: ad.views_count, clicks: ad.clicks_count })}
</p>
</div>
<div className="text-left">
<p className={`font-semibold ${STATUS_COLOR[ad.effective_status]}`}>
{t(`ads.status.${ad.effective_status}`)}
</p>
<p className="text-neutral-400">{Number(ad.price).toLocaleString()} {t("common.toman")}</p>
</div>
</Link>
))
)}
</div>
</Container>
</LocalePageShell>
);
}

View File

@@ -0,0 +1,58 @@
"use client";
import Container from "@/components/elements/Container";
import PageTitle from "@/components/settings/PageTitle";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import AutoDirectList from "@/components/settings/AutoDirectList";
import { staticIconUrl } from "@/components/main/BaseUrl";
import Image from "next/image";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import AutoDirectFormModal from "@/components/settings/AutoDirectFormModal";
function AutoDirectSettingsPage() {
const { t } = useTranslation("common");
const [showCreate, setShowCreate] = useState(false);
const [refreshKey, setRefreshKey] = useState(0);
return (
<LocalePageShell>
<Container>
<PageTitle
action={
<button
type="button"
onClick={() => setShowCreate(true)}
aria-label={t("autoDirect.addNew")}
className="p-2"
>
<Image
width={24}
height={24}
alt=""
src={staticIconUrl("/images/icons/add.svg")}
className="dark:brightness-0 dark:invert"
/>
</button>
}
>
{t("settings.nav.autoDirect")}
</PageTitle>
<div className="text-xs md:text-sm">
<AutoDirectList refreshKey={refreshKey} />
</div>
</Container>
{showCreate && (
<AutoDirectFormModal
isOpen={showCreate}
onClose={() => setShowCreate(false)}
rule={null}
onSaved={() => setRefreshKey((k) => k + 1)}
/>
)}
</LocalePageShell>
);
}
export default AutoDirectSettingsPage;

View File

@@ -1,6 +1,7 @@
"use client";
import Container from "@/components/elements/Container";
import EmptyState from "@/components/elements/EmptyState";
import RoundedButton from "@/components/elements/RoundedButton";
import PageTitle from "@/components/settings/PageTitle";
import LocalePageShell from "@/components/i18n/LocalePageShell";
@@ -58,9 +59,11 @@ export default function BookingListPage() {
{t("common.loading")}
</p>
) : filteredConfigs.length === 0 ? (
<p className="py-10 text-center text-sm text-neutral-500">
{t("booking.emptyList")}
</p>
<EmptyState
icon="calendar"
title={t("booking.emptyList")}
subtitle={t("booking.emptyListHint")}
/>
) : (
filteredConfigs.map((config) => (
<BookingListCard

View File

@@ -11,6 +11,7 @@ import {
} from "@/lib/chat/socketClient";
import MessageInput from "@/components/chat/MessageInput";
import GiftInChatModal from "@/components/chat/GiftInChatModal";
import GiftPaymentResultModal from "@/components/chat/GiftPaymentResultModal";
import MultiImageModal from "@/components/chat/MultiImageModal";
import MediaPreviewModal from "@/components/chat/MediaPreviewModal";
import ChatActionBar from "@/components/chat/ChatActionBar";
@@ -26,7 +27,7 @@ import ForwardMessageModal from "@/components/chat/ForwardMessageModal";
import { AnimatePresence } from "framer-motion";
import { useQueryClient } from "@tanstack/react-query";
import { chatThreadQueryKey } from "@/lib/chat/queryKeys";
import { normalizeThreadId, upsertMessageInThreadCache } from "@/lib/chat/threadCache";
import { normalizeThreadId, upsertMessageInThreadCache, type ChatThreadData } from "@/lib/chat/threadCache";
import { useStableMessageKeys } from "@/hooks/useStableMessageKeys";
import { getStoredUserId } from "@/lib/auth/session";
import { isViewOnceMediaType } from "@/lib/chat/viewOnce";
@@ -68,6 +69,7 @@ function TicketChat({ params }: ITicketChatProps) {
const { username, id: chatPartnerId } = resolvedParams;
const [newMessage, setNewMessage] = useState("");
const [showGiftModal, setShowGiftModal] = useState(false);
const [giftResultStatus, setGiftResultStatus] = useState<"success" | "failed" | null>(null);
const [userTwoDetail, setUserTwoDetail] = useState<User>();
const [pendingImages, setPendingImages] = useState<File[]>([]);
const [showMultiModal, setShowMultiModal] = useState(false);
@@ -129,7 +131,7 @@ function TicketChat({ params }: ITicketChatProps) {
if (currentUserId && targetReceiverId) {
queryClient.setQueryData(
chatThreadQueryKey(currentUserId, targetReceiverId),
(oldData) => upsertMessageInThreadCache(oldData, sentMsg)
(oldData: ChatThreadData | undefined) => upsertMessageInThreadCache(oldData, sentMsg)
);
}
@@ -250,14 +252,23 @@ function TicketChat({ params }: ITicketChatProps) {
useEffect(() => {
const giftId = searchParams.get("giftId");
if (!giftId) return;
const giftAmount = Number(searchParams.get("giftAmount") || 0);
const giftMessage = searchParams.get("giftMessage") || "";
sendGiftMessage({ _id: giftId, amount: giftAmount, message: giftMessage || null });
const giftFailed = searchParams.get("giftPaymentFailed");
if (!giftId && !giftFailed) return;
if (giftId) {
const giftAmount = Number(searchParams.get("giftAmount") || 0);
const giftMessage = searchParams.get("giftMessage") || "";
sendGiftMessage({ _id: giftId, amount: giftAmount, message: giftMessage || null });
setGiftResultStatus("success");
} else if (giftFailed) {
setGiftResultStatus("failed");
}
const params = new URLSearchParams(searchParams.toString());
params.delete("giftId");
params.delete("giftAmount");
params.delete("giftMessage");
params.delete("giftPaymentFailed");
const query = params.toString();
router.replace(`/settings/chats/${username}/${chatPartnerId}${query ? `?${query}` : ""}`);
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -386,7 +397,8 @@ function TicketChat({ params }: ITicketChatProps) {
for (const key in messagePayload) {
formData.append(key, String(messagePayload[key]));
}
formData.append("file", fileForUpload);
// isFileUpload بالا (که این بلوک زیرِ اونه) از قبل fileForUpload رو truthy چک کرده
formData.append("file", fileForUpload as File);
response = await request<ChatMessage | { data: ChatMessage }>(
"post",
@@ -511,7 +523,7 @@ function TicketChat({ params }: ITicketChatProps) {
if (currentUserId && targetReceiverId) {
queryClient.setQueryData(
chatThreadQueryKey(currentUserId, targetReceiverId),
(oldData) => {
(oldData: ChatThreadData | undefined) => {
if (!oldData) return oldData;
return {
...oldData,
@@ -542,7 +554,7 @@ function TicketChat({ params }: ITicketChatProps) {
if (updated && currentUserId && targetReceiverId) {
queryClient.setQueryData(
chatThreadQueryKey(currentUserId, targetReceiverId),
(oldData) => upsertMessageInThreadCache(oldData, updated)
(oldData: ChatThreadData | undefined) => upsertMessageInThreadCache(oldData, updated)
);
}
closeActionMode();
@@ -550,7 +562,7 @@ function TicketChat({ params }: ITicketChatProps) {
if (currentUserId && targetReceiverId) {
queryClient.setQueryData(
chatThreadQueryKey(currentUserId, targetReceiverId),
(oldData) => {
(oldData: ChatThreadData | undefined) => {
if (!oldData) return oldData;
return {
...oldData,
@@ -741,6 +753,15 @@ function TicketChat({ params }: ITicketChatProps) {
onWalletGiftSent={sendGiftMessage}
/>
<GiftPaymentResultModal
status={giftResultStatus}
onClose={() => setGiftResultStatus(null)}
onRetry={() => {
setGiftResultStatus(null);
setShowGiftModal(true);
}}
/>
<MultiImageModal
isOpen={showMultiModal}
files={pendingImages}

View File

@@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import Container from "@/components/elements/Container";
import EmptyState from "@/components/elements/EmptyState";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import VerificationBadge from "@/components/main/VerificationBadge";
import LocalePageShell from "@/components/i18n/LocalePageShell";
@@ -264,10 +265,12 @@ export default function FindPartnerPage() {
</p>
{users.length === 0 ? (
<div className="py-6 text-center">
<p className="text-sm text-neutral-500">{t("findPartner.empty")}</p>
<p className="mt-2 text-xs text-neutral-400">{t("findPartner.emptyHint")}</p>
</div>
<EmptyState
icon="emoji-happy"
title={t("findPartner.empty")}
subtitle={t("findPartner.emptyHint")}
align="right"
/>
) : (
users.map((item) => {
const age = ageFromBirthday(item.birthday);

View File

@@ -6,6 +6,7 @@ import { useRouter } from "next/navigation";
import Map, { Marker } from "react-map-gl";
import "mapbox-gl/dist/mapbox-gl.css";
import Container from "@/components/elements/Container";
import EmptyState from "@/components/elements/EmptyState";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import VerificationBadge from "@/components/main/VerificationBadge";
import LocalePageShell from "@/components/i18n/LocalePageShell";
@@ -479,9 +480,11 @@ export default function NearbyFriendsPage() {
) : null}
{users.length === 0 ? (
<p className="py-6 text-center text-sm text-neutral-500">
{t("nearbyFriends.empty")}
</p>
<EmptyState
icon="location"
title={t("nearbyFriends.empty")}
subtitle={t("nearbyFriends.emptyHint")}
/>
) : (
users.map((item) => (
<Link

View File

@@ -1,6 +1,7 @@
"use client";
import Container from "@/components/elements/Container";
import EmptyState from "@/components/elements/EmptyState";
import RoundedInput from "@/components/elements/RoundedInput";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import VerificationBadge from "@/components/main/VerificationBadge";
@@ -199,9 +200,11 @@ function Chats() {
</Link>
{isEmpty ? (
<p className="py-8 text-center text-neutral-500">
{t("chats.empty")}
</p>
<EmptyState
icon="message"
title={t("chats.empty")}
subtitle={t("chats.emptyHint")}
/>
) : (
data?.pages.map((page) =>
page.filteredUsersData?.map((item: IMessage) => {

View File

@@ -4,6 +4,7 @@ import React, { useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import Container from "@/components/elements/Container";
import EmptyState from "@/components/elements/EmptyState";
import BackButton from "@/components/ui/BackButton";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import VerificationBadge from "@/components/main/VerificationBadge";
@@ -856,9 +857,7 @@ export default function ChatRoomThreadPage({
<IOSSpinner size={28} />
</div>
) : messages.length === 0 ? (
<p className="py-16 text-center text-sm text-neutral-500">
{t("chats.threadEmpty")}
</p>
<EmptyState icon="message" title={t("chats.threadEmpty")} />
) : (
<div className="flex flex-col gap-2 pb-2">
{messages.map((msg) => {

View File

@@ -2,6 +2,7 @@
import Container from "@/components/elements/Container";
import CreateChatRoomModal from "@/components/chat/CreateChatRoomModal";
import EmptyState from "@/components/elements/EmptyState";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import useAxios from "@/hooks/useAxios";
import { useUser } from "@/hooks/useUser";
@@ -106,9 +107,7 @@ export default function ChatRoomsPage() {
<IOSSpinner size={28} />
</div>
) : rooms.length === 0 ? (
<p className="py-12 text-center text-neutral-500">
{t("chats.roomsEmpty")}
</p>
<EmptyState icon="message" title={t("chats.roomsEmpty")} />
) : (
<div className="mt-2 flex flex-col gap-1">
{rooms.map((room) => (

View File

@@ -81,7 +81,7 @@ function Sizes() {
className={`p-1 rounded-2xl w-1/3 border text-sm flex items-center justify-center gap-1 ${
selectedButton === 1
? "btn-modern--selected text-white"
: "bg-tertiary-light dark:bg-tertiary-dark text-black border-gray-400"
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
}`}
onClick={() => setSelectedButton(1)}
>

View File

@@ -1,6 +1,7 @@
"use client";
import Container from "@/components/elements/Container";
import EmptyState from "@/components/elements/EmptyState";
import RoundedButton from "@/components/elements/RoundedButton";
import GiftItem from "@/components/settings/gifts/GiftItem";
import GiftReplyModal from "@/components/settings/gifts/GiftReplyModal";
@@ -59,9 +60,11 @@ function GiftsPage() {
<div className="mt-10 w-full">
{data?.pages.length === 0 ||
(data?.pages[0]?.gifts?.length === 0 && !isFetchingNextPage) ? (
<p className="text-center text-gray-500">
{t("settings.gifts.empty")}
</p>
<EmptyState
icon="gift"
title={t("settings.gifts.empty")}
subtitle={t("settings.gifts.emptyHint")}
/>
) : (
data?.pages?.map((page, pageIndex) => (
<React.Fragment key={pageIndex}>

View File

@@ -2,6 +2,7 @@
import MainBillboardCard from "@/components/billboards/MainBillboardCard/MainBillboardCard";
import Container from "@/components/elements/Container";
import EmptyState from "@/components/elements/EmptyState";
import RoundedInput from "@/components/elements/RoundedInput";
import FilterModal from "@/components/settings/my-billboards/FilterModal";
import PageTitle from "@/components/settings/PageTitle";
@@ -97,9 +98,11 @@ function MyBillboard() {
{data?.pages.length === 0 ||
(data?.pages[0]?.advertisings?.length === 0 &&
!isFetchingNextPage) ? (
<p className="text-center text-gray-500">
{t("settings.billboards.empty")}
</p>
<EmptyState
icon="gallery"
title={t("settings.billboards.empty")}
subtitle={t("settings.billboards.emptyHint")}
/>
) : (
data?.pages?.map((page, pageIndex) => (
<React.Fragment key={pageIndex}>

View File

@@ -1,6 +1,7 @@
"use client";
import Container from "@/components/elements/Container";
import EmptyState from "@/components/elements/EmptyState";
import PageTitle from "@/components/settings/PageTitle";
import UserDetails from "@/components/settings/UserDetails";
import NotificationRow from "@/components/settings/NotificationRow";
@@ -229,9 +230,11 @@ function Notifications() {
<div className="mt-5 font-semibold">
{filteredItems.length === 0 && !isFetchingNextPage ? (
<p className="text-center text-gray-500">
{t("settings.notifications.empty")}
</p>
<EmptyState
icon="notification"
title={t("settings.notifications.empty")}
subtitle={t("settings.notifications.emptyHint")}
/>
) : (
groupedItems.map((entry) =>
entry.type === "date" ? (

View File

@@ -1,6 +1,7 @@
"use client";
import Container from "@/components/elements/Container";
import EmptyState from "@/components/elements/EmptyState";
import RoundedButton from "@/components/elements/RoundedButton";
import CommentModal from "@/components/settings/offers/CommentModal";
import ConfirmModal from "@/components/settings/offers/ConfirmModal";
@@ -63,9 +64,11 @@ function Offers() {
<div className="w-full mt-10">
{data?.pages.length === 0 ||
(data?.pages[0]?.offer?.length === 0 && !isFetchingNextPage) ? (
<p className="text-center text-gray-500">
{t("settings.offers.empty")}
</p>
<EmptyState
icon="briefcase"
title={t("settings.offers.empty")}
subtitle={t("settings.offers.emptyHint")}
/>
) : (
data?.pages?.map((page, pageIndex) => (
<React.Fragment key={pageIndex}>

View File

@@ -223,6 +223,30 @@ export default function UserSettingsPage() {
<span className="text-neutral-400">›</span>
</Link>
<Link
href="/settings/auto-direct"
className="flex items-center justify-between rounded-2xl border border-neutral-200 px-4 py-3 dark:border-neutral-800"
>
<span className="font-semibold">{t("settings.nav.autoDirect")}</span>
<span className="text-neutral-400">›</span>
</Link>
<Link
href="/settings/shareable-links"
className="flex items-center justify-between rounded-2xl border border-neutral-200 px-4 py-3 dark:border-neutral-800"
>
<span className="font-semibold">{t("settings.nav.shareableLinks")}</span>
<span className="text-neutral-400">›</span>
</Link>
<Link
href="/settings/ads"
className="flex items-center justify-between rounded-2xl border border-neutral-200 px-4 py-3 dark:border-neutral-800"
>
<span className="font-semibold">{t("settings.nav.ads")}</span>
<span className="text-neutral-400">›</span>
</Link>
{/* آنالیتیکس/کاربران مسدود/بایگانی/فعالیت شما و محتوای این آیتم‌ها فقط توی
فارسی/عربی/انگلیسی نمایش داده می‌شن */}
{SETTINGS_ITEMS_VISIBLE_LANGUAGES.includes(language) ? (

View File

@@ -2,6 +2,7 @@
import React, { useEffect, useMemo, useState } from "react";
import Container from "@/components/elements/Container";
import EmptyState from "@/components/elements/EmptyState";
import PageTitle from "@/components/settings/PageTitle";
import VisitorRow, { type ProfileVisitor } from "@/components/settings/VisitorRow";
import ListDateSeparator from "@/components/settings/ListDateSeparator";
@@ -60,9 +61,11 @@ export default function ProfileVisitorsPage() {
) : error ? (
<p className="py-8 text-center text-sm text-red-500">{error}</p>
) : visitors.length === 0 ? (
<p className="py-8 text-center text-sm text-neutral-500">
{t("settings.visitor.empty")}
</p>
<EmptyState
icon="eye"
title={t("settings.visitor.empty")}
subtitle={t("settings.visitor.emptyHint")}
/>
) : (
<div className="mt-2 font-semibold">
{groupedVisitors.map((entry) =>

View File

@@ -0,0 +1,24 @@
"use client";
import Container from "@/components/elements/Container";
import PageTitle from "@/components/settings/PageTitle";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import ShareableLinksManager from "@/components/settings/ShareableLinksManager";
import { useTranslation } from "react-i18next";
function ShareableLinksSettingsPage() {
const { t } = useTranslation("common");
return (
<LocalePageShell>
<Container>
<PageTitle>{t("settings.nav.shareableLinks")}</PageTitle>
<div className="text-xs md:text-sm">
<ShareableLinksManager />
</div>
</Container>
</LocalePageShell>
);
}
export default ShareableLinksSettingsPage;

View File

@@ -1,6 +1,7 @@
"use client";
import Container from "@/components/elements/Container";
import EmptyState from "@/components/elements/EmptyState";
import RoundedButton from "@/components/elements/RoundedButton";
import RoundedInput from "@/components/elements/RoundedInput";
import PageTitle from "@/components/settings/PageTitle";
@@ -57,9 +58,11 @@ function Tickets() {
<div className="w-full mt-5">
{data?.pages.length === 0 ||
(data?.pages[0]?.tickets?.length === 0 && !isFetchingNextPage) ? (
<p className="text-center text-gray-500">
{t("settings.tickets.empty")}
</p>
<EmptyState
icon="message-question"
title={t("settings.tickets.empty")}
subtitle={t("settings.tickets.emptyHint")}
/>
) : (
data?.pages?.map((page, pageIndex) => (
<React.Fragment key={pageIndex}>

View File

@@ -3,6 +3,7 @@
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Container from "@/components/elements/Container";
import EmptyState from "@/components/elements/EmptyState";
import RoundedButton from "@/components/elements/RoundedButton";
import MainProjectCard from "@/components/projects/MainProjectCard";
import PageTitle from "@/components/settings/PageTitle";
@@ -162,9 +163,11 @@ const Workroom = () => {
) : data?.pages.length === 0 ||
(data?.pages[0]?.projects?.length === 0 &&
!isFetchingNextPage) ? (
<p className="text-center text-gray-500">
{t("settings.workroom.empty")}
</p>
<EmptyState
icon="briefcase"
title={t("settings.workroom.empty")}
subtitle={t("settings.workroom.emptyHint")}
/>
) : (
data?.pages?.map((page, pageIndex) => (
<div key={pageIndex}>

View File

@@ -0,0 +1,15 @@
import type { Metadata } from "next";
// این مسیر داشبورد مدیریت محصولاتِ خودِ فروشنده‌ست (نسخه‌ی عمومیِ سئوشده در
// /shops/profile/[shopId] هست) — پس noindex می‌کنیم تا محتوای تکراری ایندکس نشه.
export const metadata: Metadata = {
robots: { index: false, follow: false },
};
export default function ShopManageLayout({
children,
}: {
children: React.ReactNode;
}) {
return children;
}

View File

@@ -40,7 +40,6 @@ function SpecRow({
<div className="flex flex-1 flex-wrap items-center justify-end gap-2 text-right">
{children}
</div>
<BoldIcon name="arrow-down" size={16} tinted className="shrink-0 text-neutral-400" />
</div>
);
}
@@ -439,7 +438,7 @@ export default function ProductManagePage() {
<span className="text-xs text-neutral-400 line-through">
{matchedVariant.price.toLocaleString()} {t("settings.toman")}
</span>
<span className="text-sm font-bold text-red-500">
<span className="text-sm font-bold text-red-500 dark:text-white">
{matchedVariant.discount_price.toLocaleString()} {t("settings.toman")}
</span>
</span>

View File

@@ -11,6 +11,7 @@ import { useEffect, useState } from "react";
import Image from "next/image";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import WalletUsagePrompt from "@/components/payment/WalletUsagePrompt";
type OrderDetail = {
_id: string;
@@ -37,21 +38,31 @@ export default function CheckoutPage() {
const params = useParams<{ orderId: string }>();
const { request, loading } = useAxios();
const [order, setOrder] = useState<OrderDetail | null>(null);
const [walletBalance, setWalletBalance] = useState(0);
const [showWalletPrompt, setShowWalletPrompt] = useState(false);
const [paidFromWallet, setPaidFromWallet] = useState(false);
useEffect(() => {
request<{ order: OrderDetail }>("GET", `/orders/${params.orderId}`)
.then((res) => setOrder(res?.order || null))
.catch(() => setOrder(null));
request<{ charged?: { available?: number } }>("GET", "/wallet/summary")
.then((res) => setWalletBalance(res?.charged?.available || 0))
.catch(() => setWalletBalance(0));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [params.orderId]);
const handlePay = async () => {
const startPayment = async (useWallet: boolean) => {
try {
const response = await request<{ authority?: string }>(
const response = await request<{ authority?: string; paidFromWallet?: boolean }>(
"POST",
"/payment/shop-order/initiate",
{ orderId: params.orderId }
{ orderId: params.orderId, useWallet }
);
if (response?.paidFromWallet) {
setPaidFromWallet(true);
return;
}
if (response?.authority) {
// router.push (next/navigation) برای URLهای بیرونی رفتارِ تضمین‌شده‌ای نداره —
// window.location.href مطمئن‌ترین راهه (خصوصاً سافاری) و با بقیه‌ی مسیرهای
@@ -66,8 +77,29 @@ export default function CheckoutPage() {
}
};
const handlePay = () => {
if (walletBalance > 0) {
setShowWalletPrompt(true);
return;
}
void startPayment(false);
};
if (!order) return null;
if (paidFromWallet) {
return (
<LocalePageShell>
<Container>
<PageTitle>{t("shops.checkoutTitle")}</PageTitle>
<p className="mx-auto mt-10 max-w-md text-center text-sm font-semibold text-green-600">
{t("wallet.paidFromWalletSuccess")}
</p>
</Container>
</LocalePageShell>
);
}
const primaryImage =
order.listing.images?.[order.listing.primaryImageIndex] ||
order.listing.images?.[0];
@@ -151,6 +183,16 @@ export default function CheckoutPage() {
{t("shops.proceedToPayment")}
</AuthNextButton>
</div>
<WalletUsagePrompt
isOpen={showWalletPrompt}
amount={order.total_amount}
walletBalance={walletBalance}
onAnswer={(useWallet) => {
setShowWalletPrompt(false);
void startPayment(useWallet);
}}
/>
</Container>
</LocalePageShell>
);

View File

@@ -0,0 +1,23 @@
import type { Metadata } from "next";
import { generatePageMetadata } from "@/utils/generatePageMetadata";
import { getServerLanguage } from "@/lib/i18n/server";
import { translateCommon } from "@/lib/i18n/translate";
export async function generateMetadata(): Promise<Metadata> {
const lang = await getServerLanguage();
return generatePageMetadata({
title: translateCommon(lang, "shopsDiscoverMeta.title"),
description: translateCommon(lang, "shopsDiscoverMeta.description"),
path: "/shops/discover",
lang,
});
}
export default function ShopsDiscoverLayout({
children,
}: {
children: React.ReactNode;
}) {
return children;
}

View File

@@ -44,7 +44,6 @@ function SpecRow({ label, children }: { label: string; children: React.ReactNode
<div className="flex flex-1 flex-wrap items-center justify-end gap-2 text-right">
{children}
</div>
<BoldIcon name="arrow-down" size={16} tinted className="shrink-0 text-neutral-400" />
</div>
);
}
@@ -285,12 +284,15 @@ export default function ListingDetailClient() {
<BackButton />
</div>
{/* طبق خواسته‌ی کاربر: دکمه‌ی اشتراک‌گذاریِ خودِ کالا (نه فروشگاه) روبروی نام
فروشگاه سمت چپ — عینِ ProductDetailScreen.tsx اندروید */}
فروشگاه سمت چپ — عینِ ProductDetailScreen.tsx اندروید. Link (آواتار+نام)
flex-1 داره تا فضای خالی رو خودش ببلعه؛ وگرنه justify-between روی سه فرزند
(این Link + دو دکمه) این فضا رو مساوی پخش می‌کرد و فاصله‌ی دو دکمه هم به‌اندازه‌ی
فاصله‌شون تا نام فروشگاه (خیلی بیشتر از حد معمول) می‌شد — عینِ shops/[shopId]/page.tsx */}
{shopInfo?._id ? (
<div className="flex w-full items-center justify-between gap-2">
<div className="flex w-full items-center gap-2">
<Link
href={`/shops/profile/${shopInfo._id}`}
className="flex items-center gap-2"
className="flex min-w-0 flex-1 items-center gap-2"
>
<span className="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-full bg-neutral-100 dark:bg-neutral-800">
{shopInfo?.logo ? (
@@ -310,7 +312,7 @@ export default function ListingDetailClient() {
/>
)}
</span>
<span className="text-sm font-bold">{shopInfo?.name}</span>
<span className="truncate text-sm font-bold">{shopInfo?.name}</span>
</Link>
<button
type="button"
@@ -524,7 +526,9 @@ export default function ListingDetailClient() {
<span className="text-xs text-neutral-400 line-through">
{(matchedVariant.price * quantity + shippingCost).toLocaleString()} {t("settings.toman")}
</span>
<span className="text-sm font-bold text-red-500">
{/* توی تم تاریک قرمزِ ثابت روی پس‌زمینه‌ی تیره کنتراست کمی داره — طبق
خواسته‌ی کاربر توی تاریک سفید بشه */}
<span className="text-sm font-bold text-red-500 dark:text-white">
{(matchedVariant.discount_price * quantity + shippingCost).toLocaleString()} {t("settings.toman")}
</span>
</span>

View File

@@ -26,7 +26,10 @@ function uniqueValues(values: (string | null | undefined)[]): string[] {
// Deduped per request — see loadUser in users/[username]/page.tsx for rationale.
const loadListing = cache(async (listingId: string) => {
const { data } = await fetchApiJson<ListingResponse>(`/shop-products/${listingId}`);
const { data } = await fetchApiJson<ListingResponse>(
`/shop-products/${listingId}`,
{ next: { revalidate: 45 } }
);
return data?.listing ?? null;
});

View File

@@ -30,7 +30,10 @@ type PublicShop = {
// this shop; cache() keeps them to a single backend fetch (see loadUser in
// users/[username]/page.tsx for the full rationale).
const loadShop = cache(async (shopId: string) => {
const { data } = await fetchApiJson<{ shop?: PublicShop }>(`/shops/public/${shopId}`);
const { data } = await fetchApiJson<{ shop?: PublicShop }>(
`/shops/public/${shopId}`,
{ next: { revalidate: 45 } }
);
return data?.shop ?? null;
});
@@ -97,6 +100,7 @@ export default async function ShopProfilePage({ params }: IShopProfilePageProps)
return (
<>
{shop && <h1 className="sr-only">{shop.name}</h1>}
{jsonLd && (
<script
type="application/ld+json"

View File

@@ -9,6 +9,8 @@ import {
countCoursesPages,
countPostsPages,
countProjectsPages,
countShopListingsPages,
countShopProfilesPages,
countUsersPages,
} from "@/lib/sitemap/builders";
@@ -35,6 +37,8 @@ export async function GET() {
billboardsPages,
coursesPages,
academiesPages,
shopListingsPages,
shopProfilesPages,
] = await Promise.all([
countPostsPages().catch(() => 1),
countUsersPages().catch(() => 1),
@@ -42,6 +46,8 @@ export async function GET() {
countBillboardsPages().catch(() => 1),
countCoursesPages().catch(() => 1),
countAcademiesPages().catch(() => 1),
countShopListingsPages().catch(() => 1),
countShopProfilesPages().catch(() => 1),
]);
const parts: string[] = [
@@ -67,6 +73,12 @@ export async function GET() {
for (let i = 1; i <= academiesPages; i++) {
parts.push(sitemapLoc(`/sitemaps/academies/${i}.xml`));
}
for (let i = 1; i <= shopListingsPages; i++) {
parts.push(sitemapLoc(`/sitemaps/shop-listings/${i}.xml`));
}
for (let i = 1; i <= shopProfilesPages; i++) {
parts.push(sitemapLoc(`/sitemaps/shop-profiles/${i}.xml`));
}
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">

View File

@@ -10,6 +10,8 @@ import {
buildFilterEntries,
buildPostsPage,
buildProjectsPage,
buildShopListingsPage,
buildShopProfilesPage,
buildStaticEntries,
buildUsersPage,
} from "@/lib/sitemap/builders";
@@ -84,6 +86,10 @@ async function resolveEntries(
return buildCoursesPage(page);
case "academies":
return buildAcademiesPage(page);
case "shop-listings":
return buildShopListingsPage(page);
case "shop-profiles":
return buildShopProfilesPage(page);
default:
return [];
}

View File

@@ -1,11 +1,13 @@
import { buildStorageUrl } from "@/components/main/BaseUrl";
import React, { cache } from "react";
import React, { cache, Suspense } from "react";
import { cookies } from "next/headers";
import { notFound } from "next/navigation";
import { User } from "@/types/types";
import Container from "@/components/elements/Container";
import ModelHead from "@/components/models/ModelPage/ModelHead";
import ModelContent from "@/components/models/ModelPage/ModelContent";
import ProfileVisitTracker from "@/components/explore/ProfileVisitTracker";
import ReferralCapture from "@/components/profile/ReferralCapture";
import { Metadata } from "next";
import { generatePageMetadata } from "@/utils/generatePageMetadata";
import { fetchApiJson } from "@/lib/api/fetchApiJson";
@@ -96,13 +98,7 @@ async function UserPage({ params }: IUserProps) {
const user = await loadUser(username, token);
if (!user) {
return (
<Container>
<div className="py-20 text-center font-bold">
{translateCommon(lang, "profile.userNotFound")}
</div>
</Container>
);
notFound();
}
if (user.blocked_you) {
@@ -145,6 +141,9 @@ async function UserPage({ params }: IUserProps) {
<LocalePageShell>
<article>
{user._id ? <ProfileVisitTracker profileUserId={user._id} /> : null}
<Suspense fallback={null}>
<ReferralCapture />
</Suspense>
<h1 className="sr-only">{seo.h1}</h1>
<ModelHead

View File

@@ -0,0 +1,52 @@
"use client";
import { cn } from "@/lib/utils";
type NumberedStarRatingProps = {
value: number;
onChange: (value: number) => void;
/** اندازه ستاره — پیش‌فرض ۲۸ مثل اندروید */
size?: number;
className?: string;
};
/**
* ستارهٔ امتیاز با عدد ۱ تا ۵ زیر هر ستاره — عین CommentBottomSheet اندروید
* (StarRating در PostCommentsSheet.tsx)
*/
export default function NumberedStarRating({
value,
onChange,
size = 28,
className,
}: NumberedStarRatingProps) {
return (
<div className={cn("flex items-center justify-center gap-2", className)}>
{[1, 2, 3, 4, 5].map((star) => {
const active = star <= value;
return (
<button
key={star}
type="button"
onClick={() => onChange(star === value ? 0 : star)}
className="flex flex-col items-center gap-0.5 active:opacity-70"
aria-label={`${star}`}
>
<span
className={cn(
"leading-none",
active ? "text-amber-500" : "text-neutral-300 dark:text-neutral-600"
)}
style={{ fontSize: size }}
>
★
</span>
<span className="text-[10px] font-medium tabular-nums text-neutral-500 dark:text-neutral-400">
{star}
</span>
</button>
);
})}
</div>
);
}

View File

@@ -31,20 +31,36 @@ export default function PushNotificationListener() {
const title = payload.notification?.title;
if (!title) return;
const body = payload.notification?.body;
// عکسِ فرستنده (یا بنرِ admin_broadcast) — همون فیلدی که سرویس‌ورکر برای حالتِ
// پس‌زمینه استفاده می‌کنه (utils/pushNotification.js: notification.image)
const avatar = payload.notification?.image;
// بک‌اند وقتی دقیقاً می‌دونه مقصد کجاست یه مسیرِ نسبیِ آماده می‌فرسته
// (notifyUser.js/chatHelpers.js: data.link)
const link = payload.data?.link || "/settings/notifications";
toast(
(t) => (
<button
type="button"
onClick={() => {
toast.dismiss(t.id);
router.push("/settings/notifications");
router.push(link);
}}
className="flex flex-col items-start gap-0.5 text-start"
className="flex items-center gap-2.5 text-start"
>
<span className="text-sm font-bold">{title}</span>
{body ? (
<span className="text-xs text-neutral-500">{body}</span>
{avatar ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={avatar}
alt=""
className="h-9 w-9 shrink-0 rounded-full object-cover"
/>
) : null}
<span className="flex flex-col gap-0.5">
<span className="text-sm font-bold">{title}</span>
{body ? (
<span className="text-xs text-neutral-500">{body}</span>
) : null}
</span>
</button>
),
{ duration: 6000 },

View File

@@ -0,0 +1,62 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import toast from "react-hot-toast";
import Modal from "@/components/elements/Modal";
import RoundedButton from "@/components/elements/RoundedButton";
import { apiClient } from "@/hooks/useAxios";
import { getApiBaseUrl } from "@/components/main/BaseUrl";
type GeoCheckResult = {
shouldWarn: boolean;
countryCode: string | null;
isProxy: boolean;
};
// عینِ VpnWarningGate.tsx اندروید: هر بار اپِ وب لود می‌شه (mount ریشه‌ی layout.tsx)
// چک می‌شه که کاربر با آی‌پیِ غیرایران یا VPN/پراکسی وصل شده یا نه؛ اگه بله، هم یه
// toast (معادلِ نوتیفِ اندروید — چون وب پرمیشنِ Notification API لازم داره) هم یه
// مودالِ غیراجباری نشون داده می‌شه
export default function VpnWarningGate() {
const { t } = useTranslation("common");
const [open, setOpen] = useState(false);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await apiClient.get<GeoCheckResult>(
`${getApiBaseUrl()}/geo/check`
);
if (cancelled || !res.data?.shouldWarn) return;
setOpen(true);
toast(t("settings.vpnWarningMessage"), { icon: "⚠️", duration: 6000 });
} catch {
/* چکِ VPN نباید هیچ جریان دیگه‌ای رو بشکنه */
}
})();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<Modal isOpen={open} onClose={() => setOpen(false)} height="auto" elevated>
<div className="flex flex-col items-center gap-4 py-2 text-center">
<h2 className="text-lg font-bold">{t("settings.vpnWarningTitle")}</h2>
<p className="text-sm text-neutral-500 dark:text-neutral-400">
{t("settings.vpnWarningMessage")}
</p>
<RoundedButton
variant="primary"
className="mt-2 h-10 w-40"
onClick={() => setOpen(false)}
>
{t("settings.vpnWarningAction")}
</RoundedButton>
</div>
</Modal>
);
}

View File

@@ -1,7 +1,7 @@
"use client";
import React from "react";
import Modal from "@/components/elements/Modal";
import CommentDrawer from "@/components/comments/CommentDrawer";
import CourseCommentsContent from "./CourseCommentsContent";
import { Course } from "@/types/types";
@@ -19,16 +19,20 @@ function CommentsModal({
onCommentAdded,
}: CommentsModalProps) {
return (
<Modal isOpen={isOpen} onClose={onClose} height="min(82vh, 520px)" elevated>
<CommentDrawer
isOpen={isOpen}
onOpenChange={(open) => !open && onClose()}
elevated
>
<CourseCommentsContent
courseId={postData._id}
courseName={postData.cuorse_name}
variant="modal"
variant="sheet"
onClose={onClose}
onCommentAdded={onCommentAdded}
className="h-full"
/>
</Modal>
</CommentDrawer>
);
}

View File

@@ -9,9 +9,9 @@ import React, {
import RoundedInput from "@/components/elements/RoundedInput";
import useAxios from "@/hooks/useAxios";
import toast from "react-hot-toast";
import Rate from "rc-rate";
import "rc-rate/assets/index.css";
import "@/styles/rc-rate-custom.css";
import NumberedStarRating from "@/components/comments/NumberedStarRating";
import CommentComposerBar, { COMMENT_LIST_PAD } from "@/components/comments/CommentComposerBar";
import { useCommentDrawer } from "@/components/comments/CommentDrawer";
import BoldIcon from "@/components/ui/BoldIcon";
import CommentsSkeleton from "@/components/ui/CommentsSkeleton";
import ProfileAvatar from "@/components/main/ProfileAvatar";
@@ -29,6 +29,15 @@ export interface CourseCommentUser {
verify_badge?: string;
}
/** این اندپوینت‌ها ساختارهای مختلفی برمی‌گردونن — یه تایپِ عمومیِ صریح که همون
* فیلدهایی که کد واقعاً استفاده می‌کنه رو پوشش می‌ده (بجای {} ضمنی) */
type ApiResponse = {
success?: boolean;
user?: any;
data?: any;
[key: string]: any;
};
export interface CourseComment {
_id: string;
comment: string;
@@ -119,6 +128,7 @@ export default function CourseCommentsContent({
className,
}: CourseCommentsContentProps) {
const { t } = useTranslation("common");
const { expandToFull } = useCommentDrawer();
const [newMessage, setNewMessage] = useState("");
const [rating, setRating] = useState(0);
const [comments, setComments] = useState<
@@ -143,7 +153,7 @@ export default function CourseCommentsContent({
const fetchUserInfo = async (userId: string) => {
if (usersCacheRef.current[userId]) return usersCacheRef.current[userId];
try {
const response = await request("GET", `/profile/${userId}`);
const response = await request<ApiResponse>("GET", `/profile/${userId}`);
if (response?.user) {
const userData: CourseCommentUser = {
_id: response.user._id,
@@ -166,7 +176,7 @@ export default function CourseCommentsContent({
setLoading(true);
try {
const response = await request(
const response = await request<ApiResponse>(
"GET",
`/academy/course/${courseId}/comments?page=${pageNum}&limit=20&status=accepted&sortBy=createdAt&sortOrder=desc`
);
@@ -266,7 +276,7 @@ export default function CourseCommentsContent({
payload.rate = rating;
}
const response = await request("POST", "/academy/comment/create", payload);
const response = await request<ApiResponse>("POST", "/academy/comment/create", payload);
if (response?.success) {
setNewMessage("");
@@ -291,8 +301,8 @@ export default function CourseCommentsContent({
: "pb-[calc(5.5rem+env(safe-area-inset-bottom))]";
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 data-comment-sheet className={cn("relative flex h-full min-h-0 flex-col overflow-hidden", className)}>
<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
@@ -306,7 +316,7 @@ export default function CourseCommentsContent({
) : null}
</div>
<div ref={containerRef} className="min-h-0 flex-1 overflow-y-auto px-4 py-3">
<div ref={containerRef} data-comment-list className={cn("min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-3", COMMENT_LIST_PAD)}>
{comments.length === 0 && loading ? (
<CommentsSkeleton />
) : comments.length === 0 && !loading ? (
@@ -333,21 +343,11 @@ export default function CourseCommentsContent({
) : null}
</div>
<div
className={cn(
"shrink-0 border-t border-neutral-200/60 px-4 pt-3 dark:border-neutral-700",
composerPad
)}
>
<CommentComposerBar>
{!hasRatedCourse && (
<div className="mb-3 flex flex-col items-center gap-1">
<span className="text-[11px] text-neutral-500">{t("posts.ratingOptional")}</span>
<Rate
value={rating}
onChange={(v) => setRating(v)}
count={5}
style={{ fontSize: 28, color: "#f59e0b" }}
/>
<NumberedStarRating value={rating} onChange={setRating} />
</div>
)}
<div className="flex items-center gap-2">
@@ -364,10 +364,12 @@ export default function CourseCommentsContent({
onChange={(e) => {
if (e.target.value.length <= 500) setNewMessage(e.target.value);
}}
onFocus={() => { window.scrollTo(0, 0); }}
className="flex-1"
/>
</div>
</div>
</CommentComposerBar>
</div>
);
}

View File

@@ -12,6 +12,15 @@ import { btnPrimary } from "@/lib/ui/buttonStyles";
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
/** این اندپوینت ساختارهای مختلفی برمی‌گردونه — یه تایپِ عمومیِ صریح که همون
* فیلدهایی که کد واقعاً استفاده می‌کنه رو پوشش می‌ده (بجای {} ضمنی) */
type ApiResponse = {
success?: boolean;
data?: any;
courses?: any[];
[key: string]: any;
};
interface InfinitePostsProps {
token: string;
filters: {
@@ -112,7 +121,7 @@ export default function InfinitePosts({ filters }: InfinitePostsProps) {
console.log("Fetching URL:", url);
const response = await request("GET", url);
const response = await request<ApiResponse>("GET", url);
console.log(`Courses page ${page} response:`, response);
@@ -194,6 +203,25 @@ export default function InfinitePosts({ filters }: InfinitePostsProps) {
filters?.sortOrder,
]);
// تپ دوباره روی تبِ آکادمی (وقتی از قبل توش هستیم) — مثل اینستاگرام، لیست رو
// از صفحه‌ی اول رفرش می‌کنه
useEffect(() => {
const handleTabReselect = (e: Event) => {
const detail = (e as CustomEvent<{ href: string }>).detail;
if (detail?.href !== "/academy") return;
setCourses([]);
setCoursesPage(1);
setHasMoreCourses(true);
setIsInitialCoursesLoading(true);
setTotalItems(0);
fetchCourses(1, false);
};
window.addEventListener("app:tab-reselect", handleTabReselect);
return () =>
window.removeEventListener("app:tab-reselect", handleTabReselect);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filterKey]);
// تنظیم Intersection Observer برای لود خودکار
useEffect(() => {
if (!hasMoreCourses || isLoadingMoreCourses || isInitialCoursesLoading)

View File

@@ -11,6 +11,7 @@ import React, {
import { Academy, Course } from "@/types/types";
import Image from "next/image";
import MainModelCardActions from "./MainModelCardActions";
import type { Comments } from "./CommentsModal";
import Link from "next/link";
import ExpandableCaption from "@/components/posts/ExpandableCaption";
import {
@@ -44,6 +45,15 @@ interface AcademyResponse {
academy: Academy;
}
/** این اندپوینت‌ها ساختارهای مختلفی برمی‌گردونن — یه تایپِ عمومیِ صریح که همون
* فیلدهایی که کد واقعاً استفاده می‌کنه رو پوشش می‌ده (بجای {} ضمنی) */
type ApiResponse = {
success?: boolean;
message?: string;
data?: any;
[key: string]: any;
};
function MainModelCard({
postData,
scrollKey,
@@ -100,7 +110,7 @@ function MainModelCard({
}
try {
const response = await request("GET", `/profile/${userId}`);
const response = await request<ApiResponse>("GET", `/profile/${userId}`);
if (response?.user) {
const userData = response.user;
setUsersCache((prev) => ({ ...prev, [userId]: userData }));
@@ -117,7 +127,7 @@ function MainModelCard({
if (!_id) return;
try {
const response = await request(
const response = await request<ApiResponse>(
"GET",
`/academy/course/${_id}/comments/count`
);
@@ -136,15 +146,15 @@ function MainModelCard({
setLoading(true);
try {
const response = await request(
const response = await request<ApiResponse>(
"GET",
`/academy/course/${_id}/comments?page=${pageNum}&limit=20&status=${statusFilter}&sortBy=${sortBy}&sortOrder=${sortOrder}`
);
let sum = 0;
let count = 0;
response.data.stats.rateDistribution.forEach(element => {
response.data.stats.rateDistribution.forEach((element: number) => {
sum += Number(element);
count++;
});
@@ -161,7 +171,7 @@ function MainModelCard({
if (response?.success) {
let newComments = response.data.comments;
let newComments: any[] = response.data.comments;
const pagination = response.data.pagination;
if (newComments && newComments.length > 0) {
@@ -202,7 +212,9 @@ function MainModelCard({
);
}
} else {
setComments(append ? [...prev] : []);
// باگ: prev اینجا تعریف نشده بود (setComments((prev) => ...) نبود) —
// با append=true و صفر کامنتِ جدید، این خط با ReferenceError کرش می‌کرد
setComments((prev) => (append ? prev : []));
}
setHasMore(pagination?.hasNextPage || false);
@@ -252,9 +264,9 @@ function MainModelCard({
useEffect(() => {
const fetchData = async () => {
try {
const response = (await request("POST", `/academy/findAcademyById`, {
const response = await request<AcademyResponse>("POST", `/academy/findAcademyById`, {
_id: academyId,
})) as AcademyResponse;
});
setAcademy(response?.academy);
console.log(response);
} catch (error) {

View File

@@ -17,6 +17,15 @@ import { profileActionBtnClass } from "@/lib/ui/buttonStyles";
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
/** این اندپوینت‌ها ساختارهای مختلفی برمی‌گردونن — یه تایپِ عمومیِ صریح که همون
* فیلدهایی که کد واقعاً استفاده می‌کنه رو پوشش می‌ده (بجای {} ضمنی) */
type ApiResponse = {
success?: boolean;
isLiked?: boolean;
data?: any;
[key: string]: any;
};
function MainModelCardActions({ postData }: { postData: Course }) {
const { t } = useTranslation("common");
const { _id, user_id, likes } = postData;
@@ -34,7 +43,7 @@ function MainModelCardActions({ postData }: { postData: Course }) {
const [showCommentsModal, setShowCommentsModal] = useState<boolean>(false);
const [showServicesModal, setShowServicesModal] = useState<boolean>(false);
const [liked, setLiked] = useState(false);
const [likesCountState, setLikesCount] = useState(likes || 0);
const [likesCountState, setLikesCount] = useState(Number(likes) || 0);
const { request, loading } = useAxios();
const router = useRouter();
@@ -42,7 +51,7 @@ function MainModelCardActions({ postData }: { postData: Course }) {
useEffect(() => {
const fetchData = async () => {
try {
const response = await request(
const response = await request<ApiResponse>(
"POST",
`/academy/academy/course/is-like`,
{
@@ -50,7 +59,7 @@ function MainModelCardActions({ postData }: { postData: Course }) {
}
);
setLiked(response?.isLiked);
setLiked(Boolean(response?.isLiked));
} catch (error) {
console.error("خطا در دریافت اطلاعات:", error);
}
@@ -101,7 +110,7 @@ function MainModelCardActions({ postData }: { postData: Course }) {
useEffect(() => {
const fetchCommentsStats = async () => {
try {
const response = await request(
const response = await request<ApiResponse>(
"GET",
`/academy/course/${_id}/comments/count`
);
@@ -174,7 +183,10 @@ function MainModelCardActions({ postData }: { postData: Course }) {
<SocialStatButton
layout="horizontal"
count={totalComments}
onClick={() => setShowCommentsModal(!showCommentsModal)}
onClick={() => {
if (!requireAuth()) return;
setShowCommentsModal(!showCommentsModal);
}}
aria-label={t("posts.comment")}
icon={
<Image

View File

@@ -1,5 +1,6 @@
"use client";
import { useRef } from "react";
import OtpInput from "react-otp-input";
import { motion } from "framer-motion";
import { cn } from "@/lib/utils";
@@ -30,6 +31,10 @@ export default function AuthOtpInput({
className,
}: AuthOtpInputProps) {
const { t } = useTranslation("common");
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
// اولین کادر خالی (یا آخرین کادر وقتی کد کامله) — کاربر با کلیک روی هر کادر دیگه
// باید به همین کادر منتقل بشه، نه اینکه بتونه با فاصله وسط کادرها تایپ کنه
const activeIndex = Math.min(value.length, numInputs - 1);
return (
<div className={cn("w-full flex justify-center", className)}>
@@ -58,6 +63,19 @@ export default function AuthOtpInput({
return (
<motion.input
{...inputProps}
ref={(el: HTMLInputElement | null) => {
inputRefs.current[index] = el;
if (typeof inputProps.ref === "function") {
inputProps.ref(el);
}
}}
onFocus={(e: React.FocusEvent<HTMLInputElement>) => {
if (index !== activeIndex) {
inputRefs.current[activeIndex]?.focus();
return;
}
inputProps.onFocus?.(e);
}}
disabled={disabled}
animate={
filled

View File

@@ -1,17 +0,0 @@
"use client";
import Container from "@/components/elements/Container";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import { useTranslation } from "react-i18next";
export default function BillboardAdNotFound() {
const { t } = useTranslation("common");
return (
<LocalePageShell>
<Container className="py-20 text-center">
{t("billboards.adNotFound")}
</Container>
</LocalePageShell>
);
}

View File

@@ -1,17 +0,0 @@
"use client";
import Container from "@/components/elements/Container";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import { useTranslation } from "react-i18next";
export default function BillboardNotFound() {
const { t } = useTranslation("common");
return (
<LocalePageShell>
<Container className="py-20 text-center font-bold">
{t("billboards.notFound")}
</Container>
</LocalePageShell>
);
}

View File

@@ -58,6 +58,9 @@ function BillboardDetails({
}
};
// lat/lng از سرور جابجا برمی‌گردن (submitBillboard.ts عمداً همین‌طور ذخیره‌شون
// می‌کنه، طبقِ تصمیمِ کاربر برای دورزدنِ نیازِ به migrationِ دیتابیس) — این
// تصحیح، تنها جاییه که باید هم برای نقشه هم برای دکمه‌ی مسیریابی استفاده بشه
const correctedLat = Number(lng);
const correctedLng = Number(lat);
@@ -136,7 +139,7 @@ function BillboardDetails({
)}
{lat && lng && lat !== "undefined" && lng !== "undefined" && (
<div className="my-5 rounded-2xl overflow-hidden">
<ShowMap lat={lat} lng={lng} height="200px" />
<ShowMap lat={correctedLat} lng={correctedLng} height="200px" />
</div>
)}
{showNavigationModal && (

View File

@@ -77,6 +77,18 @@ export default function InfiniteBillboard({
refetch();
}, [filterString, refetch]);
// تپ دوباره روی تبِ بیلبورد — مثل اینستاگرام، لیست رو رفرش می‌کنه
useEffect(() => {
const handleTabReselect = (e: Event) => {
const detail = (e as CustomEvent<{ href: string }>).detail;
if (detail?.href !== "/billboards") return;
refetch();
};
window.addEventListener("app:tab-reselect", handleTabReselect);
return () =>
window.removeEventListener("app:tab-reselect", handleTabReselect);
}, [refetch]);
useEffect(() => {
if (!observerRef.current || !hasNextPage) return;

View File

@@ -1,146 +1,220 @@
"use client";
import React, { useState } from "react";
import Modal from "../../elements/Modal";
import useInfiniteScroll from "@/hooks/useInfiniteScroll";
import VerificationBadge from "@/components/main/VerificationBadge";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import RoundedInput from "@/components/elements/RoundedInput";
import BoldIcon from "@/components/ui/BoldIcon";
import CommentsSkeleton from "@/components/ui/CommentsSkeleton";
import useAxios from "@/hooks/useAxios";
import { useRequireAuth } from "@/lib/auth/useRequireAuth";
import { useTranslation } from "react-i18next";
export interface Comments {
_id: string;
text: string;
createdAt: string;
updatedAt: string;
status: string;
advertisingId: string;
userId: User;
__v: never;
}
export interface User {
profile_image: string | null;
first_name: string;
last_name: string;
user_name: string;
is_verified: string;
verify_badge?: string;
user_type: string;
_id: string;
}
type ReportModalProps = {
isOpen: boolean;
onClose: () => void;
advertisingId: string;
};
function BillboardCommentsModal({
isOpen,
onClose,
advertisingId,
}: ReportModalProps) {
const { t } = useTranslation("common");
const { data, containerRef, isFetchingNextPage, isLoading, refetch } = useInfiniteScroll({
endpoint: "/advertising/comments",
queryKey: ["comments", advertisingId],
params: { advertisingId: advertisingId },
});
const [newMessage, setNewMessage] = useState("");
const { request } = useAxios();
const requireAuth = useRequireAuth();
const comments: Comments[] =
data?.pages?.flatMap((page) => page?.comments || []) || [];
const sendMessageHandler = async () => {
if (!requireAuth() || !newMessage.trim()) return;
try {
await request("POST", "/advertising/comment", {
text: newMessage,
advertisingId: advertisingId,
});
setNewMessage("");
refetch?.();
} catch (error) {
console.log(error);
}
};
return (
<Modal isOpen={isOpen} onClose={onClose} height="min(82vh, 520px)" elevated>
<div className="flex h-full flex-col">
<div className="flex shrink-0 items-center justify-center border-b border-neutral-200/60 px-4 py-3 dark:border-neutral-700">
<h3 className="font-bold">{t("posts.comments")}</h3>
</div>
<div ref={containerRef} className="min-h-0 flex-1 overflow-y-auto px-4 py-3">
{isLoading ? (
<CommentsSkeleton />
) : comments.length === 0 && !isFetchingNextPage ? (
<p className="py-8 text-center text-gray-500">{t("billboards.comments.empty")}</p>
) : (
comments.map((item) => (
<div
key={item._id}
className="mb-3 flex gap-2 border-b border-neutral-100 pb-2 dark:border-neutral-800"
>
<ProfileAvatar
src={item?.userId?.profile_image}
alt={item?.userId?.user_name || t("common.user")}
size="chat"
rounded="full"
className="shrink-0"
userId={item?.userId?._id}
verifyBadge={item?.userId?.verify_badge}
/>
<div className="min-w-0 flex-1 text-sm">
<div className="flex items-center gap-2">
<span className="inline-flex items-center gap-1 font-semibold">
{item?.userId?.user_name}
<VerificationBadge verifyBadge={item?.userId?.verify_badge} />
</span>
</div>
<p className="mt-0.5 break-words text-neutral-800 dark:text-neutral-100">
{item.text}
</p>
</div>
</div>
))
)}
{isFetchingNextPage && (
<p className="py-4 text-center text-sm text-neutral-400">
{t("billboards.comments.loading")}
</p>
)}
</div>
<div className="shrink-0 border-t border-neutral-200/60 px-4 pt-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] dark:border-neutral-700">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => void sendMessageHandler()}
className="ig-dm-send-btn gentle-transition flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-white active:scale-90"
>
<BoldIcon name="send-2" size={18} tinted className="text-current" />
</button>
<RoundedInput
value={newMessage}
placeholder={t("posts.writeComment")}
onChange={(e) => setNewMessage(e.target.value)}
className="flex-1"
/>
</div>
</div>
</div>
</Modal>
);
}
export default BillboardCommentsModal;
"use client";
import React, { useMemo, useState } from "react";
import CommentDrawer, {
useCommentDrawer,
} from "@/components/comments/CommentDrawer";
import NumberedStarRating from "@/components/comments/NumberedStarRating";
import CommentComposerBar, { COMMENT_LIST_PAD } from "@/components/comments/CommentComposerBar";
import useInfiniteScroll from "@/hooks/useInfiniteScroll";
import VerificationBadge from "@/components/main/VerificationBadge";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import RoundedInput from "@/components/elements/RoundedInput";
import BoldIcon from "@/components/ui/BoldIcon";
import CommentsSkeleton from "@/components/ui/CommentsSkeleton";
import useAxios from "@/hooks/useAxios";
import { useRequireAuth } from "@/lib/auth/useRequireAuth";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
export interface Comments {
_id: string;
text: string;
createdAt: string;
updatedAt: string;
status: string;
advertisingId: string;
rate?: number | null;
userId: User;
__v: never;
}
export interface User {
profile_image: string | null;
first_name: string;
last_name: string;
user_name: string;
is_verified: string;
verify_badge?: string;
user_type: string;
_id: string;
}
type ReportModalProps = {
isOpen: boolean;
onClose: () => void;
advertisingId: string;
};
function ComposerField({
value,
onChange,
placeholder,
}: {
value: string;
onChange: (v: string) => void;
placeholder: string;
}) {
const { expandToFull } = useCommentDrawer();
return (
<RoundedInput
value={value}
placeholder={placeholder}
onChange={(e) => onChange(e.target.value)}
onFocus={() => expandToFull()}
className="flex-1"
/>
);
}
function BillboardCommentsModal({
isOpen,
onClose,
advertisingId,
}: ReportModalProps) {
const { t } = useTranslation("common");
const { data, containerRef, isFetchingNextPage, isLoading, refetch } =
useInfiniteScroll({
endpoint: "/advertising/comments",
queryKey: ["comments", advertisingId],
params: { advertisingId: advertisingId },
});
const [newMessage, setNewMessage] = useState("");
const [rating, setRating] = useState(0);
const { request } = useAxios();
const requireAuth = useRequireAuth();
const comments: Comments[] =
data?.pages?.flatMap((page) => page?.comments || []) || [];
const hasRated = useMemo(() => {
const fromApi = data?.pages?.[0]?.hasRated;
if (typeof fromApi === "boolean") return fromApi;
return false;
}, [data?.pages]);
const sendMessageHandler = async () => {
if (!requireAuth() || !newMessage.trim()) return;
try {
const body: { text: string; advertisingId: string; rate?: number } = {
text: newMessage,
advertisingId: advertisingId,
};
if (!hasRated && rating >= 1 && rating <= 5) {
body.rate = rating;
}
await request("POST", "/advertising/comment", body);
setNewMessage("");
setRating(0);
refetch?.();
} catch (error) {
console.log(error);
}
};
return (
<CommentDrawer
isOpen={isOpen}
onOpenChange={(open) => !open && onClose()}
elevated
>
<div data-comment-sheet className="relative flex h-full min-h-0 flex-col overflow-hidden">
<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>
<button
type="button"
onClick={onClose}
className="absolute end-4 top-1/2 -translate-y-1/2 rounded-full p-1"
aria-label={t("common.close")}
>
<BoldIcon name="close-circle" size={20} tinted />
</button>
</div>
<div
ref={containerRef}
data-comment-list className={cn("min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-3", COMMENT_LIST_PAD)}
>
{isLoading ? (
<CommentsSkeleton />
) : comments.length === 0 && !isFetchingNextPage ? (
<p className="py-8 text-center text-gray-500">
{t("billboards.comments.empty")}
</p>
) : (
comments.map((item) => (
<div
key={item._id}
className="mb-3 flex gap-2 border-b border-neutral-100 pb-2 dark:border-neutral-800"
>
<ProfileAvatar
src={item?.userId?.profile_image}
alt={item?.userId?.user_name || t("common.user")}
size="chat"
rounded="full"
className="shrink-0"
userId={item?.userId?._id}
verifyBadge={item?.userId?.verify_badge}
/>
<div className="min-w-0 flex-1 text-sm">
<div className="flex items-center gap-2">
<span className="inline-flex items-center gap-1 font-semibold">
{item?.userId?.user_name}
<VerificationBadge
verifyBadge={item?.userId?.verify_badge}
/>
</span>
{item.rate != null && item.rate > 0 ? (
<span className="text-amber-500">★ {item.rate}</span>
) : null}
</div>
<p className="mt-0.5 break-words text-neutral-800 dark:text-neutral-100">
{item.text}
</p>
</div>
</div>
))
)}
{isFetchingNextPage && (
<p className="py-4 text-center text-sm text-neutral-400">
{t("billboards.comments.loading")}
</p>
)}
</div>
<CommentComposerBar>
{!hasRated && (
<div className="mb-3 flex flex-col items-center gap-1">
<span className="text-[11px] text-neutral-500">
{t("posts.ratingOptional")}
</span>
<NumberedStarRating value={rating} onChange={setRating} />
</div>
)}
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => void sendMessageHandler()}
className="ig-dm-send-btn gentle-transition flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-white active:scale-90"
>
<BoldIcon
name="send-2"
size={18}
tinted
className="text-current"
/>
</button>
<ComposerField
value={newMessage}
onChange={setNewMessage}
placeholder={t("posts.writeComment")}
/>
</div>
</CommentComposerBar>
</div>
</CommentDrawer>
);
}
export default BillboardCommentsModal;

View File

@@ -58,6 +58,11 @@ export async function submitBillboard(
data.append("address", address);
}
if (markerCoordinate) {
// طبقِ تصمیمِ صریحِ کاربر: بدونِ دست‌زدن به دیتابیس. اینجا عمداً (نه به‌اشتباه)
// جابجا ذخیره می‌شه، دقیقاً مثلِ همه‌ی آگهی‌های قبلی — تصحیح فقط یه‌جا و
// متمرکز، موقعِ نمایش، انجام می‌شه (BillboardDetails.tsx: correctedLat/Lng
// که الان هم برای نقشه هم برای دکمه‌ی مسیریابی استفاده می‌شه). اگه این خط
// عوض بشه بدونِ عوض‌کردنِ اون تصحیح، آگهی‌های تازه اشتباه نمایش داده می‌شن
data.append("lat", markerCoordinate[0]);
data.append("lng", markerCoordinate[1]);
}

View File

@@ -84,8 +84,10 @@ function AdProfileContent({ profile }: { profile: IAdvertisingProfile }) {
<BillboardLocationModal
showLocationModal={showLocationModal}
setShowLocationModal={setShowLocationModal}
lat={Number(lat)}
lng={Number(lng)}
// lat/lng از سرور جابجا میان (همون دلیلِ BillboardDetails.tsx) — اینجا
// هم باید تصحیح بشن وگرنه نشونگرِ نقشه جای اشتباه میفته
lat={Number(lng)}
lng={Number(lat)}
address={address || ""}
/>
)}

View File

@@ -7,6 +7,7 @@ import BookingReservationItem, {
type ReceivedBooking,
} from "./BookingReservationItem";
import BookingStatusModal from "./BookingStatusModal";
import EmptyState from "@/components/elements/EmptyState";
export default function IncomingBookingsList() {
const { t } = useTranslation("common");
@@ -41,9 +42,11 @@ export default function IncomingBookingsList() {
{t("common.loading")}
</p>
) : bookings.length === 0 ? (
<p className="py-10 text-center text-gray-500">
{t("booking.bookingsListEmpty")}
</p>
<EmptyState
icon="calendar"
title={t("booking.bookingsListEmpty")}
subtitle={t("booking.bookingsListEmptyHint")}
/>
) : (
bookings.map((booking) => (
<BookingReservationItem

View File

@@ -18,6 +18,7 @@ import {
type ChatThreadData,
} from "@/lib/chat/threadCache";
import ChatMessageCard, { ChatMessage } from "./ChatMessageCard";
import EmptyState from "@/components/elements/EmptyState";
import ChatDateSeparator from "./ChatDateSeparator";
import { groupMessagesByDate } from "@/lib/chat/groupMessagesByDate";
import { dedupeChatMessages, filterResolvedPendingMessages } from "@/lib/chat/dedupeMessages";
@@ -679,9 +680,11 @@ const ChatMessageList = ({
</button>
</div>
) : grouped.length === 0 ? (
<p className="py-12 text-center text-sm text-neutral-500">
{t("chats.messagesEmpty")}
</p>
<EmptyState
icon="message"
title={t("chats.messagesEmpty")}
subtitle={t("chats.messagesEmptyHint")}
/>
) : (
<div className="flex flex-col pb-1">
{grouped.map((item, index) => {

View File

@@ -1,6 +1,6 @@
"use client";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import Modal from "@/components/elements/Modal";
import RoundedButton from "@/components/elements/RoundedButton";
import RoundedInput from "@/components/elements/RoundedInput";
@@ -8,6 +8,7 @@ import useAxios from "@/hooks/useAxios";
import { filterChipClass } from "@/lib/ui/buttonStyles";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import WalletUsagePrompt from "@/components/payment/WalletUsagePrompt";
const PRESET_AMOUNTS = [2000, 5000, 10000, 15000, 20000, 50000, 100000];
const CUSTOM_MIN = 5000;
@@ -43,6 +44,16 @@ export default function GiftInChatModal({
const [isCustom, setIsCustom] = useState(false);
const [message, setMessage] = useState("");
const [submitting, setSubmitting] = useState(false);
const [walletBalance, setWalletBalance] = useState(0);
const [showWalletPrompt, setShowWalletPrompt] = useState(false);
useEffect(() => {
if (!open) return;
request<{ charged?: { available?: number } }>("GET", "/wallet/summary")
.then((res) => setWalletBalance(res?.charged?.available || 0))
.catch(() => setWalletBalance(0));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const finalAmount = useMemo(() => {
if (isCustom) return Number(customAmount) || 0;
@@ -62,14 +73,14 @@ export default function GiftInChatModal({
setMessage("");
};
const handleSubmit = async () => {
const handleSubmit = async (useWallet: boolean) => {
if (!canSubmit) return;
setSubmitting(true);
try {
const response = await request<GiftChatResponse>(
"POST",
"/gifts/chat/initiate",
{ receiverId, amount: finalAmount, message, return_path: returnPath }
{ receiverId, amount: finalAmount, message, return_path: returnPath, useWallet }
);
if (response?.paidFromWallet && response.gift) {
toast.success(t("chats.gift.sentSuccess"));
@@ -93,6 +104,15 @@ export default function GiftInChatModal({
}
};
const handlePayClick = () => {
if (!canSubmit) return;
if (walletBalance > 0) {
setShowWalletPrompt(true);
return;
}
void handleSubmit(false);
};
return (
<Modal
isOpen={open}
@@ -169,11 +189,21 @@ export default function GiftInChatModal({
variant="primary"
className="h-10 w-40"
disabled={!canSubmit}
onClick={() => void handleSubmit()}
onClick={handlePayClick}
>
{submitting ? t("chats.gift.sending") : t("giftPage.payButton")}
</RoundedButton>
</div>
<WalletUsagePrompt
isOpen={showWalletPrompt}
amount={finalAmount}
walletBalance={walletBalance}
onAnswer={(useWallet) => {
setShowWalletPrompt(false);
void handleSubmit(useWallet);
}}
/>
</div>
</Modal>
);

View File

@@ -0,0 +1,54 @@
"use client";
import Image from "next/image";
import { useTranslation } from "react-i18next";
import Modal from "@/components/elements/Modal";
import RoundedButton from "@/components/elements/RoundedButton";
type Props = {
status: "success" | "failed" | null;
onClose: () => void;
onRetry: () => void;
};
// نتیجه‌ی پرداختِ هدیه‌ی داخلِ چت — قبلاً موفقیت کاملاً بی‌صدا بود (فقط پیام هدیه
// توی ترد اضافه می‌شد) و شکست اصلاً پردازش نمی‌شد (?giftPaymentFailed=1 هیچ‌جا خونده
// نمی‌شد). همون الگوی صفحه‌های اختصاصیِ gift/payment/success|failed رو اینجا به‌شکلِ
// مودال نشون می‌ده، چون کاربر همین‌جا وسطِ چته و نباید از صفحه خارج بشه
export default function GiftPaymentResultModal({ status, onClose, onRetry }: Props) {
const { t } = useTranslation("common");
if (!status) return null;
const success = status === "success";
return (
<Modal isOpen height="auto" elevated onClose={onClose}>
<div className="flex flex-col items-center gap-4 py-2 text-center">
<Image
width={50}
height={50}
alt=""
src={success ? "/images/icons/success.svg" : "/images/icons/failed.svg"}
/>
<p className="text-sm font-semibold">
{success
? t("giftPage.paymentSuccess.message")
: t("giftPage.paymentFailed.message")}
</p>
{success ? (
<RoundedButton variant="primary" className="h-9 w-40" onClick={onClose}>
{t("giftPage.paymentSuccess.backToChat")}
</RoundedButton>
) : (
<div className="grid grid-cols-2 gap-4">
<RoundedButton variant="primary" className="h-9 w-32" onClick={onRetry}>
{t("giftPage.paymentFailed.retry")}
</RoundedButton>
<RoundedButton variant="primary" className="h-9 w-32" onClick={onClose}>
{t("giftPage.paymentFailed.back")}
</RoundedButton>
</div>
)}
</div>
</Modal>
);
}

View File

@@ -1,6 +1,7 @@
"use client";
import useInfiniteScroll from "@/hooks/useInfiniteScroll";
import EmptyState from "@/components/elements/EmptyState";
import MessageCard from "./MessageCard";
import { User } from "@/types/types";
import React from "react";
@@ -31,7 +32,11 @@ const MessageList = ({ id, userDetail }: MessageListProps) => {
<div className="w-full mt-5 pb-10">
{data?.pages.length === 0 ||
(data?.pages[0]?.tickets?.length === 0 && !isFetchingNextPage) ? (
<p className="text-center text-gray-500">{t("chats.commentsEmpty")}</p>
<EmptyState
icon="message"
title={t("chats.commentsEmpty")}
subtitle={t("chats.commentsEmptyHint")}
/>
) : (
data?.pages?.map((page, pageIndex) => (
<React.Fragment key={pageIndex}>

View File

@@ -0,0 +1,43 @@
"use client";
import { cn } from "@/lib/utils";
import { useCommentDrawer } from "@/components/comments/CommentDrawer";
type CommentComposerBarProps = {
children: React.ReactNode;
className?: string;
/** ارتفاع تقریبی برای padding لیست — پیش‌فرض فضای ستاره+اینپوت */
listPadClassName?: string;
};
/**
* نوار پایین مودال کامنت — همیشه به پایین پنل می‌چسبد
* و با keyboardOffset دقیقاً بالای کیبورد می‌ایستد (بدون پرش به وسط صفحه).
*/
export default function CommentComposerBar({
children,
className,
}: CommentComposerBarProps) {
const { keyboardOffset, expandToFull } = useCommentDrawer();
return (
<div
data-comment-composer
className={cn(
"absolute inset-x-0 z-10 px-4 pt-3",
"pb-[calc(0.75rem+env(safe-area-inset-bottom))]",
className
)}
style={{
bottom: keyboardOffset > 0 ? keyboardOffset : 0,
}}
onFocusCapture={() => expandToFull()}
>
{children}
</div>
);
}
/** className برای padding-bottom لیست تا زیر composer نرود */
export const COMMENT_LIST_PAD =
"pb-[calc(8.5rem+env(safe-area-inset-bottom))]";

View File

@@ -0,0 +1,167 @@
"use client";
import React, {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { Drawer } from "vaul";
import { cn } from "@/lib/utils";
import { glassBackdropStyle } from "@/lib/glassBackdrop";
const DEFAULT_SNAP = 0.62;
const FULLSCREEN_SNAP = 1;
const SNAP_POINTS = [DEFAULT_SNAP, FULLSCREEN_SNAP];
type CommentDrawerContextValue = {
expandToFull: () => void;
isFullscreen: boolean;
keyboardOffset: number;
};
const CommentDrawerContext = createContext<CommentDrawerContextValue>({
expandToFull: () => {},
isFullscreen: false,
keyboardOffset: 0,
});
export function useCommentDrawer() {
return useContext(CommentDrawerContext);
}
type CommentDrawerProps = {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
children: React.ReactNode;
elevated?: boolean;
onFullscreenChange?: (isFullscreen: boolean) => void;
};
/**
* مودال کامنت — رفتار پایدار روی کروم موبایل
*
* مشکل قبلی: با فوکوس، کروم اینپوت را وسط visualViewport می‌آورد
* و با کم‌کردن height به‌اندازه کیبورد، باکس «می‌پرید بالا» و زیرش خالی می‌ماند.
*
* راه‌حل:
* - ارتفاع پنل = اسنپ دیده‌شده (۶۲٪ یا ۱۰۰٪) — بدون کم‌کردن کیبورد از height
* - children با position:absolute و bottom:keyboardOffset باکس را
* دقیقاً بالای کیبورد می‌چسباند (CommentComposerBar)
*/
export default function CommentDrawer({
isOpen,
onOpenChange,
children,
elevated = false,
onFullscreenChange,
}: CommentDrawerProps) {
const [snap, setSnap] = useState<number | string | null>(DEFAULT_SNAP);
const [keyboardOffset, setKeyboardOffset] = useState(0);
const keyboardOpenRef = useRef(false);
const isFullscreen = snap === FULLSCREEN_SNAP;
const expandToFull = useCallback(() => {
setSnap(FULLSCREEN_SNAP);
}, []);
useEffect(() => {
if (isOpen) {
setSnap(DEFAULT_SNAP);
setKeyboardOffset(0);
keyboardOpenRef.current = false;
}
}, [isOpen]);
useEffect(() => {
onFullscreenChange?.(isFullscreen);
}, [isFullscreen, onFullscreenChange]);
useEffect(() => {
if (!isOpen || typeof window === "undefined") return;
const vv = window.visualViewport;
if (!vv) return;
const update = () => {
// فقط اختلاف innerHeight و visualViewport — بدون offsetTop (باعث پرش می‌شد)
const offset = Math.max(0, Math.round(window.innerHeight - vv.height));
const next = offset > 80 ? offset : 0;
setKeyboardOffset(next);
if (next > 0 && !keyboardOpenRef.current) {
keyboardOpenRef.current = true;
setSnap(FULLSCREEN_SNAP);
}
if (next === 0) {
keyboardOpenRef.current = false;
}
};
vv.addEventListener("resize", update);
update();
return () => vv.removeEventListener("resize", update);
}, [isOpen]);
// ارتفاع پنل = فقط اسنپ؛ کیبورد را با absolute bottom روی composer جبران می‌کنیم
const panelHeight =
isFullscreen || keyboardOffset > 0
? "100dvh"
: `${DEFAULT_SNAP * 100}dvh`;
return (
<CommentDrawerContext.Provider
value={{ expandToFull, isFullscreen, keyboardOffset }}
>
<Drawer.Root
open={isOpen}
onOpenChange={onOpenChange}
snapPoints={SNAP_POINTS}
activeSnapPoint={snap}
setActiveSnapPoint={(v) => {
if (keyboardOffset > 0) {
setSnap(FULLSCREEN_SNAP);
return;
}
setSnap(v);
}}
fadeFromIndex={0}
dismissible={keyboardOffset === 0}
>
<Drawer.Portal>
<Drawer.Overlay
className={cn(
"glass-modal-overlay fixed inset-0",
elevated ? "z-[220]" : "z-40"
)}
style={glassBackdropStyle("glass-modal-overlay")}
/>
<Drawer.Content
aria-describedby={undefined}
className={cn(
"glass-modal-panel glass-modal-panel--sheet !border-0",
"fixed inset-x-0 bottom-0 flex h-full max-h-[100dvh] flex-col overflow-hidden rounded-t-3xl outline-none",
elevated ? "z-[220]" : "z-40"
)}
style={glassBackdropStyle("glass-modal-panel")}
>
<Drawer.Title className="sr-only">Comments</Drawer.Title>
<div
className="relative flex min-h-0 flex-col overflow-hidden"
style={{ height: panelHeight, maxHeight: panelHeight }}
>
<div className="mx-auto mt-3 h-1 w-10 shrink-0 rounded-full bg-neutral-400/50 dark:bg-neutral-600/60" />
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
{children}
</div>
</div>
</Drawer.Content>
</Drawer.Portal>
</Drawer.Root>
</CommentDrawerContext.Provider>
);
}

View File

@@ -0,0 +1,53 @@
"use client";
import { cn } from "@/lib/utils";
type NumberedStarRatingProps = {
value: number;
onChange: (value: number) => void;
size?: number;
className?: string;
};
/** ستاره با عدد ۱–۵ — ترتیب همیشه LTR مثل اندروید */
export default function NumberedStarRating({
value,
onChange,
size = 28,
className,
}: NumberedStarRatingProps) {
return (
<div
dir="ltr"
className={cn("flex items-center justify-center gap-2", className)}
>
{[1, 2, 3, 4, 5].map((star) => {
const active = star <= value;
return (
<button
key={star}
type="button"
onClick={() => onChange(star === value ? 0 : star)}
className="flex flex-col items-center gap-0.5 active:opacity-70"
aria-label={`${star}`}
>
<span
className={cn(
"leading-none",
active
? "text-amber-500"
: "text-neutral-300 dark:text-neutral-600"
)}
style={{ fontSize: size }}
>
★
</span>
<span className="text-[10px] font-medium tabular-nums text-neutral-500 dark:text-neutral-400">
{star}
</span>
</button>
);
})}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More