Compare commits
36 Commits
bf41ad4e0f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
551260a836 | ||
|
|
f364f989ed | ||
|
|
d6386b779f | ||
|
|
9fb454b0a7 | ||
|
|
6e76e367f3 | ||
|
|
200b387504 | ||
|
|
3b89bbfec3 | ||
|
|
76d5652252 | ||
|
|
2da2defde7 | ||
|
|
e9a5c4b7b3 | ||
|
|
e7ce4d6808 | ||
|
|
0602bde788 | ||
|
|
1d1d3cd461 | ||
|
|
83c2102afe | ||
|
|
0c080d22cd | ||
|
|
9342494a36 | ||
|
|
c22df8231e | ||
|
|
9e0760d9c5 | ||
|
|
c1189164b8 | ||
|
|
efad306617 | ||
|
|
1efb435dad | ||
|
|
43896b4caf | ||
|
|
f18e19aff7 | ||
|
|
3e3351a9c3 | ||
|
|
e484b85bf5 | ||
|
|
1b8eadbf19 | ||
|
|
e31a08df41 | ||
|
|
71da2b53fe | ||
|
|
e652c255c9 | ||
|
|
60d1aac55b | ||
|
|
d7051c6b16 | ||
|
|
d2f3a96ddf | ||
|
|
9310062616 | ||
|
|
9d48c0534e | ||
|
|
901ce94443 | ||
|
|
2d5505065b |
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "modstagram-next",
|
||||
"version": "0.1.0",
|
||||
"version": "4.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "modstagram-next",
|
||||
"version": "0.1.0",
|
||||
"version": "4.2.0",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "modstagram-next",
|
||||
"version": "0.1.0",
|
||||
"version": "4.4.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack --port 3004",
|
||||
|
||||
1
public/images/icons/brand/instagram-brand.svg
Normal file
1
public/images/icons/brand/instagram-brand.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 102 102" id="instagram"><defs><radialGradient id="a" cx="6.601" cy="99.766" r="129.502" gradientUnits="userSpaceOnUse"><stop offset=".09" stop-color="#fa8f21"></stop><stop offset=".78" stop-color="#d82d7e"></stop></radialGradient><radialGradient id="b" cx="70.652" cy="96.49" r="113.963" gradientUnits="userSpaceOnUse"><stop offset=".64" stop-color="#8c3aaa" stop-opacity="0"></stop><stop offset="1" stop-color="#8c3aaa"></stop></radialGradient></defs><path fill="url(#a)" d="M25.865,101.639A34.341,34.341,0,0,1,14.312,99.5a19.329,19.329,0,0,1-7.154-4.653A19.181,19.181,0,0,1,2.5,87.694,34.341,34.341,0,0,1,.364,76.142C.061,69.584,0,67.617,0,51s.067-18.577.361-25.14A34.534,34.534,0,0,1,2.5,14.312,19.4,19.4,0,0,1,7.154,7.154,19.206,19.206,0,0,1,14.309,2.5,34.341,34.341,0,0,1,25.862.361C32.422.061,34.392,0,51,0s18.577.067,25.14.361A34.534,34.534,0,0,1,87.691,2.5a19.254,19.254,0,0,1,7.154,4.653A19.267,19.267,0,0,1,99.5,14.309a34.341,34.341,0,0,1,2.14,11.553c.3,6.563.361,8.528.361,25.14s-.061,18.577-.361,25.14A34.5,34.5,0,0,1,99.5,87.694,20.6,20.6,0,0,1,87.691,99.5a34.342,34.342,0,0,1-11.553,2.14c-6.557.3-8.528.361-25.14.361s-18.577-.058-25.134-.361"></path><path fill="url(#b)" d="M25.865,101.639A34.341,34.341,0,0,1,14.312,99.5a19.329,19.329,0,0,1-7.154-4.653A19.181,19.181,0,0,1,2.5,87.694,34.341,34.341,0,0,1,.364,76.142C.061,69.584,0,67.617,0,51s.067-18.577.361-25.14A34.534,34.534,0,0,1,2.5,14.312,19.4,19.4,0,0,1,7.154,7.154,19.206,19.206,0,0,1,14.309,2.5,34.341,34.341,0,0,1,25.862.361C32.422.061,34.392,0,51,0s18.577.067,25.14.361A34.534,34.534,0,0,1,87.691,2.5a19.254,19.254,0,0,1,7.154,4.653A19.267,19.267,0,0,1,99.5,14.309a34.341,34.341,0,0,1,2.14,11.553c.3,6.563.361,8.528.361,25.14s-.061,18.577-.361,25.14A34.5,34.5,0,0,1,99.5,87.694,20.6,20.6,0,0,1,87.691,99.5a34.342,34.342,0,0,1-11.553,2.14c-6.557.3-8.528.361-25.14.361s-18.577-.058-25.134-.361"></path><path fill="#fff" d="M461.114,477.413a12.631,12.631,0,1,1,12.629,12.632,12.631,12.631,0,0,1-12.629-12.632m-6.829,0a19.458,19.458,0,1,0,19.458-19.458,19.457,19.457,0,0,0-19.458,19.458m35.139-20.229a4.547,4.547,0,1,0,4.549-4.545h0a4.549,4.549,0,0,0-4.547,4.545m-30.99,51.074a20.943,20.943,0,0,1-7.037-1.3,12.547,12.547,0,0,1-7.193-7.19,20.923,20.923,0,0,1-1.3-7.037c-.184-3.994-.22-5.194-.22-15.313s.04-11.316.22-15.314a21.082,21.082,0,0,1,1.3-7.037,12.54,12.54,0,0,1,7.193-7.193,20.924,20.924,0,0,1,7.037-1.3c3.994-.184,5.194-.22,15.309-.22s11.316.039,15.314.221a21.082,21.082,0,0,1,7.037,1.3,12.541,12.541,0,0,1,7.193,7.193,20.926,20.926,0,0,1,1.3,7.037c.184,4,.22,5.194.22,15.314s-.037,11.316-.22,15.314a21.023,21.023,0,0,1-1.3,7.037,12.547,12.547,0,0,1-7.193,7.19,20.925,20.925,0,0,1-7.037,1.3c-3.994.184-5.194.22-15.314.22s-11.316-.037-15.309-.22m-.314-68.509a27.786,27.786,0,0,0-9.2,1.76,19.373,19.373,0,0,0-11.083,11.083,27.794,27.794,0,0,0-1.76,9.2c-.187,4.04-.229,5.332-.229,15.623s.043,11.582.229,15.623a27.793,27.793,0,0,0,1.76,9.2,19.374,19.374,0,0,0,11.083,11.083,27.813,27.813,0,0,0,9.2,1.76c4.042.184,5.332.229,15.623.229s11.582-.043,15.623-.229a27.8,27.8,0,0,0,9.2-1.76,19.374,19.374,0,0,0,11.083-11.083,27.716,27.716,0,0,0,1.76-9.2c.184-4.043.226-5.332.226-15.623s-.043-11.582-.226-15.623a27.786,27.786,0,0,0-1.76-9.2,19.379,19.379,0,0,0-11.08-11.083,27.748,27.748,0,0,0-9.2-1.76c-4.041-.185-5.332-.229-15.621-.229s-11.583.043-15.626.229" transform="translate(-422.637 -426.196)"></path></svg>
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
14
public/images/icons/brand/phone-brand.svg
Normal file
14
public/images/icons/brand/phone-brand.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 48 48" style="enable-background:new 0 0 48 48;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#00FF00;}
|
||||
.st1{fill:#FFFFFF;}
|
||||
</style>
|
||||
<path class="st0" d="M9.3,48h29.3c5.2,0,9.3-4.2,9.3-9.3V9.3C48,4.2,43.8,0,38.7,0H9.3C4.2,0,0,4.2,0,9.3v29.3C0,43.8,4.2,48,9.3,48
|
||||
z"/>
|
||||
<path class="st1" d="M39.3,33.4l-6.1-4.1c-0.8-0.5-1.7-0.5-2.4,0c0,0,0,0-2.6,1.5c-0.4,0.2-0.7,0.2-1,0.2c-0.3-0.1-0.6-0.3-0.6-0.3
|
||||
c-1.4-1.1-3.1-2.5-4.9-4.3c-1.8-1.8-3.2-3.5-4.3-4.9c0,0-0.2-0.3-0.3-0.6c0-0.3,0-0.6,0.2-1c1.5-2.6,1.5-2.6,1.5-2.6
|
||||
c0.4-0.8,0.4-1.7,0-2.4l-4.1-6.1c-0.5-0.8-1.5-1-2.3-0.4c0,0-1.9,1.3-2.6,1.8c-1,0.7-1.3,1.8-1.3,3.3c0,2.1,1.8,8.8,9.6,16.6l0,0
|
||||
l0,0l0,0l0,0c7.8,7.8,14.5,9.6,16.6,9.6c1.5,0,2.6-0.3,3.3-1.3c0.4-0.6,1.8-2.6,1.8-2.6C40.2,34.9,40,33.9,39.3,33.4z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 953 B |
16
public/images/icons/brand/telegram-brand.svg
Normal file
16
public/images/icons/brand/telegram-brand.svg
Normal file
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 48 48" style="enable-background:new 0 0 48 48;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#29B6F6;}
|
||||
.st1{fill:#FFFFFF;}
|
||||
.st2{fill:#B0BEC5;}
|
||||
.st3{fill:#CFD8DC;}
|
||||
</style>
|
||||
<path class="st0" d="M24,0C10.7,0,0,10.7,0,24s10.7,24,24,24s24-10.7,24-24S37.3,0,24,0z"/>
|
||||
<path class="st1" d="M35.9,13.2l-4.5,23c0,0-0.2,1-1.5,1c-0.7,0-1-0.3-1-0.3l-9.7-8.1l-4.8-2.4l-6.1-1.6c0,0-1.1-0.3-1.1-1.2
|
||||
c0-0.8,1.1-1.1,1.1-1.1l25.6-10.2c0,0,0.8-0.3,1.4-0.3c0.4,0,0.8,0.1,0.8,0.6C36,12.9,35.9,13.2,35.9,13.2z"/>
|
||||
<path class="st2" d="M22.8,31.8l-4.1,4c0,0-0.2,0.1-0.4,0.1c-0.1,0-0.2,0-0.3-0.1l1.2-7.2L22.8,31.8z"/>
|
||||
<path class="st3" d="M31.1,17c-0.2-0.3-0.6-0.3-0.8-0.1l-15.8,9.5c0,0,2.5,7.1,2.9,8.3c0.4,1.2,0.7,1.3,0.7,1.3l1.2-7.2L31,17.9
|
||||
C31.2,17.7,31.3,17.3,31.1,17z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 955 B |
26
public/images/icons/brand/whatsapp-brand.svg
Normal file
26
public/images/icons/brand/whatsapp-brand.svg
Normal file
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 48 48" style="enable-background:new 0 0 48 48;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill-rule:evenodd;clip-rule:evenodd;fill:#FFFFFF;}
|
||||
.st1{fill-rule:evenodd;clip-rule:evenodd;fill:#CFD8DC;}
|
||||
.st2{fill-rule:evenodd;clip-rule:evenodd;fill:#40C351;}
|
||||
</style>
|
||||
<path class="st0" d="M1.3,46.8l3.2-11.7c-2-3.4-3-7.3-3-11.3c0-12.4,10.1-22.5,22.6-22.5c6,0,11.7,2.4,16,6.6
|
||||
c4.3,4.3,6.6,9.9,6.6,16c0,12.4-10.1,22.5-22.6,22.5c0,0,0,0,0,0h0c-3.8,0-7.5-0.9-10.8-2.7L1.3,46.8z"/>
|
||||
<path class="st0" d="M1.3,47.4c-0.2,0-0.3-0.1-0.4-0.2c-0.1-0.2-0.2-0.4-0.2-0.6l3.1-11.4c-1.9-3.5-3-7.4-3-11.4
|
||||
c0-12.8,10.4-23.1,23.2-23.1c6.2,0,12,2.4,16.4,6.8c4.4,4.4,6.8,10.2,6.8,16.4C47.2,36.6,36.9,47,24.1,47c-3.8,0-7.5-0.9-10.9-2.7
|
||||
L1.5,47.4C1.4,47.4,1.4,47.4,1.3,47.4z"/>
|
||||
<path class="st1" d="M24.1,1.3c6,0,11.7,2.4,16,6.6c4.3,4.3,6.6,9.9,6.6,16c0,12.4-10.1,22.5-22.6,22.5h0c-3.8,0-7.5-0.9-10.8-2.7
|
||||
l-12,3.1l3.2-11.7c-2-3.4-3-7.3-3-11.3C1.5,11.4,11.7,1.3,24.1,1.3 M24.1,46.4L24.1,46.4L24.1,46.4 M24.1,46.4L24.1,46.4L24.1,46.4
|
||||
M24.1,0.1L24.1,0.1C11,0.1,0.4,10.8,0.3,23.9c0,4,1,7.9,2.9,11.4L0.2,46.5c-0.1,0.4,0,0.8,0.3,1.1C0.7,47.9,1,48,1.3,48
|
||||
c0.1,0,0.2,0,0.3,0l11.5-3c3.4,1.7,7.1,2.7,10.9,2.7c13.1,0,23.7-10.6,23.8-23.7c0-6.3-2.5-12.3-6.9-16.8
|
||||
C36.4,2.6,30.4,0.1,24.1,0.1L24.1,0.1z"/>
|
||||
<path class="st2" d="M37.4,10.6c-3.5-3.5-8.2-5.5-13.3-5.5c-10.3,0-18.8,8.4-18.8,18.7c0,3.5,1,7,2.9,10l0.4,0.7l-1.9,6.9l7.1-1.9
|
||||
l0.7,0.4c2.9,1.7,6.2,2.6,9.5,2.6h0c10.3,0,18.7-8.4,18.7-18.7C42.8,18.9,40.9,14.2,37.4,10.6z"/>
|
||||
<path class="st0" d="M18.5,14.4c-0.4-0.9-0.9-1-1.3-1c-0.3,0-0.7,0-1.1,0c-0.4,0-1,0.1-1.5,0.7c-0.5,0.6-2,1.9-2,4.7s2,5.5,2.3,5.8
|
||||
s3.9,6.2,9.6,8.5c4.8,1.9,5.7,1.5,6.8,1.4c1-0.1,3.3-1.4,3.8-2.7c0.5-1.3,0.5-2.4,0.3-2.7c-0.1-0.2-0.5-0.4-1.1-0.7
|
||||
c-0.6-0.3-3.3-1.6-3.9-1.8s-0.9-0.3-1.3,0.3c-0.4,0.6-1.5,1.8-1.8,2.2c-0.3,0.4-0.7,0.4-1.2,0.1c-0.6-0.3-2.4-0.9-4.5-2.8
|
||||
c-1.7-1.5-2.8-3.3-3.1-3.9c-0.3-0.6,0-0.9,0.2-1.2c0.3-0.3,0.6-0.7,0.8-1c0.3-0.3,0.4-0.6,0.6-0.9c0.2-0.4,0.1-0.7,0-1
|
||||
C20.1,18.3,19,15.5,18.5,14.4z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
3
public/images/icons/vuesax/bold/pinterest.svg
Normal file
3
public/images/icons/vuesax/bold/pinterest.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12C2 16.25 4.67 19.9 8.44 21.34C8.35 20.56 8.28 19.36 8.47 18.5C8.65 17.72 9.63 13.6 9.63 13.6C9.63 13.6 9.33 13 9.33 12.09C9.33 10.66 10.16 9.6 11.2 9.6C12.08 9.6 12.5 10.26 12.5 11.05C12.5 11.93 11.93 13.26 11.63 14.5C11.39 15.53 12.15 16.38 13.16 16.38C14.99 16.38 16.4 14.45 16.4 11.66C16.4 9.19 14.62 7.46 12.08 7.46C9.14 7.46 7.42 9.66 7.42 11.94C7.42 12.82 7.76 13.77 8.19 14.29C8.27 14.39 8.28 14.48 8.26 14.58C8.19 14.86 8.04 15.46 8.01 15.59C7.97 15.77 7.87 15.81 7.68 15.72C6.44 15.14 5.67 13.34 5.67 11.9C5.67 8.75 7.95 5.86 12.24 5.86C15.68 5.86 18.36 8.31 18.36 11.62C18.36 15.07 16.19 17.85 13.16 17.85C12.13 17.85 11.16 17.31 10.83 16.68C10.83 16.68 10.32 18.65 10.2 19.1C9.97 19.97 9.35 21.07 8.94 21.74C9.9 22.04 10.93 22.2 12 22.2C17.52 22.2 22 17.72 22 12.2C22 6.68 17.52 2 12 2Z" fill="#292D32"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 952 B |
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 |
@@ -8,7 +8,7 @@
|
||||
"start_url": "/?source=pwa&lang=fa",
|
||||
"id": "modstagram-pwa",
|
||||
"display": "standalone",
|
||||
"display_override": ["standalone", "minimal-ui", "browser"],
|
||||
"display_override": ["fullscreen", "standalone", "minimal-ui", "browser"],
|
||||
"orientation": "portrait-primary",
|
||||
"prefer_related_applications": false,
|
||||
"icons": [
|
||||
|
||||
50
public/offline.html
Normal file
50
public/offline.html
Normal file
@@ -0,0 +1,50 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fa" dir="rtl">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>مدستاگرام</title>
|
||||
<style>
|
||||
:root { color-scheme: light dark; }
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Tahoma, sans-serif;
|
||||
background: #ffffff;
|
||||
color: #111111;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body { background: #0e0e0e; color: #f5f5f5; }
|
||||
}
|
||||
img { width: 88px; height: 88px; border-radius: 22px; }
|
||||
h1 { margin: 0; font-size: 18px; font-weight: 700; }
|
||||
p { margin: 0; font-size: 14px; opacity: 0.65; max-width: 320px; line-height: 1.7; }
|
||||
button {
|
||||
margin-top: 8px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
padding: 12px 32px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
background: #FF107D;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:active { opacity: 0.85; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<img src="/images/icons/192x192.png" alt="مدستاگرام" />
|
||||
<h1>اتصال برقرار نمیباشد</h1>
|
||||
<p>به نظر میرسد به اینترنت وصل نیستید. اتصال خود را بررسی کنید و دوباره تلاش کنید.</p>
|
||||
<button onclick="location.reload()">تلاش دوباره</button>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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));
|
||||
@@ -43,10 +54,22 @@ self.addEventListener("notificationclick", (event) => {
|
||||
);
|
||||
});
|
||||
|
||||
const CACHE_NAME = "pwa-cache-v11";
|
||||
const CACHE_NAME = "pwa-cache-v12";
|
||||
|
||||
const MAX_CACHED_IMAGES = 200;
|
||||
|
||||
async function trimCache(name, maxEntries) {
|
||||
const cache = await caches.open(name);
|
||||
const keys = await cache.keys();
|
||||
const extra = keys.length - maxEntries;
|
||||
if (extra > 0) {
|
||||
await Promise.all(keys.slice(0, extra).map((key) => cache.delete(key)));
|
||||
}
|
||||
}
|
||||
|
||||
const PRECACHE_URLS = [
|
||||
"/",
|
||||
"/offline.html",
|
||||
"/manifest.webmanifest",
|
||||
"/manifest.json",
|
||||
"/images/icons/192x192.png",
|
||||
@@ -82,7 +105,9 @@ self.addEventListener("fetch", (event) => {
|
||||
const url = new URL(event.request.url);
|
||||
|
||||
if (event.request.mode === "navigate") {
|
||||
event.respondWith(fetch(event.request));
|
||||
event.respondWith(
|
||||
fetch(event.request).catch(() => caches.match("/offline.html"))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -121,7 +146,8 @@ self.addEventListener("fetch", (event) => {
|
||||
const clone = res.clone();
|
||||
caches
|
||||
.open(CACHE_NAME)
|
||||
.then((cache) => cache.put(event.request, clone));
|
||||
.then((cache) => cache.put(event.request, clone))
|
||||
.then(() => trimCache(CACHE_NAME, MAX_CACHED_IMAGES));
|
||||
}
|
||||
return res;
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
import { getApiBaseUrl } from "@/components/main/BaseUrl";
|
||||
import type { PostLinkKind } from "@/lib/postLinkCaption";
|
||||
|
||||
// اپ فعلاً فقط یه خانوادهفونت (IRANSansX) با وزنهای مختلف داره، نه چند تایپفیسِ واقعاً
|
||||
// جدا مثل اینستاگرام — پس «تغییر فونت» همون وزنهاست، تنها گزینهی واقعاً موجود (عین RN)
|
||||
export const STORY_FONT_FAMILIES = [
|
||||
{ value: "light", label: "نازک" },
|
||||
{ value: "regular", label: "معمولی" },
|
||||
{ value: "medium", label: "متوسط" },
|
||||
{ value: "bold", label: "ضخیم" },
|
||||
{ value: "black", label: "خیلی ضخیم" },
|
||||
] as const;
|
||||
export type StoryFontFamily = (typeof STORY_FONT_FAMILIES)[number]["value"];
|
||||
|
||||
export type StoryTextOverlay = {
|
||||
type?: "text";
|
||||
text: string;
|
||||
x: number;
|
||||
y: number;
|
||||
font_size: number;
|
||||
font_family?: StoryFontFamily;
|
||||
color: string;
|
||||
background?: string;
|
||||
rotation?: number;
|
||||
@@ -24,7 +36,90 @@ export type StoryLinkOverlay = {
|
||||
rotation?: number;
|
||||
};
|
||||
|
||||
export type StoryOverlay = StoryTextOverlay | StoryLinkOverlay;
|
||||
export type StoryMentionOverlay = {
|
||||
type: "mention";
|
||||
mention_user_id: string;
|
||||
mention_username: string;
|
||||
x: number;
|
||||
y: number;
|
||||
rotation?: number;
|
||||
font_size: number;
|
||||
};
|
||||
|
||||
export type StoryHashtagOverlay = {
|
||||
type: "hashtag";
|
||||
text: string; // بدون #
|
||||
x: number;
|
||||
y: number;
|
||||
rotation?: number;
|
||||
font_size: number;
|
||||
};
|
||||
|
||||
export type StoryLocationOverlay = {
|
||||
type: "location";
|
||||
text: string; // نام آزاد مکان
|
||||
x: number;
|
||||
y: number;
|
||||
rotation?: number;
|
||||
font_size: number;
|
||||
};
|
||||
|
||||
export type StoryStickerOverlay = {
|
||||
type: "sticker";
|
||||
text: string; // خودِ ایموجی
|
||||
x: number;
|
||||
y: number;
|
||||
rotation?: number;
|
||||
font_size: number;
|
||||
};
|
||||
|
||||
export type StoryOverlay =
|
||||
| StoryTextOverlay
|
||||
| StoryLinkOverlay
|
||||
| StoryMentionOverlay
|
||||
| StoryHashtagOverlay
|
||||
| StoryLocationOverlay
|
||||
| StoryStickerOverlay;
|
||||
|
||||
export const DEFAULT_STICKER_FONT_SIZE = 0.045;
|
||||
|
||||
function clampNum(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
// سقفِ ایمنِ کلی — اگه یه استوریِ قدیمی (مثلاً قبل از رفعِ باگِ ناهمخوانیِ نسبتِ
|
||||
// تصویرِ ادیتور/نمایشگر) مقدارِ font_size خیلی بزرگی ذخیره کرده باشه، رندرِ اون مستقیم
|
||||
// توی ادیتور میتونه بدشکل/بیاندازه بشه — قبل از باز کردنِ ادیتور برای ویرایش، همهی
|
||||
// این مقادیر یهبار سالمسازی میشن (لینک font_size ندارد، دستنخورده میماند)
|
||||
const SAFE_FONT_SIZE_MIN = 0.02;
|
||||
const SAFE_FONT_SIZE_MAX = 0.25;
|
||||
|
||||
export function sanitizeOverlaysForEditing(overlays: StoryOverlay[]): StoryOverlay[] {
|
||||
return overlays.map((o) => {
|
||||
const clamped = {
|
||||
...o,
|
||||
x: clampNum(Number.isFinite(o.x) ? o.x : 0.5, 0, 1),
|
||||
y: clampNum(Number.isFinite(o.y) ? o.y : 0.5, 0, 1),
|
||||
};
|
||||
if (o.type === "link") return clamped;
|
||||
return {
|
||||
...clamped,
|
||||
font_size: clampNum(
|
||||
Number.isFinite(o.font_size) ? o.font_size : DEFAULT_STICKER_FONT_SIZE,
|
||||
SAFE_FONT_SIZE_MIN,
|
||||
SAFE_FONT_SIZE_MAX,
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
export const DEFAULT_EMOJI_FONT_SIZE = 0.09;
|
||||
|
||||
// عین RN — ست کوچیک و پرکاربرد، نه کل کیبورد ایموجی
|
||||
export const STORY_EMOJI_PRESETS = [
|
||||
"❤️", "🔥", "😂", "😍", "😮", "😢", "👏", "🎉",
|
||||
"✨", "💯", "😎", "🙏", "👍", "🥳", "😘", "😭",
|
||||
"🤔", "😅", "💔", "⭐", "🌹", "🎶", "☀️", "🌙",
|
||||
];
|
||||
|
||||
export type StoryItem = {
|
||||
_id: string;
|
||||
|
||||
@@ -22,6 +22,7 @@ import toast from "react-hot-toast";
|
||||
import { useAuthSessionRedirect } from "@/hooks/useAuthSessionRedirect";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import { establishIdentityAtLogin } from "@/lib/e2ee";
|
||||
|
||||
function LoginWithUsername() {
|
||||
const { t, i18n } = useTranslation("common");
|
||||
@@ -66,6 +67,12 @@ function LoginWithUsername() {
|
||||
{ noToast: true }
|
||||
)) as IVerifyOtp;
|
||||
|
||||
// قبل از اینکه password از scope خارج بشه، کلیدِ E2EE رو زیرِ همین رمز
|
||||
// بکآپ/بازیابی کن — best-effort، نباید جلویِ خودِ لاگین رو بگیره
|
||||
if (response.id) {
|
||||
// [E2EE] موقتاً غیرفعال — void establishIdentityAtLogin(response.id, values.password);
|
||||
}
|
||||
|
||||
const loggedIn = await completeLogin(router, response, {
|
||||
redirectTo: redirectPath,
|
||||
});
|
||||
|
||||
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;
|
||||
@@ -1,12 +1,12 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
|
||||
import AuthHead from "@/components/auth/AuthHead";
|
||||
import AuthPageLayout, {
|
||||
AuthFormFooter,
|
||||
AuthPageContent,
|
||||
} from "@/components/auth/AuthPageLayout";
|
||||
import React, { useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useRouter } from "next/navigation";
|
||||
import AuthNextButton from "@/components/auth/AuthNextButton";
|
||||
@@ -14,12 +14,40 @@ 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_BASE_URL } from "@/components/main/BaseUrl";
|
||||
import { IProfileData } from "@/types/types";
|
||||
|
||||
function RegisterCompletePage() {
|
||||
const { t } = useTranslation("common");
|
||||
const router = useRouter();
|
||||
const { request, loading } = useAxios();
|
||||
const [action, setAction] = useState<"app" | "continue" | null>(null);
|
||||
const [user, setUser] = useState<IProfileData | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
request<{ user: IProfileData }>("GET", "/profile")
|
||||
.then((res) => setUser(res?.user ?? null))
|
||||
.catch(() => {});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const name = [user?.first_name, user?.last_name].filter(Boolean).join(" ");
|
||||
|
||||
// مطابق درخواست: عکس پروفایل حالا زودتر، توی خودِ صفحهی 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);
|
||||
@@ -30,9 +58,12 @@ function RegisterCompletePage() {
|
||||
localStorage.setItem("usertype", "user");
|
||||
|
||||
if (next === "continue") {
|
||||
router.push("/verify/avatar");
|
||||
await goToVerifyWizard();
|
||||
} else {
|
||||
router.push(getSafeRedirectPath());
|
||||
// navigation سخت — همون دلیلِ Logout.tsx: router.push ممکنه نسخهی
|
||||
// کششدهی فید رو از زمانی که هنوز مهمان بودیم (قبل از تکمیلِ ثبتنام،
|
||||
// ریدایرکتشده به ورود) دوباره نشون بده، بدون اجرای دوبارهی میدلور
|
||||
window.location.href = getSafeRedirectPath();
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error(
|
||||
@@ -48,32 +79,53 @@ function RegisterCompletePage() {
|
||||
<AuthPageLayout>
|
||||
<div className="flex w-full flex-1 flex-col items-center">
|
||||
<AuthPageContent>
|
||||
<AuthHead title={t("auth.registerCompleteTitle")} />
|
||||
<p className="text-sm text-gray-500 text-center max-w-sm mt-2">
|
||||
<div className="flex flex-col items-center">
|
||||
<Image
|
||||
src={
|
||||
user?.profile_image
|
||||
? IMAGE_BASE_URL + user.profile_image
|
||||
: "/images/fake-avatar.png"
|
||||
}
|
||||
alt=""
|
||||
width={72}
|
||||
height={72}
|
||||
unoptimized
|
||||
className="mb-2 h-[72px] w-[72px] rounded-full object-cover bg-gray-100"
|
||||
/>
|
||||
{name ? (
|
||||
<span className="text-base font-bold text-foreground">
|
||||
{name}
|
||||
</span>
|
||||
) : null}
|
||||
{user?.user_name ? (
|
||||
<span className="text-sm text-gray-500">{user.user_name}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 text-center max-w-sm mt-6">
|
||||
{t("auth.registerCompleteDesc")}
|
||||
</p>
|
||||
</AuthPageContent>
|
||||
|
||||
<AuthFormFooter>
|
||||
<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={loading}
|
||||
loading={loading && action === "continue"}
|
||||
>
|
||||
{t("auth.continueRegistration")}
|
||||
</AuthNextButton>
|
||||
<div className="flex flex-row gap-3 w-full max-w-sm justify-center items-center">
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
onClick={() => finishRegistration("app")}
|
||||
className="!border-[#FF0000] !text-[#FF0000] w-full sm:w-auto"
|
||||
className="!border-[#16a34a] !text-[#16a34a] flex-1"
|
||||
disabled={loading}
|
||||
loading={loading && action === "app"}
|
||||
>
|
||||
{t("auth.enterApp")}
|
||||
</AuthNextButton>
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
onClick={() => finishRegistration("continue")}
|
||||
className="!border-[#FF0000] !text-[#FF0000] flex-1"
|
||||
disabled={loading}
|
||||
loading={loading && action === "continue"}
|
||||
>
|
||||
{t("auth.continueRegistration")}
|
||||
</AuthNextButton>
|
||||
</div>
|
||||
</AuthFormFooter>
|
||||
</div>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -17,6 +17,8 @@ import AuthNextButton from "@/components/auth/AuthNextButton";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import { getStoredUserId } from "@/lib/auth/session";
|
||||
import { establishIdentityAtLogin } from "@/lib/e2ee";
|
||||
|
||||
function PasswordPage() {
|
||||
const { t } = useTranslation("common");
|
||||
@@ -53,6 +55,9 @@ function PasswordPage() {
|
||||
(await request("POST", "/register/password", {
|
||||
new_password: values.password,
|
||||
})) as IVerifyOtp;
|
||||
// اولین رمزِ عبورِ این کاربره — همین الان کلیدِ E2EE رو زیرش بکآپ بگیر
|
||||
const userId = getStoredUserId();
|
||||
// [E2EE] موقتاً غیرفعال — if (userId) void establishIdentityAtLogin(userId, values.password);
|
||||
router.push("/register/fullname");
|
||||
} catch (err: any) {
|
||||
const message =
|
||||
|
||||
@@ -21,8 +21,7 @@ import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
function AvatarPage() {
|
||||
const { t } = useTranslation("common");
|
||||
const router = useRouter();
|
||||
const { request, loading } = useAxios();
|
||||
const [userDetail, setUserDetail] = useState<IProfileData | null>(null);
|
||||
const { request } = useAxios();
|
||||
const [avatar, setAvatar] = useState<string | null>(null);
|
||||
const [crop, setCrop] = useState({ x: 0, y: 0 });
|
||||
const [zoom, setZoom] = useState(1);
|
||||
@@ -35,7 +34,6 @@ function AvatarPage() {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const response = await request<{ user: IProfileData }>("GET", "/profile");
|
||||
setUserDetail(response?.user || null);
|
||||
setAvatar(response?.user?.profile_image || null);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
|
||||
@@ -106,7 +106,7 @@ function Colors() {
|
||||
|
||||
<div className="flex justify-center gap-2 mt-4 w-full max-w-[320px]">
|
||||
<button
|
||||
className={`p-1 rounded-full w-full border text-sm flex items-center justify-center gap-1 ${
|
||||
className={`p-1 rounded-2xl w-full border text-sm flex items-center justify-center gap-1 ${
|
||||
selectedButton === 1
|
||||
? "btn-modern--selected text-white"
|
||||
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
|
||||
@@ -116,7 +116,7 @@ function Colors() {
|
||||
{t("auth.eyeColor")}
|
||||
</button>
|
||||
<button
|
||||
className={`p-1 rounded-full w-full border text-sm flex items-center justify-center gap-1 ${
|
||||
className={`p-1 rounded-2xl w-full border text-sm flex items-center justify-center gap-1 ${
|
||||
selectedButton === 2
|
||||
? "btn-modern--selected text-white"
|
||||
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import AuthNextButton from "@/components/auth/AuthNextButton";
|
||||
import Modal from "@/components/elements/Modal";
|
||||
import Link from "next/link";
|
||||
@@ -19,7 +18,6 @@ function Confirm() {
|
||||
const { t } = useTranslation("common");
|
||||
const user = useUser();
|
||||
const { request } = useAxios();
|
||||
const router = useRouter();
|
||||
const [showModal, setShowModal] = useState<boolean>(false);
|
||||
const [navigating, setNavigating] = useState(false);
|
||||
const [verifiedStatus, setVerifiedStatus] = useState<string | null>(null);
|
||||
|
||||
@@ -98,10 +98,10 @@ function Sizes() {
|
||||
|
||||
<div className="flex gap-2 mt-4 w-full max-w-[320px]">
|
||||
<button
|
||||
className={`p-1 rounded-full w-1/3 border text-sm flex items-center justify-center gap-1 ${
|
||||
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)}
|
||||
>
|
||||
@@ -115,7 +115,7 @@ function Sizes() {
|
||||
{t("auth.height")}
|
||||
</button>
|
||||
<button
|
||||
className={`p-1 rounded-full w-1/3 border text-sm flex items-center justify-center gap-1 ${
|
||||
className={`p-1 rounded-2xl w-1/3 border text-sm flex items-center justify-center gap-1 ${
|
||||
selectedButton === 2
|
||||
? "btn-modern--selected text-white"
|
||||
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
|
||||
@@ -132,7 +132,7 @@ function Sizes() {
|
||||
{t("auth.weight")}
|
||||
</button>
|
||||
<button
|
||||
className={`p-1 rounded-full w-1/3 border text-sm flex items-center justify-center gap-1 ${
|
||||
className={`p-1 rounded-2xl w-1/3 border text-sm flex items-center justify-center gap-1 ${
|
||||
selectedButton === 3
|
||||
? "btn-modern--selected text-white"
|
||||
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
|
||||
@@ -174,7 +174,7 @@ function Sizes() {
|
||||
{sizes.map((sizeOption) => (
|
||||
<button
|
||||
key={sizeOption}
|
||||
className={`p-2 py-1 rounded-full border text-sm full ${
|
||||
className={`p-2 py-1 rounded-2xl border text-sm full ${
|
||||
size === sizeOption
|
||||
? "btn-modern--selected text-white"
|
||||
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
Star,
|
||||
Phone,
|
||||
} from "lucide-react";
|
||||
import { btnPrimary, btnDefault } from "@/lib/ui/buttonStyles";
|
||||
import { btnPrimary } from "@/lib/ui/buttonStyles";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Course } from "@/types/types";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
@@ -26,10 +26,11 @@ import { Comments } from "@/components/academy/CommentsModal";
|
||||
import { buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import VerificationBadge from "@/components/main/VerificationBadge";
|
||||
import ProfileAvatar from "@/components/main/ProfileAvatar";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { usePathname, useRouter, useSearchParams } 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,28 +86,49 @@ 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({
|
||||
courseId,
|
||||
initialIsPurchased = false,
|
||||
}: CourseDetailProps) {
|
||||
const { t } = useTranslation("common");
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const paymentResult = searchParams.get("payment");
|
||||
useEffect(() => {
|
||||
if (!paymentResult) return;
|
||||
if (paymentResult === "success") toast.success(t("shops.paymentSuccessTitle"));
|
||||
else toast.error(t("shops.paymentFailedTitle"));
|
||||
router.replace(window.location.pathname);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [paymentResult]);
|
||||
const [selectedVideo, setSelectedVideo] = useState(MOCK_VIDEOS[0]);
|
||||
const [isPurchased, setIsPurchased] = useState(initialIsPurchased);
|
||||
const [purchasing, setPurchasing] = useState(false);
|
||||
const [showContactInfoModal, setShowContactInfoModal] = useState(false);
|
||||
const [videos] = useState(MOCK_VIDEOS);
|
||||
const [course] = useState(MOCK_COURSE);
|
||||
const [walletBalance, setWalletBalance] = useState(0);
|
||||
const [showWalletPrompt, setShowWalletPrompt] = useState(false);
|
||||
|
||||
const pathname = usePathname();
|
||||
const match = pathname.match(/\/academy\/([^\/]+)/);
|
||||
@@ -118,6 +140,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);
|
||||
@@ -128,9 +157,8 @@ 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 [newScore, setNewScore] = useState<number>(0);
|
||||
const [rating, setRating] = useState<number>(0);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -141,11 +169,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])
|
||||
@@ -161,7 +189,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 در مسیر
|
||||
);
|
||||
@@ -190,7 +218,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 }));
|
||||
@@ -207,7 +235,7 @@ export default function CourseDetail({
|
||||
if (!_id) return;
|
||||
|
||||
try {
|
||||
const response = await request(
|
||||
const response = await request<ApiResponse>(
|
||||
"GET",
|
||||
`/academy/course/${_id}/comments/count`
|
||||
);
|
||||
@@ -226,28 +254,26 @@ 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++;
|
||||
});
|
||||
|
||||
const average = sum / count;
|
||||
const averagesum = sum *1;
|
||||
|
||||
const roundedRating = Math.round(average * 10) / 10
|
||||
setRating(roundedRating);
|
||||
setNewScore(averagesum);
|
||||
|
||||
if (response?.success) {
|
||||
let newComments = response.data.comments;
|
||||
let newComments: any[] = response.data.comments;
|
||||
const pagination = response.data.pagination;
|
||||
|
||||
if (newComments && newComments.length > 0) {
|
||||
@@ -345,7 +371,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}`
|
||||
);
|
||||
@@ -392,46 +418,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
|
||||
@@ -440,8 +427,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);
|
||||
@@ -452,6 +440,12 @@ export default function CourseDetail({
|
||||
return;
|
||||
}
|
||||
|
||||
if (response?.paidFromWallet) {
|
||||
setIsPurchased(true);
|
||||
toast.success(t("wallet.paidFromWalletSuccess"));
|
||||
return;
|
||||
}
|
||||
|
||||
const paymentUrl =
|
||||
response?.paymentUrl ||
|
||||
response?.data?.paymentUrl ||
|
||||
@@ -480,96 +474,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(paymentUrl, "_blank", "noopener,noreferrer");
|
||||
toast.success(t("academy.course.redirectedToPayment"));
|
||||
|
||||
} 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]) => {
|
||||
@@ -594,7 +515,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;
|
||||
@@ -605,9 +526,6 @@ export default function CourseDetail({
|
||||
courseContactInfo?.instagramLink
|
||||
);
|
||||
|
||||
const freeVideosCount = videos.filter((v) => v.is_free).length;
|
||||
const paidVideosCount = videos.filter((v) => !v.is_free).length;
|
||||
|
||||
// کامپوننت نمایش ستارههای کوچک برای کامنتها
|
||||
const renderSmallStars = (rate: number) => {
|
||||
return (
|
||||
@@ -655,6 +573,7 @@ useEffect(() => {
|
||||
key={selectedVideo?._id || ""}
|
||||
className="w-full aspect-video"
|
||||
controls
|
||||
playsInline
|
||||
controlsList="nodownload noplaybackrate"
|
||||
onContextMenu={preventDownload}
|
||||
onKeyDown={preventDownload}
|
||||
@@ -761,8 +680,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"
|
||||
@@ -775,8 +694,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 && (
|
||||
@@ -860,12 +779,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">
|
||||
@@ -878,7 +797,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>
|
||||
)}
|
||||
@@ -1051,6 +970,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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ function FailedProject() {
|
||||
const [project, setProject] = useState<Project>();
|
||||
const [typeList, setTypeList] = useState<IProjectType[] | null>(null);
|
||||
const [price, setPrice] = useState("");
|
||||
const [paying, setPaying] = useState(false);
|
||||
|
||||
const getDisplayTypeLabel = (projectType?: string) => {
|
||||
if (projectType === "normal") {
|
||||
@@ -68,6 +69,9 @@ function FailedProject() {
|
||||
}, [project, typeList]);
|
||||
|
||||
const payHandler = async () => {
|
||||
// قبلاً هیچ گاردی نداشت — دابلکلیک دو درخواستِ initiate-payment-web همزمان میفرستاد
|
||||
if (paying) return;
|
||||
setPaying(true);
|
||||
try {
|
||||
const response = await request<{ authority?: string; type?: string }>(
|
||||
"POST",
|
||||
@@ -82,10 +86,13 @@ function FailedProject() {
|
||||
} else {
|
||||
const authority = response.authority;
|
||||
const paymentUrl = `https://www.zarinpal.com/pg/StartPay/${authority}`;
|
||||
router.push(paymentUrl);
|
||||
// لینک خارجی — router.push رفتارِ تضمینشدهای نداره؛ window.location.href
|
||||
// مطمئنتره (خصوصاً سافاری)
|
||||
window.location.href = paymentUrl;
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.log(error);
|
||||
setPaying(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -106,7 +113,7 @@ function FailedProject() {
|
||||
</div>
|
||||
<div className="px-4 w-full text-sm md:text-md font-semibold mt-10">
|
||||
{project && <MainProjectCard project={project} />}
|
||||
<div className="flex flex-col items-center gap-4 mt-4">
|
||||
<div className="mt-6 flex w-full max-w-sm flex-row items-center justify-center gap-3">
|
||||
<RoundedDiv className="p-2 w-full">
|
||||
{getDisplayTypeLabel(project?.project_type)}:{" "}
|
||||
{Number(price).toLocaleString()} {t("settings.toman")}
|
||||
@@ -120,8 +127,9 @@ function FailedProject() {
|
||||
onClick={() => {
|
||||
payHandler();
|
||||
}}
|
||||
disabled={paying}
|
||||
variant="primary"
|
||||
className="w-32 h-9"
|
||||
className="flex-1 h-9"
|
||||
>
|
||||
{t("academy.payment.retry")}
|
||||
</RoundedButton>
|
||||
|
||||
@@ -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,20 +1,25 @@
|
||||
import Header from "@/components/main/Header";
|
||||
|
||||
import TabNavigation from "@/components/TabNavigation";
|
||||
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
|
||||
import { generateSeoPageMetadata } from "@/lib/i18n/server";
|
||||
|
||||
|
||||
|
||||
export async function generateMetadata() {
|
||||
|
||||
return generateSeoPageMetadata("newProject", {
|
||||
|
||||
index: false,
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
import Header from "@/components/main/Header";
|
||||
import TabNavigation from "@/components/TabNavigation";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import { generateSeoPageMetadata } from "@/lib/i18n/server";
|
||||
|
||||
export async function generateMetadata() {
|
||||
return generateSeoPageMetadata("newProject", {
|
||||
index: false,
|
||||
});
|
||||
}
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="pb-24">
|
||||
<Header />
|
||||
<LocalePageShell>{children}</LocalePageShell>
|
||||
{/* واقعاً توی /new-project هستیم، نه /projects — قبلاً اینجا "/projects" هاردکد
|
||||
شده بود که باعث میشد تبِ پروژهها فکر کنه از قبل توی روتِ خودشه (isExactRoot)
|
||||
و اولین کلیک روی اون تب preventDefault بشه بدون navigate (کاربر مجبور میشد
|
||||
دوبار بزنه). isActive توی TabNavigation.tsx از قبل شرطِ جداگانه برای
|
||||
"/new-project" داره، پس هایلایتِ تب دستنخورده میمونه */}
|
||||
<TabNavigation currentPage="/new-project" />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
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";
|
||||
import GuestAuthPromptModal from "@/components/auth/GuestAuthPromptModal";
|
||||
import { generatePageMetadata } from "@/utils/generatePageMetadata";
|
||||
import { fetchProject } from "@/api/fetchProject";
|
||||
import {
|
||||
@@ -15,13 +16,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 +54,21 @@ 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>
|
||||
<GuestAuthPromptModal />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
15
src/app/(projects)/projects/loading.tsx
Normal file
15
src/app/(projects)/projects/loading.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import IOSSpinner from "@/components/ui/IOSSpinner";
|
||||
import { getServerLanguage } from "@/lib/i18n/server";
|
||||
import { translateCommon } from "@/lib/i18n/translate";
|
||||
|
||||
export default async function ProjectsLoading() {
|
||||
const lang = await getServerLanguage();
|
||||
const label = translateCommon(lang, "common.loading");
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-[50vh] w-full max-w-xl flex-col items-center justify-center gap-3 px-4">
|
||||
<IOSSpinner size={28} color="#ff107d" />
|
||||
<span className="text-sm text-neutral-500">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import useAxios from "@/hooks/useAxios";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import RoundedDiv from "@/components/elements/RoundedDiv";
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import MainProjectCard from "@/components/projects/MainProjectCard";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -18,11 +18,13 @@ function FailedProject() {
|
||||
const { t } = useTranslation("common");
|
||||
const { request } = useAxios();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const projectId = searchParams.get("projectId");
|
||||
|
||||
const [project, setProject] = useState<Project>();
|
||||
const [typeList, setTypeList] = useState<IProjectType[] | null>(null);
|
||||
const [price, setPrice] = useState("");
|
||||
const [paying, setPaying] = useState(false);
|
||||
|
||||
const getDisplayTypeLabel = (projectType?: string) => {
|
||||
if (projectType === "normal" || projectType === "free") {
|
||||
@@ -68,6 +70,9 @@ function FailedProject() {
|
||||
}, [project, typeList]);
|
||||
|
||||
const payHandler = async () => {
|
||||
// قبلاً هیچ گاردی نداشت — دابلکلیک دو درخواستِ initiate-payment-web همزمان میفرستاد
|
||||
if (paying) return;
|
||||
setPaying(true);
|
||||
try {
|
||||
const response = await request<{
|
||||
authority?: string;
|
||||
@@ -88,12 +93,14 @@ function FailedProject() {
|
||||
window.location.href = `https://www.zarinpal.com/pg/StartPay/${response.authority}`;
|
||||
} else {
|
||||
toast.error(response?.message || t("projects.payment.initFailed"));
|
||||
setPaying(false);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const axiosErr = error as { response?: { data?: { message?: string } } };
|
||||
toast.error(
|
||||
axiosErr?.response?.data?.message || t("projects.payment.initFailed")
|
||||
);
|
||||
setPaying(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -114,7 +121,7 @@ function FailedProject() {
|
||||
</div>
|
||||
<div className="mt-10 w-full px-4 text-sm font-semibold md:text-base">
|
||||
{project && <MainProjectCard project={project} />}
|
||||
<div className="mt-4 flex flex-col items-center gap-4">
|
||||
<div className="mt-6 flex w-full max-w-sm flex-row items-center justify-center gap-3">
|
||||
<RoundedDiv className="w-full p-2">
|
||||
{getDisplayTypeLabel(project?.project_type)}:{" "}
|
||||
{Number(price).toLocaleString()} {t("settings.toman")}
|
||||
@@ -126,11 +133,18 @@ function FailedProject() {
|
||||
</RoundedDiv>
|
||||
<RoundedButton
|
||||
onClick={payHandler}
|
||||
disabled={paying}
|
||||
variant="primary"
|
||||
className="h-9 w-32"
|
||||
className="h-9 flex-1"
|
||||
>
|
||||
{t("projects.payment.retry")}
|
||||
</RoundedButton>
|
||||
<RoundedButton
|
||||
onClick={() => router.push("/settings/workroom?filter=unpaid")}
|
||||
className="h-9 flex-1"
|
||||
>
|
||||
{t("common.back")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
@@ -8,7 +8,6 @@ import RoundedDiv from "@/components/elements/RoundedDiv";
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import MainProjectCard from "@/components/projects/MainProjectCard";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
@@ -124,11 +123,13 @@ function SuccessProject() {
|
||||
</RoundedDiv>
|
||||
)}
|
||||
<p className="my-5">{t("projects.payment.reviewNotice")}</p>
|
||||
<Link href={"/settings/workroom"}>
|
||||
<RoundedButton variant="primary" className="h-9 w-32">
|
||||
{t("projects.payment.workroom")}
|
||||
</RoundedButton>
|
||||
</Link>
|
||||
<RoundedButton
|
||||
variant="primary"
|
||||
className="h-9 w-32"
|
||||
onClick={() => (window.location.href = projectId ? `/projects/${projectId}` : "/settings/workroom")}
|
||||
>
|
||||
{t("settings.confirm")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useRef, useState } from "react";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -12,15 +12,21 @@ import Container from "@/components/elements/Container";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import SpecialistCarousel from "@/components/projects/SpecialistCarousel";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import ProfileAvatar from "@/components/main/ProfileAvatar";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { SuggestedFollowUser } from "@/components/posts/FollowingSuggestionsCarousel";
|
||||
import { Post } from "@/types/types";
|
||||
import { getPostMedia } from "@/lib/explore/postMedia";
|
||||
import { validateMinMaxPairs } from "@/lib/rangeValidation";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
const SWIPE_THRESHOLD = 72;
|
||||
// عین PAGE_LIMIT/PREFETCH_THRESHOLD توی SpecialistsScreen.tsx اندروید — هر صفحه
|
||||
// PAGE_LIMIT «پست» میگیره (نه کاربر یکتا)، و وقتی به PREFETCH_THRESHOLD کارت مونده
|
||||
// به آخرِ لیستِ لودشده برسیم، صفحهی بعد از پیش لود میشه
|
||||
const PAGE_LIMIT = 30;
|
||||
const PREFETCH_THRESHOLD = 5;
|
||||
|
||||
const slideVariants = {
|
||||
enter: (direction: number) => ({
|
||||
@@ -58,20 +64,22 @@ function SpecialistCard({
|
||||
viewProfileLabel: string;
|
||||
}) {
|
||||
const displayName = getDisplayName(user, userFallback);
|
||||
const previewPath = user.previewPost?.files?.[0]?.path;
|
||||
const previewUrl = previewPath ? buildStorageUrl(previewPath) : null;
|
||||
const profileUrl = user.profile_image
|
||||
? buildStorageUrl(user.profile_image)
|
||||
// بدون فالبک به post_images/post_video، پستِ آخرِ بعضی کاربرها که فیلد files
|
||||
// خالی داشتن اصلاً پیشنمایشی نداشت (کارت کاملاً خالی میموند) — عین getPostMedia
|
||||
// که خودِ فید اصلی برای تشخیص عکس/ویدئو استفاده میکنه
|
||||
const media = user.previewPost
|
||||
? getPostMedia(user.previewPost as Post)
|
||||
: null;
|
||||
const isMain = variant === "main";
|
||||
const profileHref = user.user_name ? `/users/${user.user_name}` : "#";
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full overflow-hidden rounded-3xl bg-neutral-900 shadow-2xl">
|
||||
{previewUrl ? (
|
||||
user.previewPost?.type === "video" ? (
|
||||
{media ? (
|
||||
media.kind === "video" ? (
|
||||
<video
|
||||
src={previewUrl}
|
||||
src={media.src}
|
||||
poster={media.poster}
|
||||
className="h-full w-full object-cover"
|
||||
muted
|
||||
playsInline
|
||||
@@ -81,12 +89,11 @@ function SpecialistCard({
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
src={previewUrl}
|
||||
src={media.src}
|
||||
alt=""
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes={isMain ? "280px" : "120px"}
|
||||
unoptimized
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
@@ -120,26 +127,13 @@ function SpecialistCard({
|
||||
isMain ? "px-4 pb-5" : "px-2 pb-3"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"relative mb-2 overflow-hidden rounded-full ring-2 ring-white/90",
|
||||
isMain ? "h-16 w-16" : "h-9 w-9"
|
||||
)}
|
||||
>
|
||||
{profileUrl ? (
|
||||
<Image
|
||||
src={profileUrl}
|
||||
alt=""
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes={isMain ? "64px" : "36px"}
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center bg-neutral-700 text-white">
|
||||
{displayName.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-2 overflow-hidden rounded-full ring-2 ring-white/90">
|
||||
<ProfileAvatar
|
||||
src={user.profile_image}
|
||||
userId={user._id}
|
||||
sizePx={isMain ? 64 : 36}
|
||||
rounded="full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isMain ? (
|
||||
@@ -178,8 +172,14 @@ export default function SpecialistsPage() {
|
||||
const [showFilter, setShowFilter] = useState(true);
|
||||
const [users, setUsers] = useState<SuggestedFollowUser[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [[index, direction], setSlide] = useState<[number, number]>([0, 0]);
|
||||
const applyingFilterRef = useRef(false);
|
||||
// صفحهبندی بینهایته و تا totalPages ادامه پیدا میکنه — اینها فقط اندازهی هر
|
||||
// درخواست و شمارهی صفحهی بعدی رو نگه میدارن، نه سقف کلی روی نتایج
|
||||
const pageRef = useRef(1);
|
||||
const totalPagesRef = useRef(1);
|
||||
const knownUserIdsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const [stateId, setStateId] = useState("");
|
||||
const [cityId, setCityId] = useState("");
|
||||
@@ -211,10 +211,12 @@ export default function SpecialistsPage() {
|
||||
setEyeColor("");
|
||||
};
|
||||
|
||||
const fetchUsers = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const qs = new URLSearchParams({ page: "1", limit: "20" });
|
||||
const buildFilterParams = useCallback(
|
||||
(pageNum: number) => {
|
||||
const qs = new URLSearchParams({
|
||||
page: String(pageNum),
|
||||
limit: String(PAGE_LIMIT),
|
||||
});
|
||||
if (selectedExpertise) qs.set("expertise", selectedExpertise);
|
||||
if (stateId) qs.set("province", stateId);
|
||||
if (cityId) qs.set("city", cityId);
|
||||
@@ -228,21 +230,52 @@ export default function SpecialistsPage() {
|
||||
if (sizeMax) qs.set("sizeMax", sizeMax);
|
||||
if (hairColor) qs.set("hair_color", hairColor);
|
||||
if (eyeColor) qs.set("eye_color", eyeColor);
|
||||
return qs;
|
||||
},
|
||||
[
|
||||
selectedExpertise,
|
||||
stateId,
|
||||
cityId,
|
||||
userLevel,
|
||||
rateFilter,
|
||||
heightMin,
|
||||
heightMax,
|
||||
weightMin,
|
||||
weightMax,
|
||||
sizeMin,
|
||||
sizeMax,
|
||||
hairColor,
|
||||
eyeColor,
|
||||
],
|
||||
);
|
||||
|
||||
const fetchUsers = useCallback(async () => {
|
||||
setLoading(true);
|
||||
pageRef.current = 1;
|
||||
totalPagesRef.current = 1;
|
||||
knownUserIdsRef.current = new Set();
|
||||
try {
|
||||
const qs = buildFilterParams(1);
|
||||
const response = await request<{
|
||||
posts?: Post[];
|
||||
totalPages?: number;
|
||||
feedMeta?: { suggestedUsers?: SuggestedFollowUser[] };
|
||||
}>("GET", `/users/web?${qs.toString()}`, null, { noToast: true });
|
||||
|
||||
const suggested = response?.feedMeta?.suggestedUsers;
|
||||
if (Array.isArray(suggested) && suggested.length) {
|
||||
// این لیست از قبل کیوریتشدهست، نه بر پایهی صفحهبندیِ پستها — صفحهبندی
|
||||
// نمیخواد (عین RN که اصلاً این مسیر رو نداره و فقط posts رو صفحهبندی میکنه)
|
||||
totalPagesRef.current = 1;
|
||||
setUsers(suggested);
|
||||
} else {
|
||||
totalPagesRef.current = response?.totalPages ?? 1;
|
||||
const posts = response?.posts || [];
|
||||
const map = new Map<string, SuggestedFollowUser>();
|
||||
posts.forEach((post) => {
|
||||
const id = String(post.userId || post.user_id || "");
|
||||
if (!id || map.has(id)) return;
|
||||
knownUserIdsRef.current.add(id);
|
||||
map.set(id, {
|
||||
_id: id,
|
||||
user_name: post.user_name,
|
||||
@@ -253,6 +286,8 @@ export default function SpecialistsPage() {
|
||||
previewPost: {
|
||||
_id: post._id,
|
||||
type: post.type,
|
||||
post_images: post.post_images,
|
||||
post_video: post.post_video,
|
||||
files: post.files,
|
||||
caption: post.caption,
|
||||
},
|
||||
@@ -267,21 +302,71 @@ export default function SpecialistsPage() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [request, buildFilterParams]);
|
||||
|
||||
// چون هر صفحه «پست» میگیره نه «کاربر یکتا»، ممکنه یک صفحه هیچ کاربر تازهای اضافه
|
||||
// نکنه — با هر بار موفقیت یه fetch، خودِ افکتِ پایین دوباره چک میکنه و لازم شد صفحهی
|
||||
// بعد رو هم میگیره، عین SpecialistsScreen.tsx اندروید
|
||||
const fetchNextPage = useCallback(async () => {
|
||||
if (loadingMore || pageRef.current >= totalPagesRef.current) return;
|
||||
setLoadingMore(true);
|
||||
try {
|
||||
const nextPage = pageRef.current + 1;
|
||||
const qs = buildFilterParams(nextPage);
|
||||
const response = await request<{ posts?: Post[]; totalPages?: number }>(
|
||||
"GET",
|
||||
`/users/web?${qs.toString()}`,
|
||||
null,
|
||||
{ noToast: true },
|
||||
);
|
||||
pageRef.current = nextPage;
|
||||
totalPagesRef.current = response?.totalPages ?? totalPagesRef.current;
|
||||
const posts = response?.posts || [];
|
||||
const additions: SuggestedFollowUser[] = [];
|
||||
posts.forEach((post) => {
|
||||
const id = String(post.userId || post.user_id || "");
|
||||
if (!id || knownUserIdsRef.current.has(id)) return;
|
||||
knownUserIdsRef.current.add(id);
|
||||
additions.push({
|
||||
_id: id,
|
||||
user_name: post.user_name,
|
||||
first_name: post.first_name,
|
||||
last_name: post.last_name,
|
||||
expertise: post.expertise,
|
||||
profile_image: post.profile_image,
|
||||
previewPost: {
|
||||
_id: post._id,
|
||||
type: post.type,
|
||||
post_images: post.post_images,
|
||||
post_video: post.post_video,
|
||||
files: post.files,
|
||||
caption: post.caption,
|
||||
},
|
||||
is_following: post.is_following,
|
||||
});
|
||||
});
|
||||
if (additions.length) {
|
||||
setUsers((prev) => [...prev, ...additions]);
|
||||
}
|
||||
} catch {
|
||||
// خطای صفحهی بعدی نباید نتایجِ از قبل لودشده رو پاک کنه
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [request, buildFilterParams, loadingMore]);
|
||||
|
||||
// با نزدیک شدن به انتهای لیستِ لودشده (حین سوایپ)، صفحهی بعد رو پیشلود کن
|
||||
useEffect(() => {
|
||||
if (phase !== "results") return;
|
||||
if (pageRef.current >= totalPagesRef.current) return;
|
||||
if (users.length - index <= PREFETCH_THRESHOLD) {
|
||||
void fetchNextPage();
|
||||
}
|
||||
}, [
|
||||
request,
|
||||
selectedExpertise,
|
||||
stateId,
|
||||
cityId,
|
||||
userLevel,
|
||||
rateFilter,
|
||||
heightMin,
|
||||
heightMax,
|
||||
weightMin,
|
||||
weightMax,
|
||||
sizeMin,
|
||||
sizeMax,
|
||||
hairColor,
|
||||
eyeColor,
|
||||
phase,
|
||||
index,
|
||||
users.length,
|
||||
fetchNextPage,
|
||||
]);
|
||||
|
||||
const handleFilterChange = () => {
|
||||
|
||||
@@ -1,13 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Container from "@/components/elements/Container";
|
||||
import RoundedDiv from "@/components/elements/RoundedDiv";
|
||||
import EditPageHeader from "@/components/settings/EditPageHeader";
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { isAndroidDevice } from "@/lib/pwa/install";
|
||||
import { buildStorageUrl, getApiBaseUrl } from "@/components/main/BaseUrl";
|
||||
|
||||
// عین PwaInstallPrompt.tsx: مسیر مستقیمِ فایل APK که بکاند بدون روتِ جدید سرو میکنه
|
||||
const ANDROID_APK_URL = buildStorageUrl("/downloads/modstagram.apk");
|
||||
|
||||
function AboutUs() {
|
||||
const { t } = useTranslation("common");
|
||||
// فقط اندروید — طبق خواستهی صریح کاربر توی iOS این دکمه نمایش داده نشه
|
||||
const isAndroid = isAndroidDevice();
|
||||
// قبلاً ANDROID_APP_VERSION توی pwaConfig.ts هاردکد بود و باید با هر ریلیزِ اندروید
|
||||
// دستی آپدیت میشد (که یادمون میرفت و اینجا نسخهی قدیمی میموند). حالا همون
|
||||
// GET /version که پنل ادمین (Version.jsx) مقداردهی میکنه رو مستقیم میخونه — دقیقاً
|
||||
// همون منبعِ واحدی که AppUpdateGate.tsx توی اندروید هم برای چک نسخه ازش استفاده میکنه
|
||||
const [androidVersion, setAndroidVersion] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
fetch(`${getApiBaseUrl()}/version`)
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((data: { version?: string } | null) => {
|
||||
if (data?.version) setAndroidVersion(data.version);
|
||||
})
|
||||
.catch(() => {
|
||||
/* اگه چک نسخه شکست بخوره، همون خطِ نسخه نمایش داده نمیشه — چیزِ دیگهای رو نمیشکنه */
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Container>
|
||||
@@ -23,12 +46,26 @@ function AboutUs() {
|
||||
<span>02167586</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full flex justify-center items-center">
|
||||
<Link href={"/settings/tickets/new"} className="flex flex-col mt-6 gap-1">
|
||||
<RoundedDiv className="w-28 py-1">
|
||||
<span>{t("aboutPage.sendTicket")}</span>
|
||||
</RoundedDiv>
|
||||
</Link>
|
||||
<div className="w-full flex flex-col items-center gap-4">
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<Link href={"/settings/tickets/new"} className="flex flex-col mt-6 gap-1">
|
||||
<RoundedDiv className="w-28 py-1">
|
||||
<span>{t("aboutPage.sendTicket")}</span>
|
||||
</RoundedDiv>
|
||||
</Link>
|
||||
{isAndroid ? (
|
||||
<a href={ANDROID_APK_URL} className="flex flex-col mt-6 gap-1">
|
||||
<RoundedDiv className="w-28 py-1">
|
||||
<span>{t("aboutPage.downloadApp")}</span>
|
||||
</RoundedDiv>
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
{isAndroid && androidVersion ? (
|
||||
<span className="text-xs text-neutral-500">
|
||||
{t("aboutPage.appVersion", { version: androidVersion })}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
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 GuestAuthPromptModal from "@/components/auth/GuestAuthPromptModal";
|
||||
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 +81,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 +121,24 @@ 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} />
|
||||
<GuestAuthPromptModal />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default BillboardPage;
|
||||
|
||||
@@ -26,6 +26,7 @@ function BillboardPayment({ params }: IBillboardProps) {
|
||||
const [advertising, setAdvertising] = useState<IAdvertising>();
|
||||
const [typeList, setTypeList] = useState<IAdvertisingType[] | null>(null);
|
||||
const [price, setPrice] = useState("");
|
||||
const [paying, setPaying] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAd = async () => {
|
||||
@@ -50,6 +51,9 @@ function BillboardPayment({ params }: IBillboardProps) {
|
||||
}, [id, request]);
|
||||
|
||||
const payHandler = async () => {
|
||||
// قبلاً هیچ گاردی نداشت — دابلکلیک دو درخواستِ initiate-payment-web همزمان میفرستاد
|
||||
if (paying) return;
|
||||
setPaying(true);
|
||||
try {
|
||||
const response = await request<{ authority?: string }>(
|
||||
"POST",
|
||||
@@ -66,10 +70,13 @@ function BillboardPayment({ params }: IBillboardProps) {
|
||||
} else {
|
||||
const authority = response.authority;
|
||||
const paymentUrl = `https://www.zarinpal.com/pg/StartPay/${authority}`;
|
||||
router.push(paymentUrl);
|
||||
// لینک خارجی — router.push رفتارِ تضمینشدهای نداره؛ window.location.href
|
||||
// مطمئنتره (خصوصاً سافاری)
|
||||
window.location.href = paymentUrl;
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.log(error);
|
||||
setPaying(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -101,6 +108,7 @@ function BillboardPayment({ params }: IBillboardProps) {
|
||||
<RoundedButton
|
||||
variant="primary"
|
||||
onClick={payHandler}
|
||||
disabled={paying}
|
||||
className="h-9 w-32"
|
||||
>
|
||||
{t("billboards.payment.title")}
|
||||
|
||||
@@ -27,6 +27,7 @@ function FailedBillboard() {
|
||||
const price = searchParams.get("price") || "0";
|
||||
|
||||
const [advertising, setAdvertising] = useState<IAdvertising>();
|
||||
const [paying, setPaying] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAd = async () => {
|
||||
@@ -39,7 +40,11 @@ function FailedBillboard() {
|
||||
fetchAd();
|
||||
}, [projectId, request]);
|
||||
|
||||
// قبلاً هیچکدومِ این دو تابع گاردِ submitting نداشتن — دابلکلیکِ «تلاشِ دوباره»
|
||||
// دو درخواستِ پرداختِ همزمان میفرستاد
|
||||
const payHandler = async () => {
|
||||
if (paying) return;
|
||||
setPaying(true);
|
||||
try {
|
||||
const response = await request<{ authority?: string }>(
|
||||
"POST",
|
||||
@@ -56,14 +61,19 @@ function FailedBillboard() {
|
||||
} else {
|
||||
const authority = response.authority;
|
||||
const paymentUrl = `https://www.zarinpal.com/pg/StartPay/${authority}`;
|
||||
router.push(paymentUrl);
|
||||
// لینک خارجی — router.push رفتارِ تضمینشدهای نداره؛ window.location.href
|
||||
// مطمئنتره (خصوصاً سافاری)
|
||||
window.location.href = paymentUrl;
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.log(error);
|
||||
setPaying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const republishHandler = async () => {
|
||||
if (paying) return;
|
||||
setPaying(true);
|
||||
try {
|
||||
const response = await request<{ authority?: string }>(
|
||||
"POST",
|
||||
@@ -80,10 +90,13 @@ function FailedBillboard() {
|
||||
} else {
|
||||
const authority = response.authority;
|
||||
const paymentUrl = `https://www.zarinpal.com/pg/StartPay/${authority}`;
|
||||
router.push(paymentUrl);
|
||||
// لینک خارجی — router.push رفتارِ تضمینشدهای نداره؛ window.location.href
|
||||
// مطمئنتره (خصوصاً سافاری)
|
||||
window.location.href = paymentUrl;
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.log(error);
|
||||
setPaying(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -104,7 +117,7 @@ function FailedBillboard() {
|
||||
</div>
|
||||
<div className="px-4 w-full text-sm md:text-md font-semibold mt-10">
|
||||
{advertising && <MainBillboardCard billboard={advertising} />}
|
||||
<div className="flex flex-col items-center gap-4 mt-4">
|
||||
<div className="mt-6 flex w-full max-w-sm flex-row items-center justify-center gap-3">
|
||||
<RoundedDiv className="p-2 w-full">
|
||||
{getBillboardDisplayTypeLabel(t, advertising?.type)}:{" "}
|
||||
{Number(price).toLocaleString()} {t("settings.toman")}
|
||||
@@ -122,11 +135,18 @@ function FailedBillboard() {
|
||||
payHandler();
|
||||
}
|
||||
}}
|
||||
disabled={paying}
|
||||
variant="primary"
|
||||
className="w-32 h-9"
|
||||
className="flex-1 h-9"
|
||||
>
|
||||
{t("billboards.payment.retry")}
|
||||
</RoundedButton>
|
||||
<RoundedButton
|
||||
onClick={() => router.push("/settings/my-billboards?filter=unpaid")}
|
||||
className="flex-1 h-9"
|
||||
>
|
||||
{t("common.back")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
@@ -12,7 +12,6 @@ import PageTitle from "@/components/settings/PageTitle";
|
||||
import { getBillboardDisplayTypeLabel } from "@/lib/billboards/displayTypeLabel";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
function SuccessBillboard() {
|
||||
@@ -59,11 +58,13 @@ function SuccessBillboard() {
|
||||
{Number(price).toLocaleString()} {t("settings.toman")}
|
||||
</RoundedDiv>
|
||||
<p className="my-5">{t("billboards.payment.registeredMessage")}</p>
|
||||
<Link href={"/settings/my-billboards"}>
|
||||
<RoundedButton variant="primary" className="w-32 h-9">
|
||||
{t("billboards.payment.myBillboards")}
|
||||
</RoundedButton>
|
||||
</Link>
|
||||
<RoundedButton
|
||||
variant="primary"
|
||||
className="w-32 h-9"
|
||||
onClick={() => (window.location.href = projectId ? `/settings/my-billboards/${projectId}/billboard` : "/settings/my-billboards")}
|
||||
>
|
||||
{t("settings.confirm")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
buildPostSeoTitle,
|
||||
} from "@/lib/postSlug";
|
||||
import ExploreReelClient from "./ExploreReelClient";
|
||||
import GuestAuthPromptModal from "@/components/auth/GuestAuthPromptModal";
|
||||
import { getServerLanguage } from "@/lib/i18n/server";
|
||||
import { translateCommon } from "@/lib/i18n/translate";
|
||||
|
||||
@@ -44,5 +45,10 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
|
||||
export default async function ExploreReelPage({ params }: Props) {
|
||||
const { id } = await params;
|
||||
return <ExploreReelClient id={id} />;
|
||||
return (
|
||||
<>
|
||||
<ExploreReelClient id={id} />
|
||||
<GuestAuthPromptModal />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,9 +9,9 @@ import useAxios from "@/hooks/useAxios";
|
||||
import { filterChipClass } from "@/lib/ui/buttonStyles";
|
||||
import { User } from "@/types/types";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
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;
|
||||
@@ -25,7 +25,6 @@ function GiftPage({ params }: GiftPageProps) {
|
||||
const { t } = useTranslation("common");
|
||||
const { userId } = React.use(params);
|
||||
const { request } = useAxios();
|
||||
const router = useRouter();
|
||||
|
||||
const [userDetail, setUserDetail] = useState<User>();
|
||||
const [selectedAmount, setSelectedAmount] = useState<number | null>(
|
||||
@@ -35,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 () => {
|
||||
@@ -45,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(() => {
|
||||
@@ -58,19 +63,30 @@ 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) {
|
||||
router.push(response.paymentUrl);
|
||||
window.location.href = response.paymentUrl;
|
||||
} else if (response?.authority) {
|
||||
router.push(`https://www.zarinpal.com/pg/StartPay/${response.authority}`);
|
||||
window.location.href = `https://www.zarinpal.com/pg/StartPay/${response.authority}`;
|
||||
} else {
|
||||
toast.error(t("giftPage.paymentInfoError"));
|
||||
}
|
||||
@@ -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,
|
||||
}: {
|
||||
|
||||
@@ -4,13 +4,14 @@ import Container from "@/components/elements/Container";
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import PageTitle from "@/components/settings/PageTitle";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
function GiftPaymentFailed() {
|
||||
const { t } = useTranslation("common");
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const receiverId = searchParams.get("receiverId");
|
||||
|
||||
@@ -28,13 +29,18 @@ function GiftPaymentFailed() {
|
||||
/>
|
||||
<span>{t("giftPage.paymentFailed.message")}</span>
|
||||
</div>
|
||||
{receiverId ? (
|
||||
<Link className="mt-8 flex flex-col items-center" href={`/gift/${receiverId}`}>
|
||||
<RoundedButton variant="primary" className="h-9 w-32">
|
||||
{t("giftPage.paymentFailed.retry")}
|
||||
</RoundedButton>
|
||||
</Link>
|
||||
) : null}
|
||||
<div className="mt-6 flex w-full max-w-sm flex-row items-center justify-center gap-3">
|
||||
{receiverId ? (
|
||||
<Link href={`/gift/${receiverId}`} className="flex-1">
|
||||
<RoundedButton variant="primary" className="h-9 w-full">
|
||||
{t("giftPage.paymentFailed.retry")}
|
||||
</RoundedButton>
|
||||
</Link>
|
||||
) : null}
|
||||
<RoundedButton variant="default" className="h-9 flex-1" onClick={() => router.push(receiverId ? `/gift/${receiverId}` : "/")}>
|
||||
{t("giftPage.paymentFailed.back")}
|
||||
</RoundedButton>
|
||||
</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>
|
||||
|
||||
@@ -182,6 +182,14 @@
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
/* بدون این، Chrome روی گوشیهایی که «Force dark برای محتوای وب» روشنه (پیشفرض بعضی
|
||||
بیلدهای شیائومی/سامسونگ) فکر میکنه این سایت از تم تیره پشتیبانی نمیکنه و خودش
|
||||
رنگهای input/text رو الگوریتمی معکوس میکنه — دقیقاً همون چیزی که باعث میشد نام/
|
||||
نامخانوادگی توی تم تیره ناخوانا بشه. اعلامِ صریحِ color-scheme این رفتار رو خاموش میکنه */
|
||||
color-scheme: light;
|
||||
}
|
||||
html.dark {
|
||||
color-scheme: dark;
|
||||
}
|
||||
body {
|
||||
direction: rtl;
|
||||
@@ -618,6 +626,7 @@ select {
|
||||
.header-offline {
|
||||
background: linear-gradient(180deg, rgba(220, 38, 38, 0.92) 0%, rgba(185, 28, 28, 0.88) 100%) !important;
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
.header-syncing .brand-logo {
|
||||
opacity: 0;
|
||||
@@ -631,6 +640,7 @@ select {
|
||||
.tab-active-glass {
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.55);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
@@ -639,11 +649,17 @@ select {
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
/* env(safe-area-inset-bottom) روی اندروید (برخلافِ iOS) تقریباً همیشه 0px
|
||||
برمیگرده، حتی وقتی نوار جستوجوی سیستم/نشانگرِ ژستِ اندروید همون چند پیکسلِ
|
||||
پایینِ صفحه رو اشغال کرده — یعنی قبلاً تکیهی تنها به env() باعث میشد دکمهی
|
||||
ارسال/ویس روی خیلی از گوشیهای اندروید دقیقاً روی لبهی پایینیِ صفحه بشینه و
|
||||
زیرِ نوارِ سیستم بره. max() یه حداقلِ ثابت (که این نوارها رو پوشش میده) رو
|
||||
تضمین میکنه، مستقل از اینکه env() چی برگردونه */
|
||||
.chat-input-area {
|
||||
padding-bottom: calc(0.75rem + env(safe-area-inset-bottom, 0px));
|
||||
padding-bottom: max(1.5rem, calc(0.75rem + env(safe-area-inset-bottom, 0px)));
|
||||
}
|
||||
.chat-input-area--thread {
|
||||
padding-bottom: calc(1rem + env(safe-area-inset-bottom, 0px));
|
||||
padding-bottom: max(1.5rem, calc(1rem + env(safe-area-inset-bottom, 0px)));
|
||||
}
|
||||
|
||||
.ig-dm-send-btn {
|
||||
@@ -710,6 +726,10 @@ select {
|
||||
.story-ring-live {
|
||||
animation: story-ring-spin 3s linear infinite;
|
||||
}
|
||||
/* حلقهی «در حالِ آپلودِ استوری» — چرخشِ سریعتر، عینِ لودینگِ ثبتِ استوری اینستاگرام */
|
||||
.story-ring-uploading {
|
||||
animation: story-ring-spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
/* Message enter — handled by framer-motion in ChatMessageCard */
|
||||
|
||||
@@ -747,22 +767,30 @@ select {
|
||||
--btn-accent-text: #ffffff;
|
||||
}
|
||||
|
||||
.btn-modern {
|
||||
border: 1px solid var(--btn-border-color);
|
||||
transition: all 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
.btn-modern:hover:not(:disabled) {
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.btn-modern:active:not(:disabled) {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
.dark .btn-modern {
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
.dark .btn-modern:hover:not(:disabled) {
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.38);
|
||||
/* داخلِ @layer components (برخلافِ btn-modern--primary/--selected زیر که عمداً
|
||||
بیرونِ لایهست) تا رنگِ بوردرِ اینجا (خاکستریِ پیشفرض) رویِ کلاسهای Tailwind
|
||||
مثلِ border-[#FC8EAC] که کامپوننتها (مثلِ btnPrimary) پاس میدن سوار نشه —
|
||||
قبلاً چون این رول بیرونِ هر @layer بود، همیشه برنده میشد و باعث میشد بوردرِ
|
||||
دکمههای انتخابشده (مثلاً btnPrimary توی تنظیماتِ فروشگاه) هیچوقت صورتی نشه،
|
||||
با اینکه رنگِ متنش درست صورتی میشد (چون فقط border ست شده بود، نه color) */
|
||||
@layer components {
|
||||
.btn-modern {
|
||||
border: 1px solid var(--btn-border-color);
|
||||
transition: all 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
.btn-modern:hover:not(:disabled) {
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.btn-modern:active:not(:disabled) {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
.dark .btn-modern {
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
.dark .btn-modern:hover:not(:disabled) {
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.38);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-modern--primary {
|
||||
@@ -774,33 +802,33 @@ select {
|
||||
background: var(--btn-accent-hover);
|
||||
}
|
||||
|
||||
/* عینِ btnPrimary (دکمه «اعمال فیلتر»): تینتِ کمرنگِ رنگِ اصلی + بوردر/متنِ رنگِ
|
||||
اصلی، نه پرشدنِ توپر + متنِ سفید — چون این کلاس خارج از @layer تعریف شده،
|
||||
رویِ هر text-white که کنارش پاس داده بشه اولویت داره (لایههای CSS Tailwind v4) */
|
||||
.btn-modern--selected {
|
||||
background: var(--btn-accent);
|
||||
color: var(--btn-accent-text);
|
||||
background: rgba(252, 142, 172, 0.1);
|
||||
color: var(--btn-accent);
|
||||
border-color: var(--btn-accent);
|
||||
}
|
||||
.btn-modern--selected:hover:not(:disabled) {
|
||||
background: var(--btn-accent-hover);
|
||||
border-color: var(--btn-accent-hover);
|
||||
background: rgba(252, 142, 172, 0.16);
|
||||
}
|
||||
|
||||
.glass-modal-overlay {
|
||||
background: rgba(8, 8, 10, 0.52);
|
||||
backdrop-filter: blur(20px) saturate(120%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(120%);
|
||||
background: transparent;
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
.dark .glass-modal-overlay {
|
||||
background: rgba(0, 0, 0, 0.62);
|
||||
backdrop-filter: blur(22px) saturate(110%);
|
||||
-webkit-backdrop-filter: blur(22px) saturate(110%);
|
||||
background: transparent;
|
||||
}
|
||||
.glass-modal-overlay--dark {
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
backdrop-filter: blur(24px) saturate(110%);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(110%);
|
||||
background: transparent;
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
.dark .glass-modal-overlay--dark {
|
||||
background: rgba(0, 0, 0, 0.82);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.glass-modal-panel {
|
||||
|
||||
@@ -1,122 +1,127 @@
|
||||
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 { getLanguageDefinition } from "@/lib/i18n/registry";
|
||||
import { getServerLanguage } from "@/lib/i18n/server";
|
||||
import SiteJsonLd from "@/components/seo/SiteJsonLd";
|
||||
import Script from 'next/script';
|
||||
import "./globals.css";
|
||||
import Layout from "@/components/Layout";
|
||||
import RegisterSW from "@/components/RegisterSW";
|
||||
import PushNotificationListener from "@/components/PushNotificationListener";
|
||||
import ClientErrorBoundary from "@/components/ClientErrorBoundary";
|
||||
|
||||
const iranSansFont = localFont({
|
||||
src: "../../public/fonts/IRANSansX-Regular.woff",
|
||||
display: "swap",
|
||||
fallback: ["Tahoma", "Arial", "sans-serif"],
|
||||
});
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const lang = await getServerLanguage();
|
||||
const site = getSiteSeoMeta(lang);
|
||||
const home = getSeoPage("home", lang);
|
||||
|
||||
return {
|
||||
title: {
|
||||
default: home.title || site.name,
|
||||
template: `%s | ${site.titleSuffix}`,
|
||||
},
|
||||
description: home.description,
|
||||
metadataBase: new URL("https://modstagram.com"),
|
||||
keywords: site.keywords,
|
||||
authors: [{ name: "Modstagram", url: "https://modstagram.com" }],
|
||||
creator: "Modstagram",
|
||||
manifest: "/manifest.webmanifest",
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: "/favicon.png", type: "image/png", sizes: "32x32" },
|
||||
{ url: "/images/icons/192x192.png", type: "image/png", sizes: "192x192" },
|
||||
],
|
||||
shortcut: ["/favicon.png"],
|
||||
apple: [
|
||||
{ url: "/images/icons/apple-touch-icon.png", sizes: "180x180" },
|
||||
{ url: "/images/icons/192x192.png", sizes: "192x192" },
|
||||
],
|
||||
},
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: "default",
|
||||
title: site.name,
|
||||
},
|
||||
openGraph: {
|
||||
siteName: site.name,
|
||||
locale: lang === "fa" ? "fa_IR" : "en_US",
|
||||
type: "website",
|
||||
images: defaultSEOConfig.openGraph?.images || [],
|
||||
},
|
||||
robots: "index, follow",
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
creator: "@modstagram",
|
||||
site: "@modstagram",
|
||||
images: defaultSEOConfig.openGraph?.images?.map((img) => img.url) || [],
|
||||
},
|
||||
verification: {
|
||||
google: "yVjB5yKPchPUtxl33GWVyMvjH6wCEInqEvaH7ZsXXMo",
|
||||
},
|
||||
other: {
|
||||
"theme-color": "#FF107D",
|
||||
"mobile-web-app-capable": "yes",
|
||||
"msapplication-TileColor": "#FF107D",
|
||||
samandehi: "522190795",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
viewportFit: "cover",
|
||||
themeColor: [
|
||||
{ media: "(prefers-color-scheme: light)", color: "#ffffff" },
|
||||
{ media: "(prefers-color-scheme: dark)", color: "#0a0a0a" },
|
||||
],
|
||||
};
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const lang = await getServerLanguage();
|
||||
const def = getLanguageDefinition(lang);
|
||||
|
||||
return (
|
||||
<html lang={def.htmlLang} dir={def.direction} suppressHydrationWarning>
|
||||
<body className={`${iranSansFont.className} antialiased`}>
|
||||
<SiteJsonLd />
|
||||
<RegisterSW />
|
||||
<PushNotificationListener />
|
||||
<ClientErrorBoundary>
|
||||
<Layout initialLanguage={lang}>{children}</Layout>
|
||||
</ClientErrorBoundary>
|
||||
{/* ====================== گوگل آنالیتیکس ====================== */}
|
||||
<Script
|
||||
src="https://www.googletagmanager.com/gtag/js?id=G-GT0QQX6F97"
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
<Script id="google-analytics" strategy="afterInteractive">
|
||||
{`
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
gtag('config', 'G-GT0QQX6F97');
|
||||
`}
|
||||
</Script>
|
||||
{/* ======================================================== */} </body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import localFont from "next/font/local";
|
||||
import { defaultSEOConfig } from "@/config/seoConfig";
|
||||
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";
|
||||
import { toPwaLang } from "@/lib/i18n/pwaLang";
|
||||
import { PWA_VERSION } from "@/lib/pwaConfig";
|
||||
import Script from 'next/script';
|
||||
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({
|
||||
src: "../../public/fonts/IRANSansX-Regular.woff",
|
||||
display: "swap",
|
||||
fallback: ["Tahoma", "Arial", "sans-serif"],
|
||||
});
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const lang = await getServerLanguage();
|
||||
const site = getSiteSeoMeta(lang);
|
||||
const home = getSeoPage("home", lang);
|
||||
|
||||
return {
|
||||
title: {
|
||||
default: home.title || site.name,
|
||||
template: `%s | ${site.titleSuffix}`,
|
||||
},
|
||||
description: home.description,
|
||||
metadataBase: new URL(SITE_URL),
|
||||
keywords: site.keywords,
|
||||
authors: [{ name: "Modstagram", url: SITE_URL }],
|
||||
creator: "Modstagram",
|
||||
alternates: buildHreflangAlternates(home.path || "/", lang),
|
||||
manifest: `/manifest.webmanifest?lang=${toPwaLang(lang)}&v=${PWA_VERSION}`,
|
||||
applicationName: site.name,
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: "/favicon.png", type: "image/png", sizes: "32x32" },
|
||||
{ url: "/images/icons/192x192.png", type: "image/png", sizes: "192x192" },
|
||||
],
|
||||
shortcut: ["/favicon.png"],
|
||||
apple: [
|
||||
{ url: "/images/icons/apple-touch-icon.png", sizes: "180x180" },
|
||||
{ url: "/images/icons/192x192.png", sizes: "192x192" },
|
||||
],
|
||||
},
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: "default",
|
||||
title: site.name,
|
||||
},
|
||||
openGraph: {
|
||||
siteName: site.name,
|
||||
locale: lang === "fa" ? "fa_IR" : "en_US",
|
||||
type: "website",
|
||||
images: defaultSEOConfig.openGraph?.images || [],
|
||||
},
|
||||
robots: "index, follow",
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
creator: "@modstagram",
|
||||
site: "@modstagram",
|
||||
images: defaultSEOConfig.openGraph?.images?.map((img) => img.url) || [],
|
||||
},
|
||||
verification: {
|
||||
google: "yVjB5yKPchPUtxl33GWVyMvjH6wCEInqEvaH7ZsXXMo",
|
||||
},
|
||||
other: {
|
||||
"msapplication-TileColor": "#FF107D",
|
||||
samandehi: "522190795",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
viewportFit: "cover",
|
||||
themeColor: [
|
||||
{ media: "(prefers-color-scheme: light)", color: "#ffffff" },
|
||||
{ media: "(prefers-color-scheme: dark)", color: "#0a0a0a" },
|
||||
],
|
||||
};
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const lang = await getServerLanguage();
|
||||
const def = getLanguageDefinition(lang);
|
||||
|
||||
return (
|
||||
<html lang={def.htmlLang} dir={def.direction} suppressHydrationWarning>
|
||||
<body className={`${iranSansFont.className} antialiased`}>
|
||||
<SiteJsonLd />
|
||||
<RegisterSW />
|
||||
<PushNotificationListener />
|
||||
<VpnWarningGate />
|
||||
<ClientErrorBoundary>
|
||||
<Layout initialLanguage={lang}>{children}</Layout>
|
||||
</ClientErrorBoundary>
|
||||
{/* ====================== گوگل آنالیتیکس ====================== */}
|
||||
<Script
|
||||
src="https://www.googletagmanager.com/gtag/js?id=G-GT0QQX6F97"
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
<Script id="google-analytics" strategy="afterInteractive">
|
||||
{`
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
gtag('config', 'G-GT0QQX6F97');
|
||||
`}
|
||||
</Script>
|
||||
{/* ======================================================== */} </body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { optimizeImageToWebP, optimizeVideoToMp4, isImageAspectRatioAllowed } from "@/lib/media";
|
||||
import { createStoryBase64, StoryOverlay } from "@/api/fetchStories";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { setPendingStoryUpload } from "@/lib/stories/pendingStoryUpload";
|
||||
import StoryEditor from "@/components/stories/StoryEditor";
|
||||
import StoryCameraCapture from "@/components/stories/StoryCameraCapture";
|
||||
import TagUsersPicker, { TaggedUser } from "@/components/posts/TagUsersPicker";
|
||||
import PostLinkPicker from "@/components/posts/PostLinkPicker";
|
||||
import {
|
||||
@@ -61,18 +64,20 @@ export default function NewPostPage() {
|
||||
const { t, i18n } = useTranslation("common");
|
||||
const captionLang = i18n.language === "en" ? "en" : "fa";
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const searchParams = useSearchParams();
|
||||
const { request, loading } = useAxios();
|
||||
const user = useUser();
|
||||
|
||||
const editPostId = searchParams.get("edit") || "";
|
||||
const isEditMode = Boolean(editPostId);
|
||||
const initialMode = (searchParams.get("type") as CreateMode) || "image";
|
||||
const initialMode = (searchParams.get("type") as CreateMode) || "video";
|
||||
const [mode, setMode] = useState<CreateMode>(
|
||||
["image", "video", "story"].includes(initialMode) ? initialMode : "image"
|
||||
["image", "video", "story"].includes(initialMode) ? initialMode : "video"
|
||||
);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [preview, setPreview] = useState<string>("");
|
||||
const [cameraOpen, setCameraOpen] = useState(false);
|
||||
const [gallery, setGallery] = useState<PostGalleryItem[]>([]);
|
||||
const [description, setDescription] = useState("");
|
||||
const [taggedUsers, setTaggedUsers] = useState<TaggedUser[]>([]);
|
||||
@@ -249,6 +254,16 @@ export default function NewPostPage() {
|
||||
if (mode === "video") setVideoCover(null);
|
||||
};
|
||||
|
||||
// نتیجهی دوربین (فیلترِ رنگونور روی مرورگر با CSS filter اعمال شده، از قبل روی
|
||||
// canvas پخته شده) دقیقاً مثل یه فایلِ انتخابشده از گالری با همون state هاست میشه
|
||||
const onCameraCapture = (captured: File) => {
|
||||
if (preview?.startsWith("blob:")) URL.revokeObjectURL(preview);
|
||||
setFile(captured);
|
||||
setPreview(URL.createObjectURL(captured));
|
||||
setStoryOverlays([]);
|
||||
setCameraOpen(false);
|
||||
};
|
||||
|
||||
const returnPath = useMemo(() => {
|
||||
const params = new URLSearchParams();
|
||||
if (mode !== "image") params.set("type", mode);
|
||||
@@ -291,39 +306,49 @@ export default function NewPostPage() {
|
||||
toast.error(t("posts.storyMediaRequired"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
toast.loading(t("posts.publishingStory"), { id: "story-upload" });
|
||||
let optimized: File;
|
||||
if (file.type.startsWith("video/")) {
|
||||
// اگر تبدیل ویدیو (ffmpeg) در دسترس نبود، فایل اصلی ارسال میشود
|
||||
try {
|
||||
optimized = await optimizeVideoToMp4(file);
|
||||
} catch {
|
||||
optimized = file;
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
optimized = await optimizeImageToWebP(file);
|
||||
} catch {
|
||||
optimized = file;
|
||||
|
||||
// عین اینستاگرام: بلافاصله به فید برمیگردیم و آپلود پسزمینه ادامه پیدا میکنه —
|
||||
// StoriesBar با خوندنِ همین پرچمِ pending، حلقهی چرخانِ «در حال آپلود» رو نشون
|
||||
// میده تا وقتی createStoryBase64 واقعاً تموم بشه، بدون اینکه کاربر منتظر بمونه
|
||||
const pendingFile = file;
|
||||
const pendingOverlays = storyOverlays;
|
||||
setPendingStoryUpload(queryClient, true);
|
||||
router.push("/");
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
let optimized: File;
|
||||
if (pendingFile.type.startsWith("video/")) {
|
||||
// اگر تبدیل ویدیو (ffmpeg) در دسترس نبود، فایل اصلی ارسال میشود
|
||||
try {
|
||||
optimized = await optimizeVideoToMp4(pendingFile);
|
||||
} catch {
|
||||
optimized = pendingFile;
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
optimized = await optimizeImageToWebP(pendingFile);
|
||||
} catch {
|
||||
optimized = pendingFile;
|
||||
}
|
||||
}
|
||||
await createStoryBase64(
|
||||
{
|
||||
name: optimized.name,
|
||||
type: optimized.type,
|
||||
data: await fileToBase64(optimized),
|
||||
},
|
||||
token,
|
||||
pendingOverlays
|
||||
);
|
||||
toast.success(t("posts.storyPublished"));
|
||||
void queryClient.invalidateQueries({ queryKey: ["stories-feed"] });
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t("posts.storyPublishError"));
|
||||
} finally {
|
||||
setPendingStoryUpload(queryClient, false);
|
||||
}
|
||||
await createStoryBase64(
|
||||
{
|
||||
name: optimized.name,
|
||||
type: optimized.type,
|
||||
data: await fileToBase64(optimized),
|
||||
},
|
||||
token,
|
||||
storyOverlays
|
||||
);
|
||||
toast.success(t("posts.storyPublished"), { id: "story-upload" });
|
||||
router.push("/");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t("posts.storyPublishError"), {
|
||||
id: "story-upload",
|
||||
});
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -562,7 +587,7 @@ export default function NewPostPage() {
|
||||
{/* Mode tabs */}
|
||||
{!isEditMode ? (
|
||||
<div className="flex border-b border-neutral-200 dark:border-neutral-800">
|
||||
{(["image", "video", "story"] as CreateMode[]).map((m) => (
|
||||
{(["video", "image", "story"] as CreateMode[]).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
@@ -650,6 +675,16 @@ export default function NewPostPage() {
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
{!preview && isStory ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCameraOpen(true)}
|
||||
className="mb-6 flex items-center gap-2 rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 dark:border-neutral-700 dark:text-neutral-200"
|
||||
>
|
||||
<BoldIcon name="camera" size={18} className="dark:invert" />
|
||||
{t("stories.camera")}
|
||||
</button>
|
||||
) : null}
|
||||
{preview && (
|
||||
<label className="absolute bottom-4 rounded-lg bg-black/60 px-4 py-2 text-xs text-white">
|
||||
{t("posts.changeFile")}
|
||||
@@ -711,6 +746,7 @@ export default function NewPostPage() {
|
||||
onShopsChange={setSelectedShops}
|
||||
onShopProductsChange={setSelectedShopProducts}
|
||||
onBookingChange={setSelectedBooking}
|
||||
taggedUserIds={taggedUsers.map((u) => u._id)}
|
||||
/>
|
||||
|
||||
<div className="mt-4">
|
||||
@@ -743,6 +779,13 @@ export default function NewPostPage() {
|
||||
onClose={() => setExpertiseModalOpen(false)}
|
||||
returnPath={returnPath}
|
||||
/>
|
||||
|
||||
{cameraOpen && (
|
||||
<StoryCameraCapture
|
||||
onCapture={onCameraCapture}
|
||||
onClose={() => setCameraOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</LocalePageShell>
|
||||
);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,279 +1,343 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import RoundedDiv from "@/components/elements/RoundedDiv";
|
||||
import { selectionCardClass } from "@/lib/ui/buttonStyles";
|
||||
import { BASE_URL } from "@/components/main/BaseUrl";
|
||||
import UserInfo from "@/components/main/UserInfo";
|
||||
import ScoreRateStats from "@/components/main/ScoreRateStats";
|
||||
import PageTitle from "@/components/settings/PageTitle";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { IOfferType, User } from "@/types/types";
|
||||
import axios from "axios";
|
||||
import { useFormik } from "formik";
|
||||
import { useRouter } from "next/navigation";
|
||||
import React, { useState, useEffect, useMemo } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import Cookies from "js-cookie";
|
||||
import * as Yup from "yup";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useUser } from "@/hooks/useUser";
|
||||
type OfferTypeRow = IOfferType & {
|
||||
disabled?: boolean;
|
||||
unavailableReason?: string | null;
|
||||
};
|
||||
|
||||
interface ITicketChatProps {
|
||||
params: Promise<{ id: string; title: string }>;
|
||||
}
|
||||
|
||||
const FALLBACK_TYPES: OfferTypeRow[] = [
|
||||
{ _id: "normal", name: "normal", price: 50000 },
|
||||
{ _id: "free", name: "free", price: 0 },
|
||||
];
|
||||
|
||||
function sortOfferTypes(list: OfferTypeRow[]): OfferTypeRow[] {
|
||||
const order = ["normal", "special", "highlight", "free"];
|
||||
return [...list].sort((a, b) => {
|
||||
const ai = order.indexOf(a.name);
|
||||
const bi = order.indexOf(b.name);
|
||||
return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi);
|
||||
});
|
||||
}
|
||||
|
||||
function TicketChat({ params }: ITicketChatProps) {
|
||||
const { t } = useTranslation("common");
|
||||
const resolvedParams = React.use(params);
|
||||
const { request } = useAxios();
|
||||
const { id } = resolvedParams;
|
||||
const router = useRouter();
|
||||
const me = useUser();
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
selectedType: Yup.string().required(t("offerPage.typeRequired")),
|
||||
});
|
||||
|
||||
const [userDetail, setUserDetail] = useState<User>();
|
||||
const [selectedType, setSelectedType] = useState("normal");
|
||||
const [typeList, setTypeList] = useState<OfferTypeRow[] | null>(null);
|
||||
const [hasUsedFreeOffer, setHasUsedFreeOffer] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUser = async () => {
|
||||
const response = await request<{ user: User }>(
|
||||
"GET",
|
||||
`/users/get?user_id=${id}`
|
||||
);
|
||||
setUserDetail(response?.user);
|
||||
};
|
||||
void fetchUser();
|
||||
}, [id, request]);
|
||||
|
||||
useEffect(() => {
|
||||
const monthly = Number(me?.monthly_free_offer ?? 1);
|
||||
setHasUsedFreeOffer(monthly <= 0);
|
||||
}, [me]);
|
||||
|
||||
const fetchOfferTypes = async () => {
|
||||
try {
|
||||
const response = await request<{
|
||||
offerTypes: OfferTypeRow[];
|
||||
monthly_free_offer?: number;
|
||||
}>("GET", "/offers/types");
|
||||
const list = sortOfferTypes(
|
||||
response?.offerTypes?.length ? response.offerTypes : FALLBACK_TYPES
|
||||
);
|
||||
setTypeList(list);
|
||||
if (typeof response?.monthly_free_offer === "number") {
|
||||
setHasUsedFreeOffer(response.monthly_free_offer <= 0);
|
||||
}
|
||||
// همیشه پیشفرض روی پولی (normal)
|
||||
const paid =
|
||||
list.find((item) => item.name === "normal" && !item.disabled) ||
|
||||
list.find((item) => item.name !== "free" && !item.disabled);
|
||||
setSelectedType(paid?.name || "normal");
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
setTypeList(FALLBACK_TYPES);
|
||||
setSelectedType("normal");
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void fetchOfferTypes();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
selectedType: "normal",
|
||||
},
|
||||
validationSchema,
|
||||
enableReinitialize: true,
|
||||
onSubmit: async (values, { setSubmitting }) => {
|
||||
const chosen = typeList?.find((t) => t.name === values.selectedType);
|
||||
if (
|
||||
chosen?.disabled ||
|
||||
(values.selectedType === "free" && hasUsedFreeOffer)
|
||||
) {
|
||||
toast.error(
|
||||
chosen?.unavailableReason || t("offerPage.freeAlreadyUsed")
|
||||
);
|
||||
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(data.paymentUrl, "_blank", "noopener,noreferrer");
|
||||
} else if (data.authority) {
|
||||
window.open(
|
||||
`https://www.zarinpal.com/pg/StartPay/${data.authority}`,
|
||||
"_blank",
|
||||
"noopener,noreferrer"
|
||||
);
|
||||
} 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")
|
||||
);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
void formik.setFieldValue("selectedType", selectedType);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedType]);
|
||||
|
||||
const displayTypes = useMemo(
|
||||
() => sortOfferTypes(typeList || FALLBACK_TYPES),
|
||||
[typeList]
|
||||
);
|
||||
|
||||
const typeLabel = (name: string) => {
|
||||
if (name === "normal") return t("offerPage.types.normal");
|
||||
if (name === "free") return t("offerPage.types.free");
|
||||
if (name === "special") return t("offerPage.types.special");
|
||||
return t("offerPage.types.highlight");
|
||||
};
|
||||
|
||||
const userHeader = userDetail ? (
|
||||
<div className="flex w-full items-end justify-between">
|
||||
<UserInfo
|
||||
first_name={userDetail?.first_name}
|
||||
is_verified={userDetail?.verify_badge}
|
||||
userId={userDetail?._id}
|
||||
profile_image={userDetail?.profile_image}
|
||||
user_level={userDetail?.user_level}
|
||||
last_name={userDetail?.last_name}
|
||||
user_name={userDetail?.user_name}
|
||||
noLink
|
||||
trailingContent={
|
||||
<ScoreRateStats score={userDetail?.user_score} rate={userDetail?.rate} />
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="mx-3 mb-10">
|
||||
<PageTitle>{t("offerPage.paymentTitle")}</PageTitle>
|
||||
|
||||
<form
|
||||
className="flex w-full flex-col items-center gap-2 text-sm"
|
||||
onSubmit={formik.handleSubmit}
|
||||
>
|
||||
{displayTypes.map((item) => {
|
||||
const isFree = item.name === "free";
|
||||
const isDisabled =
|
||||
Boolean(item.disabled) || (isFree && hasUsedFreeOffer);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item?._id || item.name}
|
||||
className="mt-8 flex w-full max-w-md cursor-pointer flex-col items-center gap-4"
|
||||
onClick={() => {
|
||||
if (isDisabled) return;
|
||||
setSelectedType(item.name);
|
||||
void formik.setFieldValue("selectedType", item.name);
|
||||
}}
|
||||
>
|
||||
{userHeader}
|
||||
<RoundedDiv
|
||||
className={`${selectionCardClass(selectedType === item?.name)} ${
|
||||
isDisabled ? "cursor-not-allowed opacity-50" : ""
|
||||
}`}
|
||||
>
|
||||
{typeLabel(item.name)}
|
||||
{Number(item.price) !== 0
|
||||
? ": " +
|
||||
Number(item.price).toLocaleString() +
|
||||
t("offerPage.currencySuffix")
|
||||
: ""}
|
||||
</RoundedDiv>
|
||||
{isDisabled && item.unavailableReason ? (
|
||||
<p className="text-center text-[11px] text-red-500">
|
||||
{item.unavailableReason}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<RoundedButton
|
||||
type="submit"
|
||||
variant="primary"
|
||||
className="mt-4 px-8 py-2"
|
||||
disabled={formik.isSubmitting}
|
||||
>
|
||||
{t("offerPage.submitRequest")}
|
||||
</RoundedButton>
|
||||
</form>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default TicketChat;
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import { BASE_URL } from "@/components/main/BaseUrl";
|
||||
import ProfileAvatar from "@/components/main/ProfileAvatar";
|
||||
import VerificationBadge from "@/components/main/VerificationBadge";
|
||||
import UserInfoClient from "@/components/main/UserInfoClient";
|
||||
import ScoreRateStats from "@/components/main/ScoreRateStats";
|
||||
import { formatFullName } from "@/lib/formatFullName";
|
||||
import PageTitle from "@/components/settings/PageTitle";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { IOfferType, User } from "@/types/types";
|
||||
import axios from "axios";
|
||||
import { useFormik } from "formik";
|
||||
import { useRouter } from "next/navigation";
|
||||
import React, { useState, useEffect, useMemo } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
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";
|
||||
import CompleteSocialSignupModal from "@/components/auth/CompleteSocialSignupModal";
|
||||
type OfferTypeRow = IOfferType & {
|
||||
disabled?: boolean;
|
||||
unavailableReason?: string | null;
|
||||
};
|
||||
|
||||
interface ITicketChatProps {
|
||||
params: Promise<{ id: string; title: string }>;
|
||||
}
|
||||
|
||||
const FALLBACK_TYPES: OfferTypeRow[] = [
|
||||
{ _id: "normal", name: "normal", price: 50000 },
|
||||
{ _id: "free", name: "free", price: 0 },
|
||||
];
|
||||
|
||||
function sortOfferTypes(list: OfferTypeRow[]): OfferTypeRow[] {
|
||||
const order = ["normal", "special", "highlight", "free"];
|
||||
return [...list].sort((a, b) => {
|
||||
const ai = order.indexOf(a.name);
|
||||
const bi = order.indexOf(b.name);
|
||||
return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi);
|
||||
});
|
||||
}
|
||||
|
||||
function TicketChat({ params }: ITicketChatProps) {
|
||||
const { t } = useTranslation("common");
|
||||
const resolvedParams = React.use(params);
|
||||
const { request } = useAxios();
|
||||
const { id } = resolvedParams;
|
||||
const router = useRouter();
|
||||
const me = useUser();
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
selectedType: Yup.string().required(t("offerPage.typeRequired")),
|
||||
});
|
||||
|
||||
const [userDetail, setUserDetail] = useState<User>();
|
||||
const [selectedType, setSelectedType] = useState("normal");
|
||||
const [typeList, setTypeList] = useState<OfferTypeRow[] | null>(null);
|
||||
const [walletBalance, setWalletBalance] = useState(0);
|
||||
const [showWalletPrompt, setShowWalletPrompt] = useState(false);
|
||||
const [socialSignupOpen, setSocialSignupOpen] = useState(false);
|
||||
const pendingPaymentRetryRef = React.useRef<(() => void) | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUser = async () => {
|
||||
const response = await request<{ user: User }>(
|
||||
"GET",
|
||||
`/users/get?user_id=${id}`
|
||||
);
|
||||
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]);
|
||||
|
||||
|
||||
const fetchOfferTypes = async () => {
|
||||
try {
|
||||
const response = await request<{
|
||||
offerTypes: OfferTypeRow[];
|
||||
}>("GET", `/offers/types?userId=${id}`);
|
||||
const list = sortOfferTypes(
|
||||
response?.offerTypes?.length ? response.offerTypes : FALLBACK_TYPES
|
||||
);
|
||||
setTypeList(list);
|
||||
// همیشه پیشفرض روی پولی (normal)
|
||||
const paid =
|
||||
list.find((item) => item.name === "normal" && !item.disabled) ||
|
||||
list.find((item) => item.name !== "free" && !item.disabled);
|
||||
setSelectedType(paid?.name || "normal");
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
setTypeList(FALLBACK_TYPES);
|
||||
setSelectedType("normal");
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void fetchOfferTypes();
|
||||
// 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"));
|
||||
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) {
|
||||
if (err?.response?.data?.type === "needs_username_password") {
|
||||
pendingPaymentRetryRef.current = () => {
|
||||
void startPayment(offerType, useWallet);
|
||||
};
|
||||
setSocialSignupOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
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",
|
||||
},
|
||||
validationSchema,
|
||||
enableReinitialize: true,
|
||||
onSubmit: async (values, { setSubmitting }) => {
|
||||
const chosen = typeList?.find((t) => t.name === values.selectedType);
|
||||
if (chosen?.disabled) {
|
||||
toast.error(
|
||||
chosen?.unavailableReason || t("offerPage.freeAlreadyUsed")
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (values.selectedType !== "free" && walletBalance > 0) {
|
||||
setShowWalletPrompt(true);
|
||||
return;
|
||||
}
|
||||
await startPayment(values.selectedType, false);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
void formik.setFieldValue("selectedType", selectedType);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedType]);
|
||||
|
||||
const displayTypes = useMemo(
|
||||
() => sortOfferTypes(typeList || FALLBACK_TYPES),
|
||||
[typeList]
|
||||
);
|
||||
|
||||
const typeLabel = (name: string) => {
|
||||
if (name === "normal") return t("offerPage.types.normal");
|
||||
if (name === "free") return t("offerPage.types.free");
|
||||
if (name === "special") return t("offerPage.types.special");
|
||||
return t("offerPage.types.highlight");
|
||||
};
|
||||
|
||||
// چیدمان این صفحه با UserInfo مشترک (استفادهشده توی نتایج جستجو/هدیه/...) فرق
|
||||
// داره — عین اندروید (OfferScreen.tsx): ستون نام+یوزرنیم کنار آواتار، ستون
|
||||
// سطح+ریت/امتیاز روبروش؛ بهجای تغییر کامپوننت مشترک، چیدمان مخصوص همین صفحهست
|
||||
const userHeader = userDetail ? (
|
||||
<div className="flex w-full items-center gap-3">
|
||||
<ProfileAvatar
|
||||
src={userDetail?.profile_image}
|
||||
alt={userDetail?.user_name || ""}
|
||||
size="md"
|
||||
rounded="2xl"
|
||||
userId={userDetail?._id}
|
||||
verifyBadge={userDetail?.verify_badge}
|
||||
/>
|
||||
<div className="flex flex-1 items-start justify-between gap-2 text-xs max-sm:text-[10px] md:text-sm font-semibold">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="font-semibold">
|
||||
{formatFullName(userDetail?.first_name, userDetail?.last_name)}
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{userDetail?.user_name}
|
||||
<VerificationBadge verifyBadge={userDetail?.verify_badge} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<UserInfoClient user_level={userDetail?.user_level} />
|
||||
<ScoreRateStats score={userDetail?.user_score} rate={userDetail?.rate} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="mx-3 mb-10">
|
||||
<PageTitle>{t("offerPage.paymentTitle")}</PageTitle>
|
||||
|
||||
<form
|
||||
className="flex w-full flex-col items-center gap-2 text-sm"
|
||||
onSubmit={formik.handleSubmit}
|
||||
>
|
||||
{displayTypes.map((item) => {
|
||||
const isDisabled = Boolean(item.disabled);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item?._id || item.name}
|
||||
className="mt-4 flex w-full max-w-md cursor-pointer flex-col items-center gap-4"
|
||||
onClick={() => {
|
||||
if (isDisabled) return;
|
||||
setSelectedType(item.name);
|
||||
void formik.setFieldValue("selectedType", item.name);
|
||||
}}
|
||||
>
|
||||
{userHeader ? <div className="w-full">{userHeader}</div> : null}
|
||||
<RoundedButton
|
||||
type="button"
|
||||
variant={selectedType === item?.name ? "primary" : "default"}
|
||||
disabled={isDisabled}
|
||||
className="w-full h-10"
|
||||
>
|
||||
{typeLabel(item.name)}
|
||||
{Number(item.price) !== 0
|
||||
? ": " +
|
||||
Number(item.price).toLocaleString() +
|
||||
t("offerPage.currencySuffix")
|
||||
: ""}
|
||||
</RoundedButton>
|
||||
{isDisabled && item.unavailableReason ? (
|
||||
<p className="text-center text-[11px] text-red-500">
|
||||
{item.unavailableReason}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="pointer-events-none fixed bottom-28 left-0 right-0 z-[54] flex justify-center px-4">
|
||||
<RoundedButton
|
||||
type="submit"
|
||||
variant="primary"
|
||||
className="pointer-events-auto h-10 w-[160px]"
|
||||
disabled={formik.isSubmitting}
|
||||
>
|
||||
{t("offerPage.submitRequest")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<WalletUsagePrompt
|
||||
isOpen={showWalletPrompt}
|
||||
amount={
|
||||
Number(
|
||||
displayTypes.find((item) => item.name === selectedType)?.price
|
||||
) || 0
|
||||
}
|
||||
walletBalance={walletBalance}
|
||||
onAnswer={(useWallet) => {
|
||||
setShowWalletPrompt(false);
|
||||
void startPayment(selectedType, useWallet);
|
||||
}}
|
||||
/>
|
||||
|
||||
<CompleteSocialSignupModal
|
||||
open={socialSignupOpen}
|
||||
hasUsername={Boolean(me?.user_name)}
|
||||
onClose={() => {
|
||||
setSocialSignupOpen(false);
|
||||
pendingPaymentRetryRef.current = null;
|
||||
}}
|
||||
onSuccess={() => {
|
||||
setSocialSignupOpen(false);
|
||||
pendingPaymentRetryRef.current?.();
|
||||
pendingPaymentRetryRef.current = null;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default TicketChat;
|
||||
|
||||
@@ -5,13 +5,14 @@ import React from "react";
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import PageTitle from "@/components/settings/PageTitle";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
function FailedBillboard() {
|
||||
const { t } = useTranslation("common");
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const userId = searchParams.get("userId");
|
||||
|
||||
@@ -29,14 +30,20 @@ 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")}
|
||||
<div className="mt-6 flex w-full max-w-sm flex-row items-center justify-center gap-3">
|
||||
<Link href={`/offer/${userId}`} className="flex-1">
|
||||
<RoundedButton variant="primary" className="h-9 w-full">
|
||||
{t("offerPage.paymentFailed.retry")}
|
||||
</RoundedButton>
|
||||
</Link>
|
||||
<RoundedButton
|
||||
variant="default"
|
||||
className="h-9 flex-1"
|
||||
onClick={() => router.push(`/offer/${userId}`)}
|
||||
>
|
||||
{t("offerPage.paymentFailed.back")}
|
||||
</RoundedButton>
|
||||
</Link>
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
|
||||
@@ -7,7 +7,7 @@ import React, { useEffect, useState } from "react";
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import PageTitle from "@/components/settings/PageTitle";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -15,6 +15,7 @@ import { useTranslation } from "react-i18next";
|
||||
function SuccessBillboard() {
|
||||
const { t } = useTranslation("common");
|
||||
const { request } = useAxios();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const userId = searchParams.get("userId");
|
||||
|
||||
@@ -50,20 +51,23 @@ 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>
|
||||
</div>
|
||||
</div>
|
||||
{/* دکمههای پایین صفحه: ارسال پیام (به گفتگو) و بازگشت (به صفحهی درخواست) */}
|
||||
<div className="fixed bottom-28 left-0 right-0 z-[54] flex justify-center gap-3 px-4">
|
||||
<Link href={`/settings/chats/${userDetail?.user_name}/${userId}`} className="flex-1 max-w-[9rem]">
|
||||
<RoundedButton variant="primary" className="h-10 w-full">
|
||||
{t("offerPage.paymentSuccess.sendMessage")}
|
||||
</RoundedButton>
|
||||
</Link>
|
||||
<RoundedButton
|
||||
variant="default"
|
||||
className="h-10 flex-1 max-w-[9rem]"
|
||||
onClick={() => router.push(`/offer/${userId}`)}
|
||||
>
|
||||
{t("offerPage.paymentFailed.back")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
import { Metadata } from "next";
|
||||
import { cache } from "react";
|
||||
import { fetchPostById } from "@/api/fetchPostById";
|
||||
import PostFeedView, { PostFeedSearchParams } from "@/components/posts/PostFeedView";
|
||||
import GuestAuthPromptModal from "@/components/auth/GuestAuthPromptModal";
|
||||
import { buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import { generatePageMetadata } from "@/utils/generatePageMetadata";
|
||||
import {
|
||||
buildPostPath,
|
||||
buildPostPublicUrl,
|
||||
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>>;
|
||||
@@ -51,7 +58,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");
|
||||
@@ -85,6 +92,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,
|
||||
});
|
||||
}
|
||||
@@ -94,14 +105,28 @@ 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
|
||||
searchParams={feedSearchParams}
|
||||
/>
|
||||
<GuestAuthPromptModal />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -12,6 +12,15 @@ import toast from "react-hot-toast";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
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;
|
||||
@@ -40,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`
|
||||
);
|
||||
@@ -172,7 +181,7 @@ export default function PurchasedCoursesPage() {
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-8">
|
||||
{t("settings.academy.myCourses.emptyDescription")}
|
||||
</p>
|
||||
<a
|
||||
<Link
|
||||
href="/academy"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-blue-500 to-purple-600 text-white rounded-2xl hover:shadow-lg transition-all duration-300 transform hover:scale-105"
|
||||
>
|
||||
@@ -190,7 +199,7 @@ export default function PurchasedCoursesPage() {
|
||||
/>
|
||||
</svg>
|
||||
{t("settings.academy.myCourses.browseCourses")}
|
||||
</a>
|
||||
</Link>
|
||||
</motion.div>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -28,7 +28,7 @@ import "leaflet/dist/leaflet.css";
|
||||
import { useRouter } from "next/navigation";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { Academy } from "@/types/types";
|
||||
import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
|
||||
import { AcademyProfileHeadSkeleton } from "@/components/academy/AcademySkeletons";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
@@ -77,13 +77,12 @@ interface AcademyResponse {
|
||||
type TagType = { value: string };
|
||||
|
||||
export default function StoreSettings() {
|
||||
const { request, loading, error } = useAxios();
|
||||
const { request } = useAxios();
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation("common");
|
||||
const storeSchema = useMemo(() => buildStoreSchema(t), [t]);
|
||||
const tagPlaceholder = t("settings.academy.storeProfile.tagPlaceholder");
|
||||
const [previewProfile, setPreviewProfile] = useState<string>("");
|
||||
const [avatar, setAvatar] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isFetching, setIsFetching] = useState(true); // برای نمایش لودینگ اولیه
|
||||
|
||||
@@ -153,7 +152,6 @@ export default function StoreSettings() {
|
||||
// تنظیم پیشنمایش عکس
|
||||
if (response.academy.academy_image) {
|
||||
setPreviewProfile(response.academy.academy_image);
|
||||
setAvatar(response.academy.academy_image);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -181,7 +179,6 @@ export default function StoreSettings() {
|
||||
const file = event.target.files[0];
|
||||
const previewUrl = URL.createObjectURL(file);
|
||||
setPreviewProfile(previewUrl);
|
||||
setAvatar(previewUrl);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { ThemeProvider, useTheme } from "next-themes";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
import {
|
||||
@@ -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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,21 +2,32 @@
|
||||
|
||||
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";
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import PageTitle from "@/components/settings/PageTitle";
|
||||
import IOSSpinner from "@/components/ui/IOSSpinner";
|
||||
import ModelDetailModal from "@/components/models/ModelDetailModal";
|
||||
import ModelServiceModal from "@/components/models/ModelServiceModal";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatFullName } from "@/lib/formatFullName";
|
||||
import { isModelExpertise } from "@/lib/isModelExpertise";
|
||||
import type { Service } from "@/types/types";
|
||||
import toast from "react-hot-toast";
|
||||
import FindPartnerFilterModal, {
|
||||
EMPTY_PARTNER_FILTERS,
|
||||
PartnerFilters,
|
||||
} from "@/components/chats/FindPartnerFilterModal";
|
||||
import {
|
||||
DESIRED_AGE_RANGE_TO_MIN_MAX,
|
||||
joinFilterValues,
|
||||
type DesiredAgeRange,
|
||||
} from "@/lib/personalDetailsFieldOptions";
|
||||
|
||||
type PartnerUser = {
|
||||
_id: string;
|
||||
@@ -36,6 +47,19 @@ type PartnerUser = {
|
||||
|
||||
type PartnerAlert = { _id: string } | null;
|
||||
|
||||
// عین فلوی ModelContent.tsx (دکمهی «درخواست همکاری» → ModelDetailModal/ModelServiceModal
|
||||
// → دکمهی داخلِ مودال میره /offer/[id]): برای تصمیمِ مدل/غیرمدل و رندرِ مودالِ درست
|
||||
type CollabUser = {
|
||||
_id: string;
|
||||
expertise?: string;
|
||||
hair_color?: string | null;
|
||||
eye_color?: string | null;
|
||||
height?: string | null;
|
||||
size?: string | null;
|
||||
weight?: string | null;
|
||||
services?: Service[];
|
||||
};
|
||||
|
||||
function ageFromBirthday(birthday?: string | null): number | null {
|
||||
if (!birthday) return null;
|
||||
const diffMs = Date.now() - new Date(birthday).getTime();
|
||||
@@ -49,8 +73,16 @@ function filtersToParams(filters: PartnerFilters): Record<string, string> {
|
||||
if (value) params[key] = value;
|
||||
};
|
||||
put("gender", filters.gender);
|
||||
put("ageMin", filters.ageMin);
|
||||
put("ageMax", filters.ageMax);
|
||||
|
||||
// بازهی سنی: ورودی عددی دستی (ageMin/ageMax) اگر پر شده باشد اولویت دارد؛
|
||||
// در غیر این صورت از دستهی «بازه سنی مورد نظر» (desired_age_range) به
|
||||
// ageMin/ageMax عددی که سرچ سرور میفهمد ترجمه میشود.
|
||||
const bucket = filters.desiredAgeRange
|
||||
? DESIRED_AGE_RANGE_TO_MIN_MAX[filters.desiredAgeRange as DesiredAgeRange]
|
||||
: undefined;
|
||||
put("ageMin", filters.ageMin || (bucket?.min != null ? String(bucket.min) : undefined));
|
||||
put("ageMax", filters.ageMax || (bucket?.max != null ? String(bucket.max) : undefined));
|
||||
|
||||
put("heightMin", filters.heightMin);
|
||||
put("heightMax", filters.heightMax);
|
||||
put("weightMin", filters.weightMin);
|
||||
@@ -65,6 +97,16 @@ function filtersToParams(filters: PartnerFilters): Record<string, string> {
|
||||
put("personal_style", filters.personalStyle);
|
||||
put("income_range", filters.incomeRange);
|
||||
put("job", filters.job);
|
||||
put("relationship_goal", joinFilterValues(filters.relationshipGoal));
|
||||
put("seeking_gender", filters.seekingGender);
|
||||
put("personality_traits", joinFilterValues(filters.personalityTraits));
|
||||
put("lifestyle", joinFilterValues(filters.lifestyle));
|
||||
put("interests", joinFilterValues(filters.interests));
|
||||
put("values", joinFilterValues(filters.values));
|
||||
put("relationship_view", filters.relationshipView);
|
||||
put("geo_preference", filters.geoPreference);
|
||||
put("children_status", joinFilterValues(filters.childrenStatus));
|
||||
put("smoking_status", filters.smokingStatus);
|
||||
if (filters.geo.countryId) params.country = String(filters.geo.countryId);
|
||||
if (filters.geo.provinceId) params.province = String(filters.geo.provinceId);
|
||||
if (filters.geo.cityId) params.city = String(filters.geo.cityId);
|
||||
@@ -74,6 +116,7 @@ function filtersToParams(filters: PartnerFilters): Record<string, string> {
|
||||
export default function FindPartnerPage() {
|
||||
const { t } = useTranslation("common");
|
||||
const { request } = useAxios();
|
||||
const router = useRouter();
|
||||
|
||||
const [filters, setFilters] = useState<PartnerFilters>(EMPTY_PARTNER_FILTERS);
|
||||
const [showFilterModal, setShowFilterModal] = useState(false);
|
||||
@@ -81,6 +124,33 @@ export default function FindPartnerPage() {
|
||||
const [status, setStatus] = useState<"loading" | "ready" | "error">("loading");
|
||||
const [alert, setAlert] = useState<PartnerAlert>(null);
|
||||
const [savingAlert, setSavingAlert] = useState(false);
|
||||
const [collabUser, setCollabUser] = useState<CollabUser | null>(null);
|
||||
|
||||
// عین sendMsgHandler توی ModelContent.tsx: قبل از رفتن به چت، وضعیت درخواست
|
||||
// همکاری (offer_status) رو تازه میگیریم — بدون درخواست ثبتشده، اول باید همون
|
||||
// مودالِ واقعیِ مشخصاتظاهری/لیستخدمات باز بشه (نه مستقیم صفحهی ثبت درخواست)
|
||||
const openUser = useCallback(
|
||||
async (item: PartnerUser) => {
|
||||
try {
|
||||
const res = await request<{ user?: CollabUser & { offer_status?: boolean } }>(
|
||||
"GET",
|
||||
`/users/get/web?user_id=${item._id}&_t=${Date.now()}`,
|
||||
null,
|
||||
{ noToast: true }
|
||||
);
|
||||
if (res?.user?.offer_status) {
|
||||
router.push(`/settings/chats/${item.user_name}/${item._id}`);
|
||||
return;
|
||||
}
|
||||
setCollabUser(res?.user ? { ...res.user, _id: item._id } : { _id: item._id });
|
||||
return;
|
||||
} catch {
|
||||
// در صورت خطا هم بریم صفحهی ثبت درخواست، نه مستقیم چت
|
||||
}
|
||||
router.push(`/offer/${item._id}`);
|
||||
},
|
||||
[request, router]
|
||||
);
|
||||
|
||||
const runSearch = useCallback(
|
||||
async (activeFilters: PartnerFilters) => {
|
||||
@@ -195,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);
|
||||
@@ -207,6 +279,10 @@ export default function FindPartnerPage() {
|
||||
<Link
|
||||
key={item._id}
|
||||
href={`/offer/${item._id}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void openUser(item);
|
||||
}}
|
||||
className="flex items-center gap-3 py-3"
|
||||
>
|
||||
<ProfileAvatar
|
||||
@@ -278,6 +354,28 @@ export default function FindPartnerPage() {
|
||||
onApply={handleApplyFilters}
|
||||
onClear={handleClearFilters}
|
||||
/>
|
||||
|
||||
{collabUser &&
|
||||
(isModelExpertise(collabUser.expertise) ? (
|
||||
<ModelDetailModal
|
||||
isOpen
|
||||
onClose={() => setCollabUser(null)}
|
||||
userId={collabUser._id}
|
||||
hairColor={collabUser.hair_color}
|
||||
eyeColor={collabUser.eye_color}
|
||||
height={collabUser.height}
|
||||
size={collabUser.size}
|
||||
weight={collabUser.weight}
|
||||
isMain
|
||||
/>
|
||||
) : (
|
||||
<ModelServiceModal
|
||||
isOpen
|
||||
onClose={() => setCollabUser(null)}
|
||||
userId={collabUser._id}
|
||||
services={collabUser.services}
|
||||
/>
|
||||
))}
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
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";
|
||||
@@ -12,11 +14,15 @@ import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import PageTitle from "@/components/settings/PageTitle";
|
||||
import IOSSpinner from "@/components/ui/IOSSpinner";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
import ModelDetailModal from "@/components/models/ModelDetailModal";
|
||||
import ModelServiceModal from "@/components/models/ModelServiceModal";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useUser } from "@/hooks/useUser";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { formatFullName } from "@/lib/formatFullName";
|
||||
import { isModelExpertise } from "@/lib/isModelExpertise";
|
||||
import type { Service } from "@/types/types";
|
||||
|
||||
const MAPBOX_ACCESS_TOKEN =
|
||||
"pk.eyJ1IjoiYWxkZjkyIiwiYSI6ImNscHh5ZG56dTByMXAycnJ2cDNmdDQ5M3YifQ.a_sUt1rLFMfA0jHrETcM7A";
|
||||
@@ -51,9 +57,25 @@ type NearbyUser = {
|
||||
is_verified?: string;
|
||||
verify_badge?: string;
|
||||
is_Register?: string | boolean;
|
||||
/** فقط با این تیک («نمایش لوکیشن به دیگران») باید روی خودِ نقشه قرار بگیره — بدونش
|
||||
* فقط توی رادار (نمایش تقریبی، بدون نقشهی واقعی) نشون داده میشه */
|
||||
show_location?: boolean;
|
||||
distanceMeters: number;
|
||||
};
|
||||
|
||||
// عین فلوی ModelContent.tsx (دکمهی «درخواست همکاری» → ModelDetailModal/ModelServiceModal
|
||||
// → دکمهی داخلِ مودال میره /offer/[id]): برای تصمیمِ مدل/غیرمدل و رندرِ مودالِ درست
|
||||
type CollabUser = {
|
||||
_id: string;
|
||||
expertise?: string;
|
||||
hair_color?: string | null;
|
||||
eye_color?: string | null;
|
||||
height?: string | null;
|
||||
size?: string | null;
|
||||
weight?: string | null;
|
||||
services?: Service[];
|
||||
};
|
||||
|
||||
type NearbyGate =
|
||||
| "checking"
|
||||
| "location_disabled"
|
||||
@@ -93,6 +115,7 @@ export default function NearbyFriendsPage() {
|
||||
const { t } = useTranslation("common");
|
||||
const user = useUser();
|
||||
const { request } = useAxios();
|
||||
const router = useRouter();
|
||||
|
||||
const [gate, setGate] = useState<NearbyGate>("checking");
|
||||
const [users, setUsers] = useState<NearbyUser[]>([]);
|
||||
@@ -102,8 +125,15 @@ export default function NearbyFriendsPage() {
|
||||
);
|
||||
const [showMap, setShowMap] = useState(false);
|
||||
const [radiusKm, setRadiusKm] = useState<number>(DEFAULT_RADIUS_METERS / 1000);
|
||||
const [collabUser, setCollabUser] = useState<CollabUser | null>(null);
|
||||
|
||||
const radarUsers = useMemo(() => users.slice(0, 12), [users]);
|
||||
// فقط کاربرهایی که «نمایش لوکیشن به دیگران» رو فعال کردن باید روی خودِ نقشه (که
|
||||
// موقعیتِ واقعیترشون رو نشون میده) بیان — رادار بالای صفحه برای همه (radarUsers) باقی میمونه
|
||||
const mapEligibleUsers = useMemo(
|
||||
() => users.filter((u) => u.show_location),
|
||||
[users]
|
||||
);
|
||||
const radarActive =
|
||||
gate === "locating" || gate === "checking" || gate === "ready";
|
||||
const radiusMeters = radiusKm * 1000;
|
||||
@@ -188,6 +218,32 @@ export default function NearbyFriendsPage() {
|
||||
[coords, loadNearby]
|
||||
);
|
||||
|
||||
// عین sendMsgHandler توی ModelContent.tsx: قبل از رفتن به چت، وضعیت درخواست
|
||||
// همکاری (offer_status) رو تازه میگیریم — بدون درخواست ثبتشده، اول باید همون
|
||||
// مودالِ واقعیِ مشخصاتظاهری/لیستخدمات باز بشه (نه مستقیم صفحهی ثبت درخواست)
|
||||
const openUser = useCallback(
|
||||
async (item: NearbyUser) => {
|
||||
try {
|
||||
const res = await request<{ user?: CollabUser & { offer_status?: boolean } }>(
|
||||
"GET",
|
||||
`/users/get/web?user_id=${item._id}&_t=${Date.now()}`,
|
||||
null,
|
||||
{ noToast: true }
|
||||
);
|
||||
if (res?.user?.offer_status) {
|
||||
router.push(`/settings/chats/${item.user_name}/${item._id}`);
|
||||
return;
|
||||
}
|
||||
setCollabUser(res?.user ? { ...res.user, _id: item._id } : { _id: item._id });
|
||||
return;
|
||||
} catch {
|
||||
// در صورت خطا هم بریم صفحهی ثبت درخواست، نه مستقیم چت
|
||||
}
|
||||
router.push(`/offer/${item._id}`);
|
||||
},
|
||||
[request, router]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
|
||||
@@ -299,6 +355,10 @@ export default function NearbyFriendsPage() {
|
||||
<Link
|
||||
key={item._id}
|
||||
href={`/offer/${item._id}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void openUser(item);
|
||||
}}
|
||||
className="absolute z-10 -translate-x-1/2 -translate-y-1/2 transition-transform active:scale-95"
|
||||
style={pos}
|
||||
title={item.user_name}
|
||||
@@ -420,14 +480,20 @@ 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
|
||||
key={`list-${item._id}`}
|
||||
href={`/offer/${item._id}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void openUser(item);
|
||||
}}
|
||||
className="flex items-center gap-3 py-3"
|
||||
>
|
||||
<ProfileAvatar
|
||||
@@ -519,18 +585,22 @@ export default function NearbyFriendsPage() {
|
||||
</div>
|
||||
</Marker>
|
||||
|
||||
{users.map((item, index) => {
|
||||
{mapEligibleUsers.map((item, index) => {
|
||||
const [lng, lat] = approximateCoordinate(
|
||||
coords.lat,
|
||||
coords.lng,
|
||||
item.distanceMeters,
|
||||
index,
|
||||
users.length
|
||||
mapEligibleUsers.length
|
||||
);
|
||||
return (
|
||||
<Marker key={item._id} longitude={lng} latitude={lat}>
|
||||
<Link
|
||||
href={`/offer/${item._id}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void openUser(item);
|
||||
}}
|
||||
className="flex flex-col items-center"
|
||||
title={item.user_name}
|
||||
>
|
||||
@@ -560,6 +630,28 @@ export default function NearbyFriendsPage() {
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{collabUser &&
|
||||
(isModelExpertise(collabUser.expertise) ? (
|
||||
<ModelDetailModal
|
||||
isOpen
|
||||
onClose={() => setCollabUser(null)}
|
||||
userId={collabUser._id}
|
||||
hairColor={collabUser.hair_color}
|
||||
eyeColor={collabUser.eye_color}
|
||||
height={collabUser.height}
|
||||
size={collabUser.size}
|
||||
weight={collabUser.weight}
|
||||
isMain
|
||||
/>
|
||||
) : (
|
||||
<ModelServiceModal
|
||||
isOpen
|
||||
onClose={() => setCollabUser(null)}
|
||||
userId={collabUser._id}
|
||||
services={collabUser.services}
|
||||
/>
|
||||
))}
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
@@ -297,9 +300,11 @@ function Chats() {
|
||||
<span className="truncate text-[11px] text-neutral-400 dark:text-neutral-500">
|
||||
{isOnline ? onlineLabel : item.last_online || offlineLabel}
|
||||
</span>
|
||||
<span className="truncate text-[11px] text-neutral-400 dark:text-neutral-500">
|
||||
{/* طبقِ خواستهی کاربر فعلاً کامنت شد — این متن همیشه ثابت
|
||||
نمایش داده میشد (نه پیشنمایشِ واقعیِ آخرین پیام) */}
|
||||
{/* <span className="truncate text-[11px] text-neutral-400 dark:text-neutral-500">
|
||||
{E2EE_LOCKED_PREVIEW}
|
||||
</span>
|
||||
</span> */}
|
||||
{item.unread_messages_count ? (
|
||||
<span className="rounded-full bg-[#387E65] px-2 py-0.5 text-xs text-white">
|
||||
{item.unread_messages_count}
|
||||
|
||||
@@ -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";
|
||||
@@ -14,8 +15,7 @@ import {
|
||||
joinGroupRoom,
|
||||
joinUserRoom,
|
||||
leaveGroupRoom,
|
||||
releaseChatSocket,
|
||||
} from "@/lib/chat/socketClient";
|
||||
releaseChatSocket, isChatSocketConnected, } from "@/lib/chat/socketClient";
|
||||
import MessageInput from "@/components/chat/MessageInput";
|
||||
import ChatActionBar from "@/components/chat/ChatActionBar";
|
||||
import AddChatRoomMembersModal from "@/components/chat/AddChatRoomMembersModal";
|
||||
@@ -42,12 +42,14 @@ import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import { optimizeMediaFile } from "@/lib/media";
|
||||
import { buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import {
|
||||
E2EE_LOCKED_PREVIEW,
|
||||
decryptRoomContent,
|
||||
decryptRoomMessageList,
|
||||
encryptRoomContent,
|
||||
ensureIdentityKeys,
|
||||
ensureRoomAesKey,
|
||||
} from "@/lib/e2ee";
|
||||
import CompleteSocialSignupModal from "@/components/auth/CompleteSocialSignupModal";
|
||||
|
||||
type RoomMember = {
|
||||
_id: string;
|
||||
@@ -163,6 +165,12 @@ function RoomMessageRow({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { onDragStart: _, ...restDragProps } = dragProps;
|
||||
|
||||
// طبقِ خواستهی کاربر، E2EE باید کاملاً نامرئی باشه — پیامِ متنیِ خالصی که رمزگشایی
|
||||
// نشده (هویتِ قدیمی ازدسترفته) اصلاً نمایش داده نمیشه، نه یه بابلِ خالی/قفلشده
|
||||
const bubbleContent =
|
||||
msg.content === E2EE_LOCKED_PREVIEW ? "" : msg.content;
|
||||
if (!bubbleContent && !msg.file) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={(el) => {
|
||||
@@ -202,7 +210,11 @@ function RoomMessageRow({
|
||||
<span className="font-semibold text-[#0095f6]">
|
||||
{msg.replyTo.senderName}
|
||||
</span>
|
||||
<p className="truncate">{msg.replyTo.content}</p>
|
||||
<p className="truncate">
|
||||
{msg.replyTo.content === E2EE_LOCKED_PREVIEW
|
||||
? ""
|
||||
: msg.replyTo.content}
|
||||
</p>
|
||||
</button>
|
||||
) : null}
|
||||
<div className="relative w-full overflow-hidden">
|
||||
@@ -287,9 +299,9 @@ function RoomMessageRow({
|
||||
<span>File</span>
|
||||
</a>
|
||||
) : null}
|
||||
{msg.content ? (
|
||||
{bubbleContent ? (
|
||||
<p className="chat-message-text select-text whitespace-pre-wrap break-words">
|
||||
{msg.content}
|
||||
{bubbleContent}
|
||||
</p>
|
||||
) : null}
|
||||
<span className="mt-1 block text-[10px] opacity-70">
|
||||
@@ -327,6 +339,8 @@ export default function ChatRoomThreadPage({
|
||||
const { request } = useAxios();
|
||||
const router = useRouter();
|
||||
const user = useUser();
|
||||
const [socialSignupOpen, setSocialSignupOpen] = useState(false);
|
||||
const pendingSendRetryRef = useRef<(() => void) | null>(null);
|
||||
const [room, setRoom] = useState<RoomDetail | null>(null);
|
||||
const [messages, setMessages] = useState<RoomMessage[]>([]);
|
||||
// این صفحه با تغییرِ فقط پارامترِ roomId دوباره mount نمیشه (رفتارِ App Router)،
|
||||
@@ -534,7 +548,12 @@ export default function ChatRoomThreadPage({
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
// وقتی سوکت وصله، پیامهای گروه real-time (newGroupMessage) میرسن؛ polling فقط
|
||||
// همگامسازیِ ایمنی با فاصلهی بیشتره، وگرنه همون ۸ ثانیهی قبلی
|
||||
let tick = 0;
|
||||
const poll = window.setInterval(() => {
|
||||
tick += 1;
|
||||
if (isChatSocketConnected() && tick % 8 !== 0) return;
|
||||
void loadMessages();
|
||||
void loadRoom();
|
||||
}, 8000);
|
||||
@@ -547,9 +566,10 @@ export default function ChatRoomThreadPage({
|
||||
|
||||
const processSendMessage = async (
|
||||
fileToUpload: File | null = null,
|
||||
fileType?: RoomMessage["fileType"]
|
||||
fileType?: RoomMessage["fileType"],
|
||||
contentOverride?: string
|
||||
) => {
|
||||
const text = newMessage.trim();
|
||||
const text = (contentOverride ?? newMessage).trim();
|
||||
if ((!text && !fileToUpload) || sending) return;
|
||||
|
||||
const senderId = String(user?._id ?? getStoredUserId());
|
||||
@@ -606,14 +626,17 @@ export default function ChatRoomThreadPage({
|
||||
}
|
||||
|
||||
try {
|
||||
let sendContent = text;
|
||||
try {
|
||||
await ensureIdentityKeys(senderId);
|
||||
const encrypted = await encryptRoomContent(senderId, roomId, text);
|
||||
if (encrypted) sendContent = encrypted;
|
||||
} catch {
|
||||
/* fallback plaintext */
|
||||
}
|
||||
// ارسالِ فایل/عکس/ویس/ویدیو توی روم هم فعلاً بدونِ e2ee میره — عینِ دلیلِ همین
|
||||
// تغییر توی چتِ مستقیم (chats/[username]/[id]/page.tsx): اگه encryptRoomContent
|
||||
// به هر دلیلی null برگردونه، قبلاً کلِ آپلودِ فایل هم با همین خطا شکست میخورد
|
||||
const sendContent = fileForUpload
|
||||
? text
|
||||
: await (async () => {
|
||||
await ensureIdentityKeys(senderId);
|
||||
const encrypted = await encryptRoomContent(senderId, roomId, text);
|
||||
if (encrypted === null) throw new Error("E2EE encryption failed");
|
||||
return encrypted;
|
||||
})();
|
||||
|
||||
let res: { message?: RoomMessage } | null = null;
|
||||
if (fileForUpload) {
|
||||
@@ -662,6 +685,17 @@ export default function ChatRoomThreadPage({
|
||||
if (replyPayload && savedReply) {
|
||||
setReplyingTo(savedReply);
|
||||
}
|
||||
|
||||
const errType = (err as { response?: { data?: { type?: string } } })
|
||||
?.response?.data?.type;
|
||||
if (errType === "needs_username_password") {
|
||||
pendingSendRetryRef.current = () => {
|
||||
void processSendMessage(fileToUpload, fileType, text);
|
||||
};
|
||||
setSocialSignupOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const message =
|
||||
(err as { response?: { data?: { error?: string; message?: string } } })
|
||||
?.response?.data?.error ||
|
||||
@@ -856,9 +890,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) => {
|
||||
@@ -958,6 +990,20 @@ export default function ChatRoomThreadPage({
|
||||
existingMemberIds={room?.members?.map((member) => member._id) ?? []}
|
||||
onAdded={() => void loadRoom()}
|
||||
/>
|
||||
|
||||
<CompleteSocialSignupModal
|
||||
open={socialSignupOpen}
|
||||
hasUsername={Boolean(user?.user_name)}
|
||||
onClose={() => {
|
||||
setSocialSignupOpen(false);
|
||||
pendingSendRetryRef.current = null;
|
||||
}}
|
||||
onSuccess={() => {
|
||||
setSocialSignupOpen(false);
|
||||
pendingSendRetryRef.current?.();
|
||||
pendingSendRetryRef.current = null;
|
||||
}}
|
||||
/>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import Container from "@/components/elements/Container";
|
||||
import ProfileAvatar from "@/components/main/ProfileAvatar";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import ChatBoldIcon from "@/components/chat/ChatBoldIcon";
|
||||
import { chatHeaderCircleBtn, chatHeaderInfoPill, chatHeaderGlassStyle } from "@/components/chat/ChatHeader";
|
||||
import VideoMessageBubble from "@/components/chat/VideoMessageBubble";
|
||||
import BackButton from "@/components/ui/BackButton";
|
||||
import IOSSpinner from "@/components/ui/IOSSpinner";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { glassBackdropStyle } from "@/lib/glassBackdrop";
|
||||
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
|
||||
import { formatBubbleTime } from "@/lib/chat/formatMessageTime";
|
||||
import {
|
||||
@@ -58,6 +58,7 @@ export default function ShopChatPage() {
|
||||
const params = useParams<{ shopId: string; buyerId: string }>();
|
||||
const { shopId, buyerId } = params;
|
||||
const { request } = useAxios();
|
||||
const router = useRouter();
|
||||
|
||||
const [messages, setMessages] = useState<ShopChatMessage[] | null>(null);
|
||||
const [counterpart, setCounterpart] = useState<Counterpart | null>(null);
|
||||
@@ -162,12 +163,15 @@ export default function ShopChatPage() {
|
||||
dir="rtl"
|
||||
className="pointer-events-auto mx-auto flex max-w-lg items-center gap-2"
|
||||
>
|
||||
<BackButton
|
||||
tinted
|
||||
iconSize={22}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
className={chatHeaderCircleBtn}
|
||||
style={chatHeaderGlassStyle}
|
||||
/>
|
||||
aria-label={t("chats.actions.back")}
|
||||
>
|
||||
<ChatBoldIcon name="back" size={22} className="text-current" />
|
||||
</button>
|
||||
|
||||
<div dir="ltr" className={chatHeaderInfoPill} style={chatHeaderGlassStyle}>
|
||||
<div dir="rtl" className="min-w-0 flex-1 text-right">
|
||||
@@ -291,7 +295,10 @@ export default function ShopChatPage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="glass-chat-input gentle-transition flex min-h-11 flex-1 items-center rounded-[22px] px-4 py-2">
|
||||
<div
|
||||
className="glass-chat-input gentle-transition flex min-h-11 flex-1 items-center rounded-[22px] px-4 py-2"
|
||||
style={glassBackdropStyle("glass-chat-input")}
|
||||
>
|
||||
<textarea
|
||||
rows={1}
|
||||
value={text}
|
||||
|
||||
@@ -152,7 +152,6 @@ function ArchivePage() {
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="50vw"
|
||||
unoptimized
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
||||
108
src/app/settings/edit/birthday/page.tsx
Normal file
108
src/app/settings/edit/birthday/page.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import AuthNextButton from "@/components/auth/AuthNextButton";
|
||||
import EditPageHeader from "@/components/settings/EditPageHeader";
|
||||
import { EditFormSkeleton } from "@/components/settings/EditPageSkeletons";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useUser } from "@/hooks/useUser";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import DatePicker from "react-multi-date-picker";
|
||||
import persian from "react-date-object/calendars/persian";
|
||||
import persian_fa from "react-date-object/locales/persian_fa";
|
||||
import Image from "next/image";
|
||||
import moment from "jalali-moment";
|
||||
|
||||
const p2e = (es: string): string =>
|
||||
es.replace(/[۰-۹]/g, (d) => String.fromCharCode("۰۱۲۳۴۵۶۷۸۹".indexOf(d) + 48));
|
||||
|
||||
// نسخهی مستقل و سبک برای ثبتِ فقط تاریخ تولد، بدون شبا/کد ملیِ الزامیِ
|
||||
// POST /verify/auth (نگاه کنید settings/edit/shaba/page.tsx) — عیناً همون
|
||||
// مکانیزمِ انتخابگرِ تاریخِ آن صفحه، ذخیره از طریق PATCH /verify/personal_details
|
||||
function EditBirthdayPage() {
|
||||
const { t } = useTranslation("common");
|
||||
const router = useRouter();
|
||||
const { request, loading } = useAxios();
|
||||
const user = useUser();
|
||||
const [birthDate, setBirthDate] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.birthday) {
|
||||
setBirthDate(moment(user.birthday, "YYYY/MM/DD").format("jYYYY/jMM/jDD"));
|
||||
}
|
||||
}, [user?.birthday]);
|
||||
|
||||
const submit = async () => {
|
||||
if (!birthDate) {
|
||||
toast.error(t("auth.birthDateRequired"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await request("PATCH", "/verify/personal_details", {
|
||||
birthday: moment(p2e(birthDate), "jYYYY/jMM/jDD").format("YYYY/MM/DD"),
|
||||
});
|
||||
toast.success(t("settings.edit.personalDetails.saveSuccess"));
|
||||
router.push("/settings/edit/personal-details");
|
||||
} catch (err: any) {
|
||||
toast.error(err?.response?.data?.message || t("settings.edit.unknownError"));
|
||||
}
|
||||
};
|
||||
|
||||
const canSave = Boolean(birthDate) && !loading;
|
||||
|
||||
return (
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center min-h-dvh justify-center auth-page">
|
||||
<EditPageHeader title={t("auth.birthDatePlaceholder")} />
|
||||
{!user ? (
|
||||
<EditFormSkeleton fields={2} />
|
||||
) : (
|
||||
<div className="w-full max-w-sm flex flex-col items-center">
|
||||
<div className="relative mt-5">
|
||||
<DatePicker
|
||||
id="birthDate"
|
||||
calendar={persian}
|
||||
locale={persian_fa}
|
||||
value={birthDate}
|
||||
onChange={(value) =>
|
||||
setBirthDate(value?.format("YYYY/MM/DD") || "")
|
||||
}
|
||||
calendarPosition="bottom-center"
|
||||
placeholder={t("auth.birthDatePlaceholder")}
|
||||
inputClass="w-full dir-ltr max-w-[290px] p-3 !pr-6 text-center rounded-2xl border border-gray-300 bg-secondary-light dark:bg-secondary-dark dark:border-neutral-600 font-medium h-[44px]"
|
||||
/>
|
||||
<label htmlFor="birthDate" className="absolute right-4 top-3">
|
||||
<Image
|
||||
width={24}
|
||||
height={24}
|
||||
alt="calendar icon"
|
||||
src="/images/icons/calendar.svg"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
variant="primary"
|
||||
className="mt-8"
|
||||
loading={loading}
|
||||
disabled={!canSave}
|
||||
onClick={submit}
|
||||
>
|
||||
{t("settings.edit.save")}
|
||||
</AuthNextButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditBirthdayPage;
|
||||
@@ -6,8 +6,7 @@ import PageTitle from "@/components/settings/PageTitle";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { SettingsListSkeleton } from "@/components/settings/EditPageSkeletons";
|
||||
import { buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import Image from "next/image";
|
||||
import ProfileAvatar from "@/components/main/ProfileAvatar";
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -17,6 +16,7 @@ type BlockedUser = {
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
profile_image?: string;
|
||||
verify_badge?: string;
|
||||
};
|
||||
|
||||
function BlockedUsersPage() {
|
||||
@@ -57,19 +57,13 @@ function BlockedUsersPage() {
|
||||
href={`/users/${u.user_name}`}
|
||||
className="flex items-center gap-3 rounded-2xl px-2 py-2 hover:bg-neutral-100 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<div className="relative h-11 w-11 shrink-0 overflow-hidden rounded-full bg-neutral-200">
|
||||
<Image
|
||||
src={
|
||||
u.profile_image
|
||||
? buildStorageUrl(u.profile_image)
|
||||
: "/images/fake-avatar.png"
|
||||
}
|
||||
alt=""
|
||||
fill
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
<ProfileAvatar
|
||||
src={u.profile_image}
|
||||
userId={u._id}
|
||||
verifyBadge={u.verify_badge}
|
||||
sizePx={44}
|
||||
rounded="full"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-bold">{name}</p>
|
||||
<p className="truncate text-xs text-neutral-500">
|
||||
|
||||
@@ -103,7 +103,7 @@ function Colors() {
|
||||
{/* Buttons for categories */}
|
||||
<div className="flex justify-center gap-2 mt-4 w-full max-w-[320px]">
|
||||
<button
|
||||
className={`p-1 rounded-full w-full border text-sm flex items-center justify-center gap-1 ${
|
||||
className={`p-1 rounded-2xl w-full border text-sm flex items-center justify-center gap-1 ${
|
||||
selectedButton === 1
|
||||
? "btn-modern--selected text-white"
|
||||
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
|
||||
@@ -113,7 +113,7 @@ function Colors() {
|
||||
{t("settings.edit.colors.eyeColor")}
|
||||
</button>
|
||||
<button
|
||||
className={`p-1 rounded-full w-full border text-sm flex items-center justify-center gap-1 ${
|
||||
className={`p-1 rounded-2xl w-full border text-sm flex items-center justify-center gap-1 ${
|
||||
selectedButton === 2
|
||||
? "btn-modern--selected text-white"
|
||||
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
|
||||
|
||||
103
src/app/settings/edit/gender/page.tsx
Normal file
103
src/app/settings/edit/gender/page.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import AuthNextButton from "@/components/auth/AuthNextButton";
|
||||
import EditPageHeader from "@/components/settings/EditPageHeader";
|
||||
import { EditFormSkeleton } from "@/components/settings/EditPageSkeletons";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useUser } from "@/hooks/useUser";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Image from "next/image";
|
||||
|
||||
// نسخهی مستقل از (auth)/verify/gender/page.tsx: بهجای پیشروی توی ویزارد ثبتنام،
|
||||
// فقط ذخیره میکنه و به «مشخصات فردی» برمیگرده — برای تکمیل جنسیتِ ثبتنشده از
|
||||
// همون صفحه (نگاه کنید src/app/settings/edit/personal-details/page.tsx)
|
||||
function EditGenderPage() {
|
||||
const { t } = useTranslation("common");
|
||||
const router = useRouter();
|
||||
const { request, loading } = useAxios();
|
||||
const user = useUser();
|
||||
const [gender, setGender] = useState<"male" | "female" | "">(
|
||||
user?.gender === "male" || user?.gender === "female" ? user.gender : ""
|
||||
);
|
||||
|
||||
const submit = async () => {
|
||||
if (!gender) {
|
||||
toast.error(t("auth.genderRequired"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await request("PATCH", "/verify/gender", { gender });
|
||||
toast.success(t("settings.edit.personalDetails.saveSuccess"));
|
||||
router.push("/settings/edit/personal-details");
|
||||
} catch (err: any) {
|
||||
toast.error(err?.response?.data?.message || t("auth.unknownError"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center min-h-dvh justify-center auth-page">
|
||||
<EditPageHeader title={t("auth.genderTitle")} />
|
||||
{!user ? (
|
||||
<EditFormSkeleton fields={2} />
|
||||
) : (
|
||||
<div className="w-full max-w-sm flex flex-col items-center">
|
||||
<div className="mt-5 flex w-full gap-4">
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
onClick={() => setGender("female")}
|
||||
className={`w-full max-w-[250px] !border-[#FC8EAC] flex items-center justify-center flex-row-reverse gap-2 ${
|
||||
gender === "female" ? "btn-modern--selected text-white" : ""
|
||||
}`}
|
||||
>
|
||||
<Image
|
||||
src={"/images/icons/woman.svg"}
|
||||
width={25}
|
||||
height={25}
|
||||
alt="gender icon"
|
||||
/>
|
||||
{t("auth.female")}
|
||||
</AuthNextButton>
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
onClick={() => setGender("male")}
|
||||
className={`w-full max-w-[250px] !border-[#3E8BFF] flex items-center justify-center flex-row-reverse gap-2 ${
|
||||
gender === "male" ? "!bg-[#3E8BFF] !text-white" : ""
|
||||
}`}
|
||||
>
|
||||
<Image
|
||||
src={"/images/icons/man.svg"}
|
||||
width={25}
|
||||
height={25}
|
||||
alt="gender icon"
|
||||
/>
|
||||
{t("auth.male")}
|
||||
</AuthNextButton>
|
||||
</div>
|
||||
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
variant="primary"
|
||||
className="mt-8"
|
||||
loading={loading}
|
||||
disabled={loading || !gender}
|
||||
onClick={submit}
|
||||
>
|
||||
{t("settings.edit.save")}
|
||||
</AuthNextButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditGenderPage;
|
||||
@@ -24,7 +24,6 @@ export default function LinkDevicePage() {
|
||||
const { t } = useTranslation("common");
|
||||
const router = useRouter();
|
||||
const [phase, setPhase] = useState<Phase>("loading");
|
||||
const [linkId, setLinkId] = useState<string | null>(null);
|
||||
const [approveUrl, setApproveUrl] = useState<string | null>(null);
|
||||
|
||||
const ephemeralPrivateKeyRef = useRef<CryptoKey | null>(null);
|
||||
@@ -44,7 +43,6 @@ export default function LinkDevicePage() {
|
||||
try {
|
||||
const { linkId: id, ephemeralPrivateKey } = await startDeviceLinkSession();
|
||||
ephemeralPrivateKeyRef.current = ephemeralPrivateKey;
|
||||
setLinkId(id);
|
||||
|
||||
const url = `${window.location.origin}/settings/edit/link-device/approve?id=${id}`;
|
||||
setApproveUrl(url);
|
||||
|
||||
@@ -16,6 +16,7 @@ const EDIT_NAV_KEY: Record<string, string> = {
|
||||
"/username": "username",
|
||||
"/password": "password",
|
||||
"/google-account": "googleAccount",
|
||||
"/register-mobile": "registerMobile",
|
||||
"/two-factor": "twoFactor",
|
||||
"/link-device": "linkDevice",
|
||||
"/avatar": "avatar",
|
||||
@@ -51,7 +52,8 @@ function EditPage() {
|
||||
{editUserNavLinks?.map((item) => (
|
||||
<Link
|
||||
className={`flex items-center gap-2 ${
|
||||
item.href === "/License" && isRegister === "false"
|
||||
(item.href === "/License" && isRegister === "false") ||
|
||||
(item.href === "/register-mobile" && Boolean(user?.mobile))
|
||||
? "hidden"
|
||||
: ""
|
||||
}`}
|
||||
|
||||
@@ -16,6 +16,7 @@ import BackButton from "@/components/ui/BackButton";
|
||||
import { useUser } from "@/hooks/useUser";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { establishIdentityAtLogin } from "@/lib/e2ee";
|
||||
|
||||
function PasswordPage() {
|
||||
const { t } = useTranslation("common");
|
||||
@@ -60,6 +61,11 @@ function PasswordPage() {
|
||||
...(user?.mobile ? { mobile: user.mobile } : {}),
|
||||
new_password: values.password,
|
||||
})) as IVerifyOtp;
|
||||
// رمزِ عبور عوض شد (یا اولینبار تنظیم شد) — کلیدِ E2EEِ همین مرورگر
|
||||
// (دستنخورده توی IndexedDB) رو زیرِ رمزِ جدید دوباره بکآپ بگیر
|
||||
if (user?._id) {
|
||||
// [E2EE] موقتاً غیرفعال — void establishIdentityAtLogin(user._id, values.password);
|
||||
}
|
||||
toast.success(
|
||||
isGoogleUser
|
||||
? t("settings.edit.password.setSuccess")
|
||||
|
||||
@@ -13,12 +13,90 @@ import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import RoundedSelect from "@/components/ui/RoundedSelect";
|
||||
import MultiSelectDropdown from "@/components/ui/MultiSelectDropdown";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
buildRelationshipGoalOptions,
|
||||
buildMaritalStatusNewOptions,
|
||||
buildIncomeRangeOptions,
|
||||
buildPersonalityTraitsOptions,
|
||||
buildLifestyleOptions,
|
||||
buildInterestsOptions,
|
||||
buildValuesOptions,
|
||||
buildRelationshipViewOptions,
|
||||
buildGeoPreferenceOptions,
|
||||
buildChildrenStatusOptions,
|
||||
buildSmokingStatusOptions,
|
||||
buildPersonalStyleOptions,
|
||||
type RelationshipView,
|
||||
type GeoPreference,
|
||||
type SmokingStatus,
|
||||
} from "@/lib/personalDetailsFieldOptions";
|
||||
|
||||
type MaritalStatus = "single" | "divorced" | null;
|
||||
type MaritalStatus =
|
||||
| "single"
|
||||
| "divorced"
|
||||
| "widowed"
|
||||
| "not_in_relationship"
|
||||
| "prefer_not_to_say"
|
||||
| null;
|
||||
type ResidenceType = "owner" | "tenant" | null;
|
||||
type VehicleType = "none" | "car" | "motorcycle" | null;
|
||||
type PersonalStyle = "formal" | "sporty" | null;
|
||||
type IncomeRange = "under_5m" | "5m_10m" | "10m_20m" | "over_20m" | null;
|
||||
type IncomeRange =
|
||||
| "under_40m"
|
||||
| "40m_50m"
|
||||
| "50m_60m"
|
||||
| "60m_70m"
|
||||
| "70m_80m"
|
||||
| "80m_90m"
|
||||
| "90m_100m"
|
||||
| "100m_150m"
|
||||
| "150m_200m"
|
||||
| "over_200m"
|
||||
| null;
|
||||
|
||||
// عین ageFromBirthday توی settings/chats/find-partner/page.tsx — برای نمایشِ
|
||||
// فقطخواندنیِ سنِ واقعیِ کاربر (نه ترجیح جستجو) توی همین صفحه
|
||||
function ageFromBirthday(birthday?: string | null): number | null {
|
||||
if (!birthday) return null;
|
||||
const diffMs = Date.now() - new Date(birthday).getTime();
|
||||
if (Number.isNaN(diffMs)) return null;
|
||||
return Math.floor(diffMs / (365.25 * 24 * 60 * 60 * 1000));
|
||||
}
|
||||
|
||||
// ردیف فقطخواندنی برای جنسیت/سنِ واقعیِ کاربر (از پروفایل، نه ترجیح جستجو) —
|
||||
// طبق درخواست کاربر seeking_gender/desired_age_range فقط باید توی فیلتر «یافتن
|
||||
// همکار» (FindPartnerFilterModal.tsx) بمونن، نه توی ویرایش مشخصات فردی؛ اگه
|
||||
// مقدار واقعی هنوز ثبت نشده، بهجاش یه لینک «افزودن» نشون داده میشه
|
||||
function ReadOnlyValueRow({
|
||||
label,
|
||||
value,
|
||||
href,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | null;
|
||||
href: string;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-semibold">{label}</p>
|
||||
{value ? (
|
||||
<div className="flex min-h-[42px] w-full items-center rounded-2xl border border-[#C3C3C3] bg-white/80 px-4 py-2.5 text-sm dark:border-neutral-600 dark:bg-neutral-900/80">
|
||||
{value}
|
||||
</div>
|
||||
) : (
|
||||
<Link
|
||||
href={href}
|
||||
className="flex min-h-[42px] w-full items-center justify-between rounded-2xl border border-neutral-950 px-4 py-2.5 text-sm font-medium text-neutral-950 dark:border-neutral-400 dark:text-neutral-50"
|
||||
>
|
||||
افزودن
|
||||
<span aria-hidden>+</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PersonalDetailsPage() {
|
||||
const { t } = useTranslation("common");
|
||||
@@ -31,10 +109,34 @@ function PersonalDetailsPage() {
|
||||
const [residenceArea, setResidenceArea] = useState("");
|
||||
const [vehicleType, setVehicleType] = useState<VehicleType>(null);
|
||||
const [vehicleDetails, setVehicleDetails] = useState("");
|
||||
const [personalStyle, setPersonalStyle] = useState<PersonalStyle>(null);
|
||||
const [personalStyle, setPersonalStyle] = useState("");
|
||||
const [job, setJob] = useState("");
|
||||
const [incomeRange, setIncomeRange] = useState<IncomeRange>(null);
|
||||
const [moralTraits, setMoralTraits] = useState("");
|
||||
|
||||
const [relationshipGoal, setRelationshipGoal] = useState<string[]>([]);
|
||||
const [personalityTraits, setPersonalityTraits] = useState<string[]>([]);
|
||||
const [lifestyle, setLifestyle] = useState<string[]>([]);
|
||||
const [interests, setInterests] = useState<string[]>([]);
|
||||
const [values, setValues] = useState<string[]>([]);
|
||||
const [relationshipView, setRelationshipView] = useState<RelationshipView | "">("");
|
||||
const [geoPreference, setGeoPreference] = useState<GeoPreference | "">("");
|
||||
const [childrenStatus, setChildrenStatus] = useState<string[]>([]);
|
||||
const [smokingStatus, setSmokingStatus] = useState<SmokingStatus | "">("");
|
||||
|
||||
const maritalStatusNewOptions = buildMaritalStatusNewOptions(t);
|
||||
const incomeRangeOptions = buildIncomeRangeOptions(t);
|
||||
const personalStyleOptions = buildPersonalStyleOptions(t);
|
||||
const relationshipGoalOptions = buildRelationshipGoalOptions(t);
|
||||
const personalityTraitsOptions = buildPersonalityTraitsOptions(t);
|
||||
const lifestyleOptions = buildLifestyleOptions(t);
|
||||
const interestsOptions = buildInterestsOptions(t);
|
||||
const valuesOptions = buildValuesOptions(t);
|
||||
const relationshipViewOptions = buildRelationshipViewOptions(t);
|
||||
const geoPreferenceOptions = buildGeoPreferenceOptions(t);
|
||||
const childrenStatusOptions = buildChildrenStatusOptions(t);
|
||||
const smokingStatusOptions = buildSmokingStatusOptions(t);
|
||||
const selectedCountLabel = (count: number) =>
|
||||
t("settings.edit.personalDetails.multiSelectSelectedCount", { count });
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
@@ -43,10 +145,18 @@ function PersonalDetailsPage() {
|
||||
setResidenceArea(user.residence_area ?? "");
|
||||
setVehicleType(user.vehicle_type ?? null);
|
||||
setVehicleDetails(user.vehicle_details ?? "");
|
||||
setPersonalStyle(user.personal_style ?? null);
|
||||
setPersonalStyle(user.personal_style ?? "");
|
||||
setJob(user.job ?? "");
|
||||
setIncomeRange(user.income_range ?? null);
|
||||
setMoralTraits(user.moral_traits ?? "");
|
||||
setRelationshipGoal(user.relationship_goal ?? []);
|
||||
setPersonalityTraits(user.personality_traits ?? []);
|
||||
setLifestyle(user.lifestyle ?? []);
|
||||
setInterests(user.interests ?? []);
|
||||
setValues(user.values ?? []);
|
||||
setRelationshipView(user.relationship_view ?? "");
|
||||
setGeoPreference(user.geo_preference ?? "");
|
||||
setChildrenStatus(user.children_status ?? []);
|
||||
setSmokingStatus(user.smoking_status ?? "");
|
||||
}, [user]);
|
||||
|
||||
const handleSave = async () => {
|
||||
@@ -57,10 +167,18 @@ function PersonalDetailsPage() {
|
||||
residence_area: residenceArea || null,
|
||||
vehicle_type: vehicleType,
|
||||
vehicle_details: vehicleType && vehicleType !== "none" ? vehicleDetails || null : null,
|
||||
personal_style: personalStyle,
|
||||
personal_style: personalStyle || null,
|
||||
job: job || null,
|
||||
income_range: incomeRange,
|
||||
moral_traits: moralTraits || null,
|
||||
relationship_goal: relationshipGoal.length ? relationshipGoal : null,
|
||||
personality_traits: personalityTraits.length ? personalityTraits : null,
|
||||
lifestyle: lifestyle.length ? lifestyle : null,
|
||||
interests: interests.length ? interests : null,
|
||||
values: values.length ? values : null,
|
||||
relationship_view: relationshipView || null,
|
||||
geo_preference: geoPreference || null,
|
||||
children_status: childrenStatus.length ? childrenStatus : null,
|
||||
smoking_status: smokingStatus || null,
|
||||
});
|
||||
toast.success(t("settings.edit.personalDetails.saveSuccess"));
|
||||
router.push("/settings/edit");
|
||||
@@ -90,46 +208,46 @@ function PersonalDetailsPage() {
|
||||
<p className="mb-2 text-sm font-semibold">
|
||||
{t("settings.edit.personalDetails.maritalStatus")}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={toggleBtnClass(maritalStatus === "single", "h-10 flex-1")}
|
||||
className={toggleBtnClass(maritalStatus === "single", "h-10 text-xs")}
|
||||
onClick={() => setMaritalStatus("single")}
|
||||
>
|
||||
{t("settings.edit.personalDetails.maritalStatusSingle")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={toggleBtnClass(maritalStatus === "divorced", "h-10 flex-1")}
|
||||
className={toggleBtnClass(maritalStatus === "divorced", "h-10 text-xs")}
|
||||
onClick={() => setMaritalStatus("divorced")}
|
||||
>
|
||||
{t("settings.edit.personalDetails.maritalStatusDivorced")}
|
||||
</button>
|
||||
{maritalStatusNewOptions.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
className={toggleBtnClass(maritalStatus === opt.value, "h-10 text-xs")}
|
||||
onClick={() => setMaritalStatus(opt.value as MaritalStatus)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* نوع سکونت */}
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-semibold">
|
||||
{t("settings.edit.personalDetails.residenceType")}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={toggleBtnClass(residenceType === "owner", "h-10 flex-1")}
|
||||
onClick={() => setResidenceType("owner")}
|
||||
>
|
||||
{t("settings.edit.personalDetails.residenceTypeOwner")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={toggleBtnClass(residenceType === "tenant", "h-10 flex-1")}
|
||||
onClick={() => setResidenceType("tenant")}
|
||||
>
|
||||
{t("settings.edit.personalDetails.residenceTypeTenant")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<RoundedSelect
|
||||
label={t("settings.edit.personalDetails.residenceType")}
|
||||
placeholder={t("settings.edit.personalDetails.selectPlaceholder")}
|
||||
value={residenceType ?? ""}
|
||||
options={[
|
||||
{ value: "owner", label: t("settings.edit.personalDetails.residenceTypeOwner") },
|
||||
{ value: "tenant", label: t("settings.edit.personalDetails.residenceTypeTenant") },
|
||||
]}
|
||||
onChange={(v) => setResidenceType((v || null) as ResidenceType)}
|
||||
className="mx-auto"
|
||||
/>
|
||||
|
||||
{/* محل سکونت */}
|
||||
<div>
|
||||
@@ -146,32 +264,18 @@ function PersonalDetailsPage() {
|
||||
|
||||
{/* وسیله نقلیه */}
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-semibold">
|
||||
{t("settings.edit.personalDetails.vehicleType")}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={toggleBtnClass(vehicleType === "none", "h-10 flex-1 text-xs")}
|
||||
onClick={() => setVehicleType("none")}
|
||||
>
|
||||
{t("settings.edit.personalDetails.vehicleTypeNone")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={toggleBtnClass(vehicleType === "car", "h-10 flex-1 text-xs")}
|
||||
onClick={() => setVehicleType("car")}
|
||||
>
|
||||
{t("settings.edit.personalDetails.vehicleTypeCar")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={toggleBtnClass(vehicleType === "motorcycle", "h-10 flex-1 text-xs")}
|
||||
onClick={() => setVehicleType("motorcycle")}
|
||||
>
|
||||
{t("settings.edit.personalDetails.vehicleTypeMotorcycle")}
|
||||
</button>
|
||||
</div>
|
||||
<RoundedSelect
|
||||
label={t("settings.edit.personalDetails.vehicleType")}
|
||||
placeholder={t("settings.edit.personalDetails.selectPlaceholder")}
|
||||
value={vehicleType ?? ""}
|
||||
options={[
|
||||
{ value: "none", label: t("settings.edit.personalDetails.vehicleTypeNone") },
|
||||
{ value: "car", label: t("settings.edit.personalDetails.vehicleTypeCar") },
|
||||
{ value: "motorcycle", label: t("settings.edit.personalDetails.vehicleTypeMotorcycle") },
|
||||
]}
|
||||
onChange={(v) => setVehicleType((v || null) as VehicleType)}
|
||||
className="mx-auto"
|
||||
/>
|
||||
{vehicleType && vehicleType !== "none" && (
|
||||
<RoundedInput
|
||||
className="mt-2"
|
||||
@@ -184,27 +288,14 @@ function PersonalDetailsPage() {
|
||||
</div>
|
||||
|
||||
{/* استایل */}
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-semibold">
|
||||
{t("settings.edit.personalDetails.personalStyle")}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={toggleBtnClass(personalStyle === "formal", "h-10 flex-1")}
|
||||
onClick={() => setPersonalStyle("formal")}
|
||||
>
|
||||
{t("settings.edit.personalDetails.personalStyleFormal")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={toggleBtnClass(personalStyle === "sporty", "h-10 flex-1")}
|
||||
onClick={() => setPersonalStyle("sporty")}
|
||||
>
|
||||
{t("settings.edit.personalDetails.personalStyleSporty")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<RoundedSelect
|
||||
label={t("settings.edit.personalDetails.personalStyle")}
|
||||
placeholder={t("settings.edit.personalDetails.selectPlaceholder")}
|
||||
value={personalStyle}
|
||||
options={personalStyleOptions}
|
||||
onChange={setPersonalStyle}
|
||||
className="mx-auto"
|
||||
/>
|
||||
|
||||
{/* شغل */}
|
||||
<div>
|
||||
@@ -220,56 +311,138 @@ function PersonalDetailsPage() {
|
||||
</div>
|
||||
|
||||
{/* درآمد */}
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-semibold">
|
||||
{t("settings.edit.personalDetails.incomeRange")}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={toggleBtnClass(incomeRange === "under_5m", "h-10 text-xs")}
|
||||
onClick={() => setIncomeRange("under_5m")}
|
||||
>
|
||||
{t("settings.edit.personalDetails.incomeUnder5m")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={toggleBtnClass(incomeRange === "5m_10m", "h-10 text-xs")}
|
||||
onClick={() => setIncomeRange("5m_10m")}
|
||||
>
|
||||
{t("settings.edit.personalDetails.income5m10m")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={toggleBtnClass(incomeRange === "10m_20m", "h-10 text-xs")}
|
||||
onClick={() => setIncomeRange("10m_20m")}
|
||||
>
|
||||
{t("settings.edit.personalDetails.income10m20m")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={toggleBtnClass(incomeRange === "over_20m", "h-10 text-xs")}
|
||||
onClick={() => setIncomeRange("over_20m")}
|
||||
>
|
||||
{t("settings.edit.personalDetails.incomeOver20m")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<RoundedSelect
|
||||
label={t("settings.edit.personalDetails.incomeRange")}
|
||||
placeholder={t("settings.edit.personalDetails.selectPlaceholder")}
|
||||
value={incomeRange ?? ""}
|
||||
options={incomeRangeOptions}
|
||||
onChange={(v) => setIncomeRange((v || null) as IncomeRange)}
|
||||
className="mx-auto"
|
||||
/>
|
||||
|
||||
{/* خصوصیات اخلاقی */}
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-semibold">
|
||||
{t("settings.edit.personalDetails.moralTraits")}
|
||||
</p>
|
||||
<textarea
|
||||
value={moralTraits}
|
||||
onChange={(e) => setMoralTraits(e.target.value)}
|
||||
placeholder={t("settings.edit.personalDetails.moralTraitsPlaceholder")}
|
||||
maxLength={1024}
|
||||
rows={4}
|
||||
className="w-full rounded-3xl border border-neutral-950 bg-white px-4 py-3 font-medium text-neutral-900 dark:border-neutral-400 dark:bg-neutral-950 dark:text-neutral-50"
|
||||
/>
|
||||
</div>
|
||||
<div className="border-t border-neutral-200 pt-6 dark:border-neutral-700" />
|
||||
|
||||
{/* هدف از رابطه */}
|
||||
<MultiSelectDropdown
|
||||
label={t("settings.edit.personalDetails.relationshipGoal")}
|
||||
placeholder={t("settings.edit.personalDetails.selectPlaceholder")}
|
||||
value={relationshipGoal}
|
||||
options={relationshipGoalOptions}
|
||||
onChange={setRelationshipGoal}
|
||||
formatSelectedSummary={selectedCountLabel}
|
||||
className="mx-auto"
|
||||
/>
|
||||
|
||||
{/* جنسیت (فقطخواندنی، از ثبتنام) */}
|
||||
<ReadOnlyValueRow
|
||||
label={t("auth.genderTitle")}
|
||||
value={
|
||||
user?.gender === "female"
|
||||
? t("auth.female")
|
||||
: user?.gender === "male"
|
||||
? t("auth.male")
|
||||
: null
|
||||
}
|
||||
href="/settings/edit/gender"
|
||||
/>
|
||||
|
||||
{/* سن (فقطخواندنی، از تاریخ تولد ثبتشده) */}
|
||||
<ReadOnlyValueRow
|
||||
label={t("settings.edit.personalDetails.age")}
|
||||
value={
|
||||
ageFromBirthday(user?.birthday) != null
|
||||
? t("settings.edit.personalDetails.ageYears", {
|
||||
age: ageFromBirthday(user?.birthday),
|
||||
})
|
||||
: null
|
||||
}
|
||||
href="/settings/edit/birthday"
|
||||
/>
|
||||
|
||||
{/* ویژگیهای شخصیتی */}
|
||||
<MultiSelectDropdown
|
||||
label={t("settings.edit.personalDetails.personalityTraits")}
|
||||
placeholder={t("settings.edit.personalDetails.selectPlaceholder")}
|
||||
value={personalityTraits}
|
||||
options={personalityTraitsOptions}
|
||||
onChange={setPersonalityTraits}
|
||||
formatSelectedSummary={selectedCountLabel}
|
||||
className="mx-auto"
|
||||
/>
|
||||
|
||||
{/* سبک زندگی */}
|
||||
<MultiSelectDropdown
|
||||
label={t("settings.edit.personalDetails.lifestyle")}
|
||||
placeholder={t("settings.edit.personalDetails.selectPlaceholder")}
|
||||
value={lifestyle}
|
||||
options={lifestyleOptions}
|
||||
onChange={setLifestyle}
|
||||
formatSelectedSummary={selectedCountLabel}
|
||||
className="mx-auto"
|
||||
/>
|
||||
|
||||
{/* علاقهمندیها */}
|
||||
<MultiSelectDropdown
|
||||
label={t("settings.edit.personalDetails.interests")}
|
||||
placeholder={t("settings.edit.personalDetails.selectPlaceholder")}
|
||||
value={interests}
|
||||
options={interestsOptions}
|
||||
onChange={setInterests}
|
||||
formatSelectedSummary={selectedCountLabel}
|
||||
className="mx-auto"
|
||||
/>
|
||||
|
||||
{/* ارزشها */}
|
||||
<MultiSelectDropdown
|
||||
label={t("settings.edit.personalDetails.values")}
|
||||
placeholder={t("settings.edit.personalDetails.selectPlaceholder")}
|
||||
value={values}
|
||||
options={valuesOptions}
|
||||
onChange={setValues}
|
||||
formatSelectedSummary={selectedCountLabel}
|
||||
className="mx-auto"
|
||||
/>
|
||||
|
||||
{/* دیدگاه نسبت به رابطه */}
|
||||
<RoundedSelect
|
||||
label={t("settings.edit.personalDetails.relationshipView")}
|
||||
placeholder={t("settings.edit.personalDetails.selectPlaceholder")}
|
||||
value={relationshipView}
|
||||
options={relationshipViewOptions}
|
||||
onChange={(v) => setRelationshipView(v as RelationshipView)}
|
||||
className="mx-auto"
|
||||
/>
|
||||
|
||||
{/* محدوده جغرافیایی مورد نظر */}
|
||||
<RoundedSelect
|
||||
label={t("settings.edit.personalDetails.geoPreference")}
|
||||
placeholder={t("settings.edit.personalDetails.selectPlaceholder")}
|
||||
value={geoPreference}
|
||||
options={geoPreferenceOptions}
|
||||
onChange={(v) => setGeoPreference(v as GeoPreference)}
|
||||
className="mx-auto"
|
||||
/>
|
||||
|
||||
{/* وضعیت فرزند */}
|
||||
<MultiSelectDropdown
|
||||
label={t("settings.edit.personalDetails.childrenStatus")}
|
||||
placeholder={t("settings.edit.personalDetails.selectPlaceholder")}
|
||||
value={childrenStatus}
|
||||
options={childrenStatusOptions}
|
||||
onChange={setChildrenStatus}
|
||||
formatSelectedSummary={selectedCountLabel}
|
||||
className="mx-auto"
|
||||
/>
|
||||
|
||||
{/* وضعیت مصرف دخانیات */}
|
||||
<RoundedSelect
|
||||
label={t("settings.edit.personalDetails.smokingStatus")}
|
||||
placeholder={t("settings.edit.personalDetails.selectPlaceholder")}
|
||||
value={smokingStatus}
|
||||
options={smokingStatusOptions}
|
||||
onChange={(v) => setSmokingStatus(v as SmokingStatus)}
|
||||
className="mx-auto"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AuthNextButton
|
||||
|
||||
132
src/app/settings/edit/register-mobile/page.tsx
Normal file
132
src/app/settings/edit/register-mobile/page.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import AuthOtpInput from "@/components/auth/AuthOtpInput";
|
||||
import AuthOtpStatus from "@/components/auth/AuthOtpStatus";
|
||||
import Container from "@/components/elements/Container";
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import RoundedInput from "@/components/elements/RoundedInput";
|
||||
import PageTitle from "@/components/settings/PageTitle";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { markOtpSent } from "@/lib/auth/otpTimer";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function RegisterMobilePage() {
|
||||
const { t } = useTranslation("common");
|
||||
const { request } = useAxios();
|
||||
const [step, setStep] = useState<"mobile" | "otp">("mobile");
|
||||
const [mobile, setMobile] = useState("");
|
||||
const [otp, setOtp] = useState("");
|
||||
const [otpError, setOtpError] = useState(false);
|
||||
const [otpExpired, setOtpExpired] = useState(false);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [verifying, setVerifying] = useState(false);
|
||||
const [resendLoading, setResendLoading] = useState(false);
|
||||
|
||||
const sendOtp = async () => {
|
||||
const cleaned = mobile.replace(/\D/g, "");
|
||||
if (!/^09[0-9]{9}$/.test(cleaned)) {
|
||||
toast.error(t("auth.invalidMobile"));
|
||||
return;
|
||||
}
|
||||
setSending(true);
|
||||
try {
|
||||
await request("POST", "/account/mobile/request", { mobile: cleaned });
|
||||
markOtpSent();
|
||||
setStep("otp");
|
||||
} catch {
|
||||
// toast از interceptor
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const verify = async () => {
|
||||
if (otp.length !== 6) return;
|
||||
setVerifying(true);
|
||||
try {
|
||||
await request("POST", "/account/mobile/verify", { otp });
|
||||
toast.success(t("settings.edit.registerMobile.success"));
|
||||
// نه router.push — صفحهی edit با useUser کششده هنوز mobile رو خالی نشون
|
||||
// میداد؛ یه ناوبریِ کامل تضمین میکنه که آیتم از لیست درست حذف بشه
|
||||
window.location.href = "/settings/edit";
|
||||
} catch {
|
||||
setOtpError(true);
|
||||
} finally {
|
||||
setVerifying(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<PageTitle>{t("settings.edit.nav.registerMobile")}</PageTitle>
|
||||
<div className="mt-6 flex flex-col items-center gap-4 text-sm">
|
||||
{step === "mobile" ? (
|
||||
<>
|
||||
<p className="text-center text-neutral-500">
|
||||
{t("settings.edit.registerMobile.hint")}
|
||||
</p>
|
||||
<RoundedInput
|
||||
value={mobile}
|
||||
onChange={(e) => setMobile(e.target.value)}
|
||||
placeholder="09xxxxxxxxx"
|
||||
dir="ltr"
|
||||
className="text-center"
|
||||
maxLength={11}
|
||||
/>
|
||||
<RoundedButton
|
||||
variant="primary"
|
||||
className="h-11 w-full"
|
||||
disabled={sending}
|
||||
onClick={sendOtp}
|
||||
>
|
||||
{sending ? t("common.loading") : t("auth.sendCode")}
|
||||
</RoundedButton>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AuthOtpStatus
|
||||
mobile={mobile.replace(/\D/g, "")}
|
||||
resendLoading={resendLoading}
|
||||
onExpiredChange={setOtpExpired}
|
||||
onResend={async () => {
|
||||
setResendLoading(true);
|
||||
try {
|
||||
await request("POST", "/account/mobile/request", {
|
||||
mobile: mobile.replace(/\D/g, ""),
|
||||
});
|
||||
setOtp("");
|
||||
setOtpError(false);
|
||||
} finally {
|
||||
setResendLoading(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<AuthOtpInput
|
||||
value={otp}
|
||||
onChange={(v) => {
|
||||
setOtp(v);
|
||||
setOtpError(false);
|
||||
}}
|
||||
onComplete={() => setTimeout(() => void verify(), 400)}
|
||||
error={otpError}
|
||||
disabled={verifying || otpExpired}
|
||||
/>
|
||||
<RoundedButton
|
||||
variant="primary"
|
||||
className="mt-3 h-11 w-full"
|
||||
disabled={verifying || otp.length !== 6}
|
||||
onClick={() => void verify()}
|
||||
>
|
||||
{verifying ? t("common.loading") : t("auth.verifyCode")}
|
||||
</RoundedButton>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
@@ -78,10 +78,10 @@ function Sizes() {
|
||||
<>
|
||||
<div className="flex gap-2 mt-4 w-full max-w-[320px]">
|
||||
<button
|
||||
className={`p-1 rounded-full w-1/3 border text-sm flex items-center justify-center gap-1 ${
|
||||
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)}
|
||||
>
|
||||
@@ -89,7 +89,7 @@ function Sizes() {
|
||||
{t("settings.edit.sizes.height")}
|
||||
</button>
|
||||
<button
|
||||
className={`p-1 rounded-full w-1/3 border text-sm flex items-center justify-center gap-1 ${
|
||||
className={`p-1 rounded-2xl w-1/3 border text-sm flex items-center justify-center gap-1 ${
|
||||
selectedButton === 2
|
||||
? "btn-modern--selected text-white"
|
||||
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
|
||||
@@ -100,7 +100,7 @@ function Sizes() {
|
||||
{t("settings.edit.sizes.weight")}
|
||||
</button>
|
||||
<button
|
||||
className={`p-1 rounded-full w-1/3 border text-sm flex items-center justify-center gap-1 ${
|
||||
className={`p-1 rounded-2xl w-1/3 border text-sm flex items-center justify-center gap-1 ${
|
||||
selectedButton === 3
|
||||
? "btn-modern--selected text-white"
|
||||
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
|
||||
@@ -136,7 +136,7 @@ function Sizes() {
|
||||
{sizes.map((sizeOption) => (
|
||||
<button
|
||||
key={sizeOption}
|
||||
className={`p-2 py-1 rounded-full border text-sm full ${
|
||||
className={`p-2 py-1 rounded-2xl border text-sm full ${
|
||||
size === sizeOption
|
||||
? "btn-modern--selected text-white"
|
||||
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
|
||||
|
||||
@@ -29,8 +29,12 @@ export default function Financial() {
|
||||
limit: 15,
|
||||
});
|
||||
|
||||
// قبلاً flatMap روی خودِ page.financial (یه آبجکت {docs, totalPages}، نه آرایه) اجرا
|
||||
// میشد، پس هر صفحه فقط به یه آیتم تبدیل میشد و allTransactions[0]?.docs همیشه فقط
|
||||
// صفحهی اول رو میداد — اسکرول به پایین صفحههای بعدی رو دانلود میکرد ولی هیچوقت
|
||||
// نمایش نمیداد
|
||||
const allTransactions =
|
||||
data?.pages.flatMap((page) => page.financial || []) ?? [];
|
||||
data?.pages.flatMap((page) => page.financial?.docs ?? []) ?? [];
|
||||
|
||||
const getTransactionType = (type: string) => {
|
||||
const known = [
|
||||
@@ -98,38 +102,38 @@ export default function Financial() {
|
||||
ref={containerRef}
|
||||
className="space-y-4 max-h-[65vh] overflow-y-auto scrollbar-thin scrollbar-thumb-neutral-300 dark:scrollbar-thumb-neutral-600"
|
||||
>
|
||||
{allTransactions[0]?.docs?.map((item: IFinancial) => (
|
||||
{allTransactions.map((item: IFinancial) => (
|
||||
<div
|
||||
key={item._id}
|
||||
className="p-5 border rounded-3xl border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-800 shadow-sm hover:shadow-md transition-all duration-200"
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-3">
|
||||
<div className="font-medium text-base md:text-lg text-neutral-900 dark:text-white flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 mb-3">
|
||||
<div className="min-w-0 font-medium text-base md:text-lg text-neutral-900 dark:text-white flex items-center gap-2">
|
||||
{getTransactionType(item.type)}
|
||||
{item.project_title && (
|
||||
<span className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
<span className="truncate text-sm text-neutral-500 dark:text-neutral-400">
|
||||
({item.project_title})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-neutral-500 dark:text-neutral-400 whitespace-nowrap">
|
||||
<div className="shrink-0 text-sm text-neutral-500 dark:text-neutral-400 whitespace-nowrap">
|
||||
{formatDate(item.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4">
|
||||
<div className="font-semibold text-lg text-neutral-900 dark:text-white">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="min-w-0 font-semibold text-lg text-neutral-900 dark:text-white">
|
||||
{t("settings.financial.amountPaid")}:{" "}
|
||||
{Number(item.amount).toLocaleString(amountLocale)}{" "}
|
||||
{t("settings.toman")}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span className="text-neutral-600 dark:text-neutral-300">
|
||||
{t("settings.status")}:
|
||||
</span>
|
||||
<span
|
||||
className={`font-medium px-4 py-1.5 rounded-full text-sm ${getStatusClass(
|
||||
className={`font-medium px-4 py-1.5 rounded-full text-sm whitespace-nowrap ${getStatusClass(
|
||||
item.status
|
||||
)}`}
|
||||
>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
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";
|
||||
@@ -30,6 +33,8 @@ function MyBillboard() {
|
||||
const { t } = useTranslation("common");
|
||||
const router = useRouter();
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
const unpaidOnly = searchParams.get("filter") === "unpaid";
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
const [showFilterModal, setShowFilterModal] = useState<boolean>(false);
|
||||
@@ -97,13 +102,17 @@ 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}>
|
||||
{page?.advertisings?.map((item: IAdvertising) => (
|
||||
{page?.advertisings
|
||||
?.filter((item: IAdvertising) => !unpaidOnly || (item as { effective_status?: string })?.effective_status === "pending_payment")
|
||||
.map((item: IAdvertising) => (
|
||||
<div
|
||||
className="cursor-pointer"
|
||||
onClick={() =>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -13,6 +13,7 @@ import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAppLanguage } from "@/contexts/LanguageProvider";
|
||||
import { isAppLanguage, type AppLanguage } from "@/lib/i18n/constants";
|
||||
import { isSubUserSession } from "@/lib/auth/session";
|
||||
// زبانهایی که آیتمهای آنالیتیکس/کاربران مسدود/بایگانی/فعالیت شما فقط توی اونا نمایش داده میشن
|
||||
const SETTINGS_ITEMS_VISIBLE_LANGUAGES: AppLanguage[] = ["fa", "ar", "en"];
|
||||
|
||||
@@ -215,6 +216,17 @@ export default function UserSettingsPage() {
|
||||
<span className="text-neutral-400">›</span>
|
||||
</Link>
|
||||
|
||||
{/* «مدیریت کاربران» فقط مالِ خودِ صاحبِ اکانته، نه کاربرِ زیرمجموعه */}
|
||||
{!isSubUserSession() && (
|
||||
<Link
|
||||
href="/settings/sub-users"
|
||||
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.subUsers")}</span>
|
||||
<span className="text-neutral-400">›</span>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<Link
|
||||
href="/settings/booking"
|
||||
className="flex items-center justify-between rounded-2xl border border-neutral-200 px-4 py-3 dark:border-neutral-800"
|
||||
@@ -223,6 +235,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) ? (
|
||||
@@ -407,6 +443,7 @@ export default function UserSettingsPage() {
|
||||
isOpen={resetConfirmOpen}
|
||||
onClose={() => setResetConfirmOpen(false)}
|
||||
height="fit"
|
||||
elevated
|
||||
>
|
||||
<div className="flex flex-col items-center gap-4 py-2 text-center">
|
||||
<p className="font-bold">{t("settings.resetAlgorithm")}</p>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user