full app
This commit is contained in:
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "modstagram-next-dev",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"port": 3004
|
||||
}
|
||||
]
|
||||
}
|
||||
1
.claude/worktrees/agent-af1dfc8fe9a6cb198
Submodule
1
.claude/worktrees/agent-af1dfc8fe9a6cb198
Submodule
Submodule .claude/worktrees/agent-af1dfc8fe9a6cb198 added at 3b89bbfec3
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
BIN
public/images/og-image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
177
src/app/(auth)/(register)/register/avatar/page.tsx
Normal file
177
src/app/(auth)/(register)/register/avatar/page.tsx
Normal 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;
|
||||
@@ -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"}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
16
src/app/booking/[userId]/layout.tsx
Normal file
16
src/app/booking/[userId]/layout.tsx
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
}: {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
25
src/app/not-found.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -34,6 +34,9 @@ export default function robots(): MetadataRoute.Robots {
|
||||
"/projects/payment/",
|
||||
"/billboards/payment/",
|
||||
"/academy/payment/",
|
||||
"/shops/new",
|
||||
"/shops/checkout",
|
||||
"/shops/orders",
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import Title from "@/Components/ui/Title";
|
||||
import Title from "@/components/ui/Title";
|
||||
import React, { ReactNode } from "react";
|
||||
|
||||
interface BoxProps {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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`
|
||||
);
|
||||
|
||||
@@ -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`
|
||||
);
|
||||
|
||||
135
src/app/settings/ads/[id]/page.tsx
Normal file
135
src/app/settings/ads/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
765
src/app/settings/ads/new/page.tsx
Normal file
765
src/app/settings/ads/new/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
97
src/app/settings/ads/page.tsx
Normal file
97
src/app/settings/ads/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
58
src/app/settings/auto-direct/page.tsx
Normal file
58
src/app/settings/auto-direct/page.tsx
Normal 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;
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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)}
|
||||
>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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" ? (
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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) ? (
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
24
src/app/settings/shareable-links/page.tsx
Normal file
24
src/app/settings/shareable-links/page.tsx
Normal 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;
|
||||
@@ -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}>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
15
src/app/shops/[shopId]/layout.tsx
Normal file
15
src/app/shops/[shopId]/layout.tsx
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
23
src/app/shops/discover/layout.tsx
Normal file
23
src/app/shops/discover/layout.tsx
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
52
src/components/NumberedStarRating.tsx
Normal file
52
src/components/NumberedStarRating.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
62
src/components/VpnWarningGate.tsx
Normal file
62
src/components/VpnWarningGate.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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 || ""}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
54
src/components/chat/GiftPaymentResultModal.tsx
Normal file
54
src/components/chat/GiftPaymentResultModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
43
src/components/comments/CommentComposerBar.tsx
Normal file
43
src/components/comments/CommentComposerBar.tsx
Normal 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))]";
|
||||
167
src/components/comments/CommentDrawer.tsx
Normal file
167
src/components/comments/CommentDrawer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
src/components/comments/NumberedStarRating.tsx
Normal file
53
src/components/comments/NumberedStarRating.tsx
Normal 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
Reference in New Issue
Block a user