Compare commits
33 Commits
9d48c0534e
...
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 |
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "modstagram-next-dev",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"port": 3000
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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": [
|
||||
|
||||
@@ -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));
|
||||
@@ -45,6 +56,17 @@ self.addEventListener("notificationclick", (event) => {
|
||||
|
||||
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",
|
||||
@@ -124,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;
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,26 +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({
|
||||
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 [course] = useState(MOCK_COURSE);
|
||||
const [walletBalance, setWalletBalance] = useState(0);
|
||||
const [showWalletPrompt, setShowWalletPrompt] = useState(false);
|
||||
|
||||
const pathname = usePathname();
|
||||
const match = pathname.match(/\/academy\/([^\/]+)/);
|
||||
@@ -116,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);
|
||||
@@ -126,7 +157,7 @@ export default function CourseDetail({
|
||||
const [sortBy, setSortBy] = useState<string>("createdAt");
|
||||
const [sortOrder, setSortOrder] = useState<string>("desc");
|
||||
const [usersCache, setUsersCache] = useState<Record<string, any>>({});
|
||||
const [videoFields, setVideoFields] = useState<MediaFile[]>([]);
|
||||
const [videoFields, setVideoFields] = useState<CourseVideo[]>([]);
|
||||
|
||||
const [rating, setRating] = useState<number>(0);
|
||||
|
||||
@@ -138,11 +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])
|
||||
@@ -158,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 در مسیر
|
||||
);
|
||||
@@ -187,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 }));
|
||||
@@ -204,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`
|
||||
);
|
||||
@@ -223,15 +254,15 @@ export default function CourseDetail({
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await request(
|
||||
const response = await request<ApiResponse>(
|
||||
"GET",
|
||||
`/academy/course/${_id}/comments?page=${pageNum}&limit=20&status=${statusFilter}&sortBy=${sortBy}&sortOrder=${sortOrder}`
|
||||
);
|
||||
|
||||
let sum = 0;
|
||||
let count = 0;
|
||||
|
||||
response.data.stats.rateDistribution.forEach(element => {
|
||||
|
||||
response.data.stats.rateDistribution.forEach((element: number) => {
|
||||
sum += Number(element);
|
||||
count++;
|
||||
});
|
||||
@@ -242,7 +273,7 @@ export default function CourseDetail({
|
||||
setRating(roundedRating);
|
||||
|
||||
if (response?.success) {
|
||||
let newComments = response.data.comments;
|
||||
let newComments: any[] = response.data.comments;
|
||||
const pagination = response.data.pagination;
|
||||
|
||||
if (newComments && newComments.length > 0) {
|
||||
@@ -340,7 +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}`
|
||||
);
|
||||
@@ -387,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
|
||||
@@ -435,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);
|
||||
@@ -447,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 ||
|
||||
@@ -475,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]) => {
|
||||
@@ -589,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;
|
||||
@@ -754,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"
|
||||
@@ -768,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 && (
|
||||
@@ -853,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">
|
||||
@@ -871,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>
|
||||
)}
|
||||
@@ -1044,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>
|
||||
|
||||
@@ -12,7 +12,7 @@ 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";
|
||||
@@ -70,9 +70,6 @@ function SpecialistCard({
|
||||
const media = user.previewPost
|
||||
? getPostMedia(user.previewPost as Post)
|
||||
: null;
|
||||
const profileUrl = user.profile_image
|
||||
? buildStorageUrl(user.profile_image)
|
||||
: null;
|
||||
const isMain = variant === "main";
|
||||
const profileHref = user.user_name ? `/users/${user.user_name}` : "#";
|
||||
|
||||
@@ -97,7 +94,6 @@ function SpecialistCard({
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes={isMain ? "280px" : "120px"}
|
||||
unoptimized
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
@@ -131,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 ? (
|
||||
|
||||
@@ -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;
|
||||
@@ -641,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 {
|
||||
@@ -712,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 */
|
||||
|
||||
@@ -749,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 {
|
||||
@@ -776,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,121 +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",
|
||||
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 />
|
||||
<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,6 +5,8 @@
|
||||
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";
|
||||
@@ -62,6 +64,7 @@ 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();
|
||||
@@ -303,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;
|
||||
}
|
||||
|
||||
@@ -733,6 +746,7 @@ export default function NewPostPage() {
|
||||
onShopsChange={setSelectedShops}
|
||||
onShopProductsChange={setSelectedShopProducts}
|
||||
onBookingChange={setSelectedBooking}
|
||||
taggedUserIds={taggedUsers.map((u) => u._id)}
|
||||
/>
|
||||
|
||||
<div className="mt-4">
|
||||
|
||||
25
src/app/not-found.tsx
Normal file
25
src/app/not-found.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import Link from "next/link";
|
||||
import Container from "@/components/elements/Container";
|
||||
import { getServerLanguage } from "@/lib/i18n/server";
|
||||
import { translateCommon } from "@/lib/i18n/translate";
|
||||
|
||||
export default async function NotFound() {
|
||||
const lang = await getServerLanguage();
|
||||
|
||||
return (
|
||||
<Container className="flex min-h-[70vh] flex-col items-center justify-center gap-4 py-20 text-center">
|
||||
<h1 className="text-2xl font-bold text-foreground">
|
||||
{translateCommon(lang, "notFoundPage.title")}
|
||||
</h1>
|
||||
<p className="max-w-sm text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{translateCommon(lang, "notFoundPage.description")}
|
||||
</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="mt-2 rounded-full bg-primary px-6 py-2 text-sm font-medium text-white"
|
||||
>
|
||||
{translateCommon(lang, "notFoundPage.backHome")}
|
||||
</Link>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -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,17 +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,
|
||||
buildPostJsonLd,
|
||||
buildPostSeoDescription,
|
||||
buildPostSeoTitle,
|
||||
} from "@/lib/postSlug";
|
||||
import { safeJsonLdString } from "@/lib/seo/safeJsonLd";
|
||||
import { getSiteSeoMeta } from "@/lib/i18n/seo";
|
||||
import { getServerLanguage } from "@/lib/i18n/server";
|
||||
import { translateCommon } from "@/lib/i18n/translate";
|
||||
|
||||
// Deduped per request: generateMetadata() and the page body's JSON-LD both need
|
||||
// this post — cache() avoids firing the fetch to the backend twice.
|
||||
const loadPost = cache((id: string) => fetchPostById(id));
|
||||
|
||||
type Props = {
|
||||
params: Promise<{ id: string; slug?: string[] }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
@@ -50,7 +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");
|
||||
@@ -84,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,
|
||||
});
|
||||
}
|
||||
@@ -93,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";
|
||||
|
||||
@@ -14,6 +14,14 @@ import { useTranslation } from "react-i18next";
|
||||
import BackButton from "@/components/ui/BackButton";
|
||||
import Link from "next/link";
|
||||
|
||||
/** این اندپوینت ساختارهای مختلفی برمیگردونه — یه تایپِ عمومیِ صریح که همون
|
||||
* فیلدهایی که کد واقعاً استفاده میکنه رو پوشش میده (بجای {} ضمنی) */
|
||||
type ApiResponse = {
|
||||
success?: boolean;
|
||||
data?: any;
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
interface PaginationData {
|
||||
page: number;
|
||||
limit: number;
|
||||
@@ -41,7 +49,7 @@ export default function PurchasedCoursesPage() {
|
||||
const fetchCourses = async (page: number) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await request(
|
||||
const response = await request<ApiResponse>(
|
||||
"GET",
|
||||
`/academy/course/getUserPurchasedCoursesWithPopulate?page=${page}&limit=6`
|
||||
);
|
||||
|
||||
@@ -59,6 +59,13 @@ interface Payment {
|
||||
};
|
||||
}
|
||||
|
||||
/** این اندپوینت ساختارهای مختلفی برمیگردونه — یه تایپِ عمومیِ صریح که همون
|
||||
* فیلدهایی که کد واقعاً استفاده میکنه رو پوشش میده (بجای {} ضمنی) */
|
||||
type ApiResponse = {
|
||||
data?: { payments?: Payment[]; [key: string]: any };
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
interface Transaction {
|
||||
id: string;
|
||||
type: "deposit" | "withdraw" | "expense";
|
||||
@@ -86,7 +93,7 @@ const WalletDashboard = () => {
|
||||
// دریافت پرداختهای موفق (فروشهای انجام شده)
|
||||
const fetchSuccessfulPayments = async () => {
|
||||
try {
|
||||
const response = await request(
|
||||
const response = await request<ApiResponse>(
|
||||
"GET",
|
||||
`/academy/course/getAllAcademyPayments?status=success`
|
||||
);
|
||||
@@ -123,7 +130,7 @@ const WalletDashboard = () => {
|
||||
// دریافت پرداختهای تسویه شده
|
||||
const fetchSettledPayments = async () => {
|
||||
try {
|
||||
const response = await request(
|
||||
const response = await request<ApiResponse>(
|
||||
"GET",
|
||||
`/academy/course/getAllAcademyPayments?status=settled`
|
||||
);
|
||||
|
||||
135
src/app/settings/ads/[id]/page.tsx
Normal file
135
src/app/settings/ads/[id]/page.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import PageTitle from "@/components/settings/PageTitle";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type SponsoredContent = { title: string; image?: string | null };
|
||||
|
||||
type AdDetail = {
|
||||
_id: string;
|
||||
ad_type: "first_post" | "first_story" | "notification" | "project" | "product" | "shop" | "billboard";
|
||||
effective_status: "pending_payment" | "active" | "expired" | "failed";
|
||||
price: number;
|
||||
views_count: number;
|
||||
clicks_count: number;
|
||||
notification_title?: string;
|
||||
notification_body?: string;
|
||||
target_gender: "female" | "male" | "any";
|
||||
target_expertise?: string | null;
|
||||
target_sub_expertise?: string[];
|
||||
started_at?: string;
|
||||
expires_at?: string;
|
||||
};
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
pending_payment: "text-amber-600",
|
||||
active: "text-green-600",
|
||||
expired: "text-neutral-400",
|
||||
failed: "text-red-500",
|
||||
};
|
||||
|
||||
export default function AdDetailPage() {
|
||||
const { t } = useTranslation("common");
|
||||
const { request } = useAxios();
|
||||
const params = useParams<{ id: string }>();
|
||||
const [ad, setAd] = useState<AdDetail | null>(null);
|
||||
const [content, setContent] = useState<SponsoredContent | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!params?.id) return;
|
||||
request<{ ad?: AdDetail; content?: SponsoredContent }>("GET", `/ads/${params.id}`, null, {
|
||||
noToast: true,
|
||||
})
|
||||
.then((res) => {
|
||||
setAd(res?.ad ?? null);
|
||||
setContent(res?.content ?? null);
|
||||
})
|
||||
.catch(() => setAd(null));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [params?.id]);
|
||||
|
||||
const isLinkedContentType = ad
|
||||
? (["project", "product", "shop", "billboard"] as const).includes(
|
||||
ad.ad_type as "project" | "product" | "shop" | "billboard"
|
||||
)
|
||||
: false;
|
||||
|
||||
const ctr =
|
||||
ad && ad.views_count > 0 ? Math.round((ad.clicks_count / ad.views_count) * 1000) / 10 : 0;
|
||||
|
||||
return (
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<PageTitle>{t("ads.detailTitle")}</PageTitle>
|
||||
|
||||
{!ad ? (
|
||||
<p className="py-10 text-center text-gray-500">{t("common.loading")}</p>
|
||||
) : (
|
||||
<div className="mt-6 space-y-4 text-xs md:text-sm">
|
||||
<div className="flex items-center justify-between rounded-2xl border border-neutral-200 px-4 py-3 dark:border-neutral-800">
|
||||
<span className="font-semibold">{t(`ads.type.${ad.ad_type}`)}</span>
|
||||
<span className={`font-semibold ${STATUS_COLOR[ad.effective_status]}`}>
|
||||
{t(`ads.status.${ad.effective_status}`)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{ad.ad_type === "notification" && (
|
||||
<div className="rounded-2xl border border-neutral-200 px-4 py-3 dark:border-neutral-800">
|
||||
<p className="font-semibold">{ad.notification_title}</p>
|
||||
<p className="mt-1 text-neutral-500">{ad.notification_body}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLinkedContentType && content && (
|
||||
<div className="flex items-center gap-3 rounded-2xl border border-neutral-200 px-4 py-3 dark:border-neutral-800">
|
||||
{content.image && (
|
||||
<div className="h-14 w-14 shrink-0 overflow-hidden rounded-xl bg-neutral-100 dark:bg-neutral-800">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={IMAGE_BASE_URL + content.image} alt="" className="h-full w-full object-cover" />
|
||||
</div>
|
||||
)}
|
||||
<span className="font-semibold">{content.title}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="rounded-2xl border border-neutral-200 px-3 py-4 text-center dark:border-neutral-800">
|
||||
<p className="text-lg font-bold">{ad.views_count}</p>
|
||||
<p className="text-neutral-500">{t("ads.viewsLabel")}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-neutral-200 px-3 py-4 text-center dark:border-neutral-800">
|
||||
<p className="text-lg font-bold">{ad.clicks_count}</p>
|
||||
<p className="text-neutral-500">{t("ads.clicksLabel")}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-neutral-200 px-3 py-4 text-center dark:border-neutral-800">
|
||||
<p className="text-lg font-bold">{ctr}%</p>
|
||||
<p className="text-neutral-500">{t("ads.ctrLabel")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-neutral-200 px-4 py-3 dark:border-neutral-800 space-y-1">
|
||||
<p className="font-semibold">{t("ads.targetingTitle")}</p>
|
||||
<p className="text-neutral-500">
|
||||
{t("ads.targetingGender")}:{" "}
|
||||
{ad.target_gender === "any" ? t("ads.genderAny") : t(`ads.gender.${ad.target_gender}`)}
|
||||
</p>
|
||||
<p className="text-neutral-500">
|
||||
{t("ads.targetingExpertise")}: {ad.target_expertise || t("ads.genderAny")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-neutral-400">
|
||||
{Number(ad.price).toLocaleString()} {t("common.toman")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
765
src/app/settings/ads/new/page.tsx
Normal file
765
src/app/settings/ads/new/page.tsx
Normal file
@@ -0,0 +1,765 @@
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import PageTitle from "@/components/settings/PageTitle";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import RoundedInput from "@/components/elements/RoundedInput";
|
||||
import CountryProvinceCitySelect, {
|
||||
type GeoSelection,
|
||||
} from "@/components/ui/CountryProvinceCitySelect";
|
||||
import WalletUsagePrompt from "@/components/payment/WalletUsagePrompt";
|
||||
import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useUser } from "@/hooks/useUser";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toggleBtnClass } from "@/lib/ui/buttonStyles";
|
||||
|
||||
type AdType =
|
||||
| "first_post"
|
||||
| "first_story"
|
||||
| "notification"
|
||||
| "project"
|
||||
| "product"
|
||||
| "shop"
|
||||
| "billboard";
|
||||
|
||||
type MyPost = {
|
||||
_id: string;
|
||||
post_images?: string[];
|
||||
post_image?: string;
|
||||
post_video?: string;
|
||||
type?: "image" | "video";
|
||||
files?: { path: string; type: "image" | "video" }[];
|
||||
caption?: string;
|
||||
};
|
||||
|
||||
type MyStory = {
|
||||
_id: string;
|
||||
media_path: string;
|
||||
media_type?: string;
|
||||
};
|
||||
|
||||
type Expertise = {
|
||||
_id: string;
|
||||
expertise: string;
|
||||
sub_expertise?: { _id: string; name: string }[];
|
||||
};
|
||||
|
||||
type MyProject = { _id: string; title?: string };
|
||||
type MyShop = { _id: string; name: string; logo?: string | null };
|
||||
type MyShopProduct = { _id: string; title: string; images?: string[]; primaryImageIndex?: number };
|
||||
type MyBillboard = { _id: string; title?: string; images?: string[] };
|
||||
|
||||
const STEPS = ["type", "content", "location", "expertise", "gender", "review"] as const;
|
||||
type Step = (typeof STEPS)[number];
|
||||
|
||||
/** تصویر بندانگشتی پست — post_images قدیمی + files جدید (و ویدیو) */
|
||||
function postThumb(post: MyPost): { src: string; isVideo: boolean } | null {
|
||||
// ۱) تصاویر قدیمی
|
||||
if (post.post_images?.[0]) {
|
||||
return { src: buildStorageUrl(post.post_images[0]), isVideo: false };
|
||||
}
|
||||
// ۲) فایلهای جدید — اول تصویر، بعد هر فایلی
|
||||
const imageFile = post.files?.find((f) => f.type === "image" && f.path);
|
||||
if (imageFile?.path) {
|
||||
return { src: buildStorageUrl(imageFile.path), isVideo: false };
|
||||
}
|
||||
const anyFile = post.files?.find((f) => f.path);
|
||||
if (anyFile?.path) {
|
||||
return {
|
||||
src: buildStorageUrl(anyFile.path),
|
||||
isVideo: anyFile.type === "video",
|
||||
};
|
||||
}
|
||||
// ۳) فیلدهای قدیمی تکی
|
||||
if (post.post_image) {
|
||||
return { src: buildStorageUrl(post.post_image), isVideo: false };
|
||||
}
|
||||
if (post.post_video) {
|
||||
return { src: buildStorageUrl(post.post_video), isVideo: true };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function NewAdPage() {
|
||||
const { t } = useTranslation("common");
|
||||
const { request } = useAxios();
|
||||
const router = useRouter();
|
||||
const user = useUser();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// از منوی ۳نقطهی ریلز («این پست رو آگهی کن») — نوع و پست از قبل معلومه، مستقیم
|
||||
// میره سراغ مرحلهی لوکیشن (index دوم توی STEPS)
|
||||
const presetPostId = searchParams.get("postId") || "";
|
||||
const presetAdType = searchParams.get("type") === "first_post" ? "first_post" : "";
|
||||
|
||||
const [stepIndex, setStepIndex] = useState(presetPostId ? 2 : 0);
|
||||
const step: Step = STEPS[stepIndex];
|
||||
|
||||
const [adType, setAdType] = useState<AdType | "">(presetAdType as AdType | "");
|
||||
const [selectedPostId, setSelectedPostId] = useState(presetPostId);
|
||||
const [selectedStoryId, setSelectedStoryId] = useState("");
|
||||
const [notifTitle, setNotifTitle] = useState("");
|
||||
const [notifBody, setNotifBody] = useState("");
|
||||
const [selectedProjectId, setSelectedProjectId] = useState("");
|
||||
const [selectedShopId, setSelectedShopId] = useState("");
|
||||
const [selectedBillboardId, setSelectedBillboardId] = useState("");
|
||||
const [productShopId, setProductShopId] = useState("");
|
||||
const [selectedShopProductId, setSelectedShopProductId] = useState("");
|
||||
|
||||
const [geo, setGeo] = useState<GeoSelection>({ countryId: null, provinceId: null, cityId: null });
|
||||
const [expertise, setExpertise] = useState("");
|
||||
const [subExpertise, setSubExpertise] = useState<string[]>([]);
|
||||
const [gender, setGender] = useState<"female" | "male" | "any">("any");
|
||||
|
||||
const [myPosts, setMyPosts] = useState<MyPost[] | null>(null);
|
||||
const [myStories, setMyStories] = useState<MyStory[] | null>(null);
|
||||
const [expertiseList, setExpertiseList] = useState<Expertise[]>([]);
|
||||
const [myProjects, setMyProjects] = useState<MyProject[] | null>(null);
|
||||
const [myShops, setMyShops] = useState<MyShop[] | null>(null);
|
||||
const [myBillboards, setMyBillboards] = useState<MyBillboard[] | null>(null);
|
||||
const [shopProducts, setShopProducts] = useState<MyShopProduct[] | null>(null);
|
||||
|
||||
const [price, setPrice] = useState<number | null>(null);
|
||||
const [priceLoading, setPriceLoading] = useState(false);
|
||||
const [walletBalance, setWalletBalance] = useState(0);
|
||||
const [showWalletPrompt, setShowWalletPrompt] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
request<{ charged?: { available?: number } }>("GET", "/wallet/summary", null, { noToast: true })
|
||||
.then((res) => setWalletBalance(res?.charged?.available || 0))
|
||||
.catch(() => setWalletBalance(0));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (adType === "first_post" && user?._id && myPosts === null) {
|
||||
request<{ posts?: MyPost[] }>("GET", `/posts/user-posts/web?user_id=${user._id}&page=1&limit=30`, null, {
|
||||
noToast: true,
|
||||
})
|
||||
.then((res) => setMyPosts(res?.posts ?? []))
|
||||
.catch(() => setMyPosts([]));
|
||||
}
|
||||
if (adType === "first_story" && myStories === null) {
|
||||
request<{ my_story?: { stories?: MyStory[] } }>("GET", "/stories/feed", null, { noToast: true })
|
||||
.then((res) => setMyStories(res?.my_story?.stories ?? []))
|
||||
.catch(() => setMyStories([]));
|
||||
}
|
||||
if (adType === "project" && user?._id && myProjects === null) {
|
||||
request<{ data?: { projects?: MyProject[] }; projects?: MyProject[] }>(
|
||||
"GET",
|
||||
`/projects/user-projects/web?user_id=${user._id}&page=1&limit=50&linkable=1`,
|
||||
null,
|
||||
{ noToast: true }
|
||||
)
|
||||
.then((res) => setMyProjects(res?.data?.projects ?? res?.projects ?? []))
|
||||
.catch(() => setMyProjects([]));
|
||||
}
|
||||
if ((adType === "shop" || adType === "product") && myShops === null) {
|
||||
request<{ shops?: MyShop[] }>("GET", "/shops/mine", null, { noToast: true })
|
||||
.then((res) => setMyShops(res?.shops ?? []))
|
||||
.catch(() => setMyShops([]));
|
||||
}
|
||||
if (adType === "billboard" && myBillboards === null) {
|
||||
request<{ advertisings?: MyBillboard[]; data?: { advertisings?: MyBillboard[] } }>(
|
||||
"GET",
|
||||
"/advertising/user-advertising?page=1&limit=50",
|
||||
null,
|
||||
{ noToast: true }
|
||||
)
|
||||
.then((res) => setMyBillboards(res?.advertisings ?? res?.data?.advertisings ?? []))
|
||||
.catch(() => setMyBillboards([]));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [adType, user?._id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!productShopId) return;
|
||||
setShopProducts(null);
|
||||
request<{ docs?: MyShopProduct[] }>("GET", `/shop-products?shopId=${productShopId}&limit=100`, null, {
|
||||
noToast: true,
|
||||
})
|
||||
.then((res) => setShopProducts(res?.docs ?? []))
|
||||
.catch(() => setShopProducts([]));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [productShopId]);
|
||||
|
||||
useEffect(() => {
|
||||
request<{ expertises?: Expertise[] }>("GET", "/expertise", null, { noToast: true })
|
||||
.then((res) => setExpertiseList(res?.expertises ?? []))
|
||||
.catch(() => setExpertiseList([]));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// قیمتِ زنده — با هر تغییری توی نوع/هدفگذاری دوباره از سرور میگیره (utils/adPricing.js)
|
||||
useEffect(() => {
|
||||
if (!adType) return;
|
||||
setPriceLoading(true);
|
||||
const params = new URLSearchParams({ ad_type: adType });
|
||||
if (geo.provinceId != null) params.set("target_province_id", String(geo.provinceId));
|
||||
if (gender !== "any") params.set("target_gender", gender);
|
||||
if (expertise) params.set("target_expertise", expertise);
|
||||
if (subExpertise.length) params.set("target_sub_expertise", subExpertise.join(","));
|
||||
request<{ price: number | null }>("GET", `/ads/pricing?${params.toString()}`, null, { noToast: true })
|
||||
.then((res) => setPrice(res?.price ?? null))
|
||||
.catch(() => setPrice(null))
|
||||
.finally(() => setPriceLoading(false));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [adType, geo.provinceId, gender, expertise, subExpertise]);
|
||||
|
||||
const canGoNext = useMemo(() => {
|
||||
if (step === "type") return Boolean(adType);
|
||||
if (step === "content") {
|
||||
if (adType === "first_post") return Boolean(selectedPostId);
|
||||
if (adType === "first_story") return Boolean(selectedStoryId);
|
||||
if (adType === "notification") return notifTitle.trim().length > 0 && notifBody.trim().length > 0;
|
||||
if (adType === "project") return Boolean(selectedProjectId);
|
||||
if (adType === "shop") return Boolean(selectedShopId);
|
||||
if (adType === "product") return Boolean(selectedShopProductId);
|
||||
if (adType === "billboard") return Boolean(selectedBillboardId);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}, [
|
||||
step,
|
||||
adType,
|
||||
selectedPostId,
|
||||
selectedStoryId,
|
||||
notifTitle,
|
||||
notifBody,
|
||||
selectedProjectId,
|
||||
selectedShopId,
|
||||
selectedShopProductId,
|
||||
selectedBillboardId,
|
||||
]);
|
||||
|
||||
const goNext = () => setStepIndex((i) => Math.min(i + 1, STEPS.length - 1));
|
||||
const goBack = () => setStepIndex((i) => Math.max(i - 1, 0));
|
||||
|
||||
const buildPayload = (useWallet: boolean) => ({
|
||||
ad_type: adType,
|
||||
content_post_id: adType === "first_post" ? selectedPostId : undefined,
|
||||
content_story_id: adType === "first_story" ? selectedStoryId : undefined,
|
||||
notification_title: adType === "notification" ? notifTitle.trim() : undefined,
|
||||
notification_body: adType === "notification" ? notifBody.trim() : undefined,
|
||||
content_project_id: adType === "project" ? selectedProjectId : undefined,
|
||||
content_shop_id: adType === "shop" ? selectedShopId : undefined,
|
||||
content_shop_product_id: adType === "product" ? selectedShopProductId : undefined,
|
||||
content_billboard_id: adType === "billboard" ? selectedBillboardId : undefined,
|
||||
target_country_id: geo.countryId ?? undefined,
|
||||
target_province_id: geo.provinceId ?? undefined,
|
||||
target_city_id: geo.cityId ?? undefined,
|
||||
target_expertise: expertise || undefined,
|
||||
target_sub_expertise: subExpertise,
|
||||
target_gender: gender,
|
||||
useWallet,
|
||||
});
|
||||
|
||||
const submit = async (useWallet: boolean) => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await request<{
|
||||
paidFromWallet?: boolean;
|
||||
paymentUrl?: string;
|
||||
authority?: string;
|
||||
}>("POST", "/ads", buildPayload(useWallet));
|
||||
|
||||
if (res?.paidFromWallet) {
|
||||
toast.success(t("wallet.paidFromWalletSuccess"));
|
||||
router.push("/settings/ads");
|
||||
return;
|
||||
}
|
||||
const payUrl = res?.paymentUrl || (res?.authority ? `https://www.zarinpal.com/pg/StartPay/${res.authority}` : null);
|
||||
if (payUrl) {
|
||||
window.location.href = payUrl;
|
||||
} else {
|
||||
toast.error(t("settings.paymentLinkNotReceived"));
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
|
||||
t("common.error");
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmitClick = () => {
|
||||
if (!price) {
|
||||
toast.error(t("ads.priceUnavailable"));
|
||||
return;
|
||||
}
|
||||
if (walletBalance > 0) {
|
||||
setShowWalletPrompt(true);
|
||||
return;
|
||||
}
|
||||
void submit(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<PageTitle>{t("ads.newTitle")}</PageTitle>
|
||||
|
||||
<div className="mt-4 space-y-6 text-xs md:text-sm">
|
||||
{step === "type" && (
|
||||
<div className="space-y-3">
|
||||
<p className="font-semibold">{t("ads.chooseTypeTitle")}</p>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{(
|
||||
// "shop" فعلاً کامنت شده — طبق خواستهی کاربر، صفحهی عمومیِ گشتوگذارِ
|
||||
// فروشگاهها وجود نداره، پس فعلاً فقط "product" (کالا) قابلانتخابه
|
||||
[
|
||||
"first_post",
|
||||
"first_story",
|
||||
"notification",
|
||||
"project",
|
||||
"product",
|
||||
// "shop",
|
||||
"billboard",
|
||||
] as AdType[]
|
||||
).map((type) => (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
onClick={() => setAdType(type)}
|
||||
className={toggleBtnClass(
|
||||
adType === type,
|
||||
"min-h-10 p-2 text-xs leading-tight text-center"
|
||||
)}
|
||||
>
|
||||
{t(`ads.type.${type}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "content" && adType === "first_post" && (
|
||||
<div className="space-y-3">
|
||||
<p className="font-semibold">{t("ads.choosePostTitle")}</p>
|
||||
{myPosts === null ? (
|
||||
<p className="text-neutral-500">{t("common.loading")}</p>
|
||||
) : myPosts.length === 0 ? (
|
||||
<p className="text-neutral-500">{t("ads.noPostsYet")}</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{myPosts.map((post) => {
|
||||
const thumb = postThumb(post);
|
||||
return (
|
||||
<button
|
||||
key={post._id}
|
||||
type="button"
|
||||
onClick={() => setSelectedPostId(post._id)}
|
||||
className={cn(
|
||||
"relative aspect-square overflow-hidden rounded-xl border-2 bg-neutral-100 dark:bg-neutral-800",
|
||||
selectedPostId === post._id ? "border-pink-500" : "border-transparent"
|
||||
)}
|
||||
>
|
||||
{thumb?.isVideo ? (
|
||||
<video
|
||||
src={thumb.src}
|
||||
className="h-full w-full object-cover"
|
||||
muted
|
||||
playsInline
|
||||
preload="metadata"
|
||||
/>
|
||||
) : thumb?.src ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={thumb.src}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "content" && adType === "first_story" && (
|
||||
<div className="space-y-3">
|
||||
<p className="font-semibold">{t("ads.chooseStoryTitle")}</p>
|
||||
{myStories === null ? (
|
||||
<p className="text-neutral-500">{t("common.loading")}</p>
|
||||
) : myStories.length === 0 ? (
|
||||
<p className="text-neutral-500">{t("ads.noStoriesYet")}</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{myStories.map((story) => (
|
||||
<button
|
||||
key={story._id}
|
||||
type="button"
|
||||
onClick={() => setSelectedStoryId(story._id)}
|
||||
className={cn(
|
||||
"aspect-[9/16] overflow-hidden rounded-xl border-2 bg-neutral-100 dark:bg-neutral-800",
|
||||
selectedStoryId === story._id ? "border-pink-500" : "border-transparent"
|
||||
)}
|
||||
>
|
||||
{story.media_type !== "video" ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={
|
||||
story.media_path.startsWith("http")
|
||||
? story.media_path
|
||||
: `https://api.modstagram.com${story.media_path}`
|
||||
}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "content" && adType === "notification" && (
|
||||
<div className="space-y-3">
|
||||
<p className="font-semibold">{t("ads.notificationContentTitle")}</p>
|
||||
<RoundedInput
|
||||
value={notifTitle}
|
||||
onChange={(e) => setNotifTitle(e.target.value)}
|
||||
placeholder={t("ads.notificationTitlePlaceholder")}
|
||||
/>
|
||||
<textarea
|
||||
value={notifBody}
|
||||
onChange={(e) => setNotifBody(e.target.value)}
|
||||
placeholder={t("ads.notificationBodyPlaceholder")}
|
||||
rows={3}
|
||||
className="w-full resize-none rounded-2xl border border-neutral-200 bg-transparent px-4 py-3 outline-none dark:border-neutral-700"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "content" && adType === "project" && (
|
||||
<div className="space-y-3">
|
||||
<p className="font-semibold">{t("ads.chooseProjectTitle")}</p>
|
||||
{myProjects === null ? (
|
||||
<p className="text-neutral-500">{t("common.loading")}</p>
|
||||
) : myProjects.length === 0 ? (
|
||||
<p className="text-neutral-500">{t("ads.noProjectsYet")}</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{myProjects.map((p) => (
|
||||
<button
|
||||
key={p._id}
|
||||
type="button"
|
||||
onClick={() => setSelectedProjectId(p._id)}
|
||||
className={cn(
|
||||
"w-full rounded-2xl border px-4 py-3 text-right",
|
||||
selectedProjectId === p._id
|
||||
? "border-pink-500 bg-pink-50 dark:bg-pink-950/30"
|
||||
: "border-neutral-200 dark:border-neutral-700"
|
||||
)}
|
||||
>
|
||||
{p.title || t("ads.untitled")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "content" && adType === "shop" && (
|
||||
<div className="space-y-3">
|
||||
<p className="font-semibold">{t("ads.chooseShopTitle")}</p>
|
||||
{myShops === null ? (
|
||||
<p className="text-neutral-500">{t("common.loading")}</p>
|
||||
) : myShops.length === 0 ? (
|
||||
<p className="text-neutral-500">{t("ads.noShopsYet")}</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{myShops.map((shop) => (
|
||||
<button
|
||||
key={shop._id}
|
||||
type="button"
|
||||
onClick={() => setSelectedShopId(shop._id)}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-1 rounded-xl border-2 p-2",
|
||||
selectedShopId === shop._id ? "border-pink-500" : "border-transparent"
|
||||
)}
|
||||
>
|
||||
<div className="h-16 w-16 overflow-hidden rounded-full bg-neutral-100 dark:bg-neutral-800">
|
||||
{shop.logo ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={IMAGE_BASE_URL + shop.logo} alt="" className="h-full w-full object-cover" />
|
||||
) : null}
|
||||
</div>
|
||||
<span className="line-clamp-1">{shop.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "content" && adType === "product" && (
|
||||
<div className="space-y-3">
|
||||
<p className="font-semibold">{t("ads.chooseShopTitle")}</p>
|
||||
{myShops === null ? (
|
||||
<p className="text-neutral-500">{t("common.loading")}</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{myShops.map((shop) => (
|
||||
<button
|
||||
key={shop._id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setProductShopId(shop._id);
|
||||
setSelectedShopProductId("");
|
||||
}}
|
||||
className={cn(
|
||||
"rounded-full border px-3 py-1.5",
|
||||
productShopId === shop._id
|
||||
? "border-pink-500 bg-pink-50 dark:bg-pink-950/30"
|
||||
: "border-neutral-200 dark:border-neutral-700"
|
||||
)}
|
||||
>
|
||||
{shop.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{productShopId && (
|
||||
<>
|
||||
<p className="font-semibold">{t("ads.chooseProductTitle")}</p>
|
||||
{shopProducts === null ? (
|
||||
<p className="text-neutral-500">{t("common.loading")}</p>
|
||||
) : shopProducts.length === 0 ? (
|
||||
<p className="text-neutral-500">{t("ads.noProductsYet")}</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{shopProducts.map((product) => {
|
||||
const img = product.images?.[product.primaryImageIndex ?? 0] ?? product.images?.[0];
|
||||
return (
|
||||
<button
|
||||
key={product._id}
|
||||
type="button"
|
||||
onClick={() => setSelectedShopProductId(product._id)}
|
||||
className={cn(
|
||||
"aspect-square overflow-hidden rounded-xl border-2 bg-neutral-100 dark:bg-neutral-800",
|
||||
selectedShopProductId === product._id ? "border-pink-500" : "border-transparent"
|
||||
)}
|
||||
>
|
||||
{img ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={IMAGE_BASE_URL + img} alt="" className="h-full w-full object-cover" />
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "content" && adType === "billboard" && (
|
||||
<div className="space-y-3">
|
||||
<p className="font-semibold">{t("ads.chooseBillboardTitle")}</p>
|
||||
{myBillboards === null ? (
|
||||
<p className="text-neutral-500">{t("common.loading")}</p>
|
||||
) : myBillboards.length === 0 ? (
|
||||
<p className="text-neutral-500">{t("ads.noBillboardsYet")}</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{myBillboards.map((b) => (
|
||||
<button
|
||||
key={b._id}
|
||||
type="button"
|
||||
onClick={() => setSelectedBillboardId(b._id)}
|
||||
className={cn(
|
||||
"w-full rounded-2xl border px-4 py-3 text-right",
|
||||
selectedBillboardId === b._id
|
||||
? "border-pink-500 bg-pink-50 dark:bg-pink-950/30"
|
||||
: "border-neutral-200 dark:border-neutral-700"
|
||||
)}
|
||||
>
|
||||
{b.title || t("ads.untitled")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "location" && (
|
||||
<div className="space-y-3">
|
||||
<p className="font-semibold">{t("ads.chooseLocationTitle")}</p>
|
||||
<CountryProvinceCitySelect value={geo} onChange={setGeo} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "expertise" && (
|
||||
<div className="space-y-3">
|
||||
<p className="font-semibold">{t("ads.chooseExpertiseTitle")}</p>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setExpertise("");
|
||||
setSubExpertise([]);
|
||||
}}
|
||||
className={toggleBtnClass(
|
||||
!expertise,
|
||||
"min-h-10 p-2 text-xs leading-tight text-center"
|
||||
)}
|
||||
>
|
||||
{t("ads.genderAny")}
|
||||
</button>
|
||||
{expertiseList.map((exp) => (
|
||||
<button
|
||||
key={exp._id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setExpertise(exp.expertise);
|
||||
setSubExpertise([]);
|
||||
}}
|
||||
className={toggleBtnClass(
|
||||
expertise === exp.expertise,
|
||||
"min-h-10 p-2 text-xs leading-tight text-center"
|
||||
)}
|
||||
>
|
||||
{exp.expertise}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{expertise &&
|
||||
(expertiseList.find((e) => e.expertise === expertise)?.sub_expertise?.length ?? 0) > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-neutral-500">{t("ads.chooseSubExpertiseTitle")}</p>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{(expertiseList.find((e) => e.expertise === expertise)?.sub_expertise ?? []).map(
|
||||
(sub) => (
|
||||
<button
|
||||
key={sub._id}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setSubExpertise((prev) =>
|
||||
prev.includes(sub.name)
|
||||
? prev.filter((s) => s !== sub.name)
|
||||
: [...prev, sub.name]
|
||||
)
|
||||
}
|
||||
className={toggleBtnClass(
|
||||
subExpertise.includes(sub.name),
|
||||
"min-h-10 p-2 text-xs leading-tight text-center"
|
||||
)}
|
||||
>
|
||||
{sub.name}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "gender" && (
|
||||
<div className="space-y-3">
|
||||
<p className="font-semibold">{t("ads.chooseGenderTitle")}</p>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{(["female", "male", "any"] as const).map((g) => (
|
||||
<button
|
||||
key={g}
|
||||
type="button"
|
||||
onClick={() => setGender(g)}
|
||||
className={toggleBtnClass(
|
||||
gender === g,
|
||||
"min-h-10 p-2 text-xs leading-tight text-center"
|
||||
)}
|
||||
>
|
||||
{g === "any" ? t("ads.genderAny") : t(`ads.gender.${g}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "review" && (
|
||||
<div className="space-y-4">
|
||||
<p className="font-semibold">{t("ads.reviewTitle")}</p>
|
||||
<div className="rounded-2xl border border-neutral-200 px-4 py-3 dark:border-neutral-700">
|
||||
<p>{t(`ads.type.${adType}`)}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-neutral-200 px-4 py-3 text-center dark:border-neutral-700">
|
||||
<p className="text-neutral-500">{t("ads.priceLabel")}</p>
|
||||
<p className="text-xl font-bold">
|
||||
{priceLoading
|
||||
? "..."
|
||||
: price != null
|
||||
? `${price.toLocaleString()} ${t("common.toman")}`
|
||||
: t("ads.priceUnavailable")}
|
||||
</p>
|
||||
</div>
|
||||
{/* dir=ltr تا در صفحه RTL هم قبلی سمت چپ فیزیکی و پرداخت سمت راست بماند */}
|
||||
<div className="flex items-center justify-between gap-3 pt-2" dir="ltr">
|
||||
<RoundedButton
|
||||
className="h-11 w-28 shrink-0"
|
||||
disabled={stepIndex === 0}
|
||||
onClick={goBack}
|
||||
>
|
||||
{t("ads.backAction")}
|
||||
</RoundedButton>
|
||||
<RoundedButton
|
||||
variant="primary"
|
||||
className="h-11 min-w-0 flex-1"
|
||||
disabled={submitting || !price}
|
||||
onClick={handleSubmitClick}
|
||||
>
|
||||
{submitting ? t("common.loading") : t("ads.payAndPublish")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step !== "review" && (
|
||||
<div className="flex justify-between pt-4">
|
||||
<RoundedButton
|
||||
variant="primary"
|
||||
className="h-10 w-28"
|
||||
disabled={!canGoNext}
|
||||
onClick={goNext}
|
||||
>
|
||||
{t("ads.nextAction")}
|
||||
</RoundedButton>
|
||||
<RoundedButton
|
||||
className="h-10 w-28"
|
||||
disabled={stepIndex === 0}
|
||||
onClick={goBack}
|
||||
>
|
||||
{t("ads.backAction")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
<WalletUsagePrompt
|
||||
isOpen={showWalletPrompt}
|
||||
amount={price ?? 0}
|
||||
walletBalance={walletBalance}
|
||||
onAnswer={(useWallet) => {
|
||||
setShowWalletPrompt(false);
|
||||
void submit(useWallet);
|
||||
}}
|
||||
/>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
97
src/app/settings/ads/page.tsx
Normal file
97
src/app/settings/ads/page.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import PageTitle from "@/components/settings/PageTitle";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import EmptyState from "@/components/elements/EmptyState";
|
||||
import { staticIconUrl } from "@/components/main/BaseUrl";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type AdRow = {
|
||||
_id: string;
|
||||
ad_type: "first_post" | "first_story" | "notification" | "project" | "product" | "shop" | "billboard";
|
||||
effective_status: "pending_payment" | "active" | "expired" | "failed";
|
||||
price: number;
|
||||
views_count: number;
|
||||
clicks_count: number;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
pending_payment: "text-amber-600",
|
||||
active: "text-green-600",
|
||||
expired: "text-neutral-400",
|
||||
failed: "text-red-500",
|
||||
};
|
||||
|
||||
export default function AdsSettingsPage() {
|
||||
const { t } = useTranslation("common");
|
||||
const { request } = useAxios();
|
||||
const [ads, setAds] = useState<AdRow[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
request<{ ads?: AdRow[] }>("GET", "/ads/mine", null, { noToast: true })
|
||||
.then((res) => setAds(res?.ads ?? []))
|
||||
.catch(() => setAds([]));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<PageTitle
|
||||
action={
|
||||
<Link href="/settings/ads/new" aria-label={t("ads.addNew")} className="p-2">
|
||||
<Image
|
||||
width={24}
|
||||
height={24}
|
||||
alt=""
|
||||
src={staticIconUrl("/images/icons/add.svg")}
|
||||
className="dark:brightness-0 dark:invert"
|
||||
/>
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
{t("settings.nav.ads")}
|
||||
</PageTitle>
|
||||
|
||||
<div className="mt-6 space-y-3 text-xs md:text-sm">
|
||||
{ads === null ? (
|
||||
<p className="py-10 text-center text-gray-500">{t("common.loading")}</p>
|
||||
) : ads.length === 0 ? (
|
||||
<EmptyState
|
||||
icon="chart-2"
|
||||
title={t("ads.emptyTitle")}
|
||||
subtitle={t("ads.emptyHint")}
|
||||
/>
|
||||
) : (
|
||||
ads.map((ad) => (
|
||||
<Link
|
||||
key={ad._id}
|
||||
href={`/settings/ads/${ad._id}`}
|
||||
className="flex items-center justify-between rounded-2xl border border-neutral-200 px-4 py-3 dark:border-neutral-800"
|
||||
>
|
||||
<div>
|
||||
<p className="font-semibold">{t(`ads.type.${ad.ad_type}`)}</p>
|
||||
<p className="text-neutral-500">
|
||||
{t("ads.viewsClicks", { views: ad.views_count, clicks: ad.clicks_count })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className={`font-semibold ${STATUS_COLOR[ad.effective_status]}`}>
|
||||
{t(`ads.status.${ad.effective_status}`)}
|
||||
</p>
|
||||
<p className="text-neutral-400">{Number(ad.price).toLocaleString()} {t("common.toman")}</p>
|
||||
</div>
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
58
src/app/settings/auto-direct/page.tsx
Normal file
58
src/app/settings/auto-direct/page.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import PageTitle from "@/components/settings/PageTitle";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import AutoDirectList from "@/components/settings/AutoDirectList";
|
||||
import { staticIconUrl } from "@/components/main/BaseUrl";
|
||||
import Image from "next/image";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import AutoDirectFormModal from "@/components/settings/AutoDirectFormModal";
|
||||
|
||||
function AutoDirectSettingsPage() {
|
||||
const { t } = useTranslation("common");
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
return (
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<PageTitle
|
||||
action={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCreate(true)}
|
||||
aria-label={t("autoDirect.addNew")}
|
||||
className="p-2"
|
||||
>
|
||||
<Image
|
||||
width={24}
|
||||
height={24}
|
||||
alt=""
|
||||
src={staticIconUrl("/images/icons/add.svg")}
|
||||
className="dark:brightness-0 dark:invert"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{t("settings.nav.autoDirect")}
|
||||
</PageTitle>
|
||||
<div className="text-xs md:text-sm">
|
||||
<AutoDirectList refreshKey={refreshKey} />
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
{showCreate && (
|
||||
<AutoDirectFormModal
|
||||
isOpen={showCreate}
|
||||
onClose={() => setShowCreate(false)}
|
||||
rule={null}
|
||||
onSaved={() => setRefreshKey((k) => k + 1)}
|
||||
/>
|
||||
)}
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
export default AutoDirectSettingsPage;
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import EmptyState from "@/components/elements/EmptyState";
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import PageTitle from "@/components/settings/PageTitle";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
@@ -58,9 +59,11 @@ export default function BookingListPage() {
|
||||
{t("common.loading")}
|
||||
</p>
|
||||
) : filteredConfigs.length === 0 ? (
|
||||
<p className="py-10 text-center text-sm text-neutral-500">
|
||||
{t("booking.emptyList")}
|
||||
</p>
|
||||
<EmptyState
|
||||
icon="calendar"
|
||||
title={t("booking.emptyList")}
|
||||
subtitle={t("booking.emptyListHint")}
|
||||
/>
|
||||
) : (
|
||||
filteredConfigs.map((config) => (
|
||||
<BookingListCard
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Container from "@/components/elements/Container";
|
||||
import EmptyState from "@/components/elements/EmptyState";
|
||||
import ProfileAvatar from "@/components/main/ProfileAvatar";
|
||||
import VerificationBadge from "@/components/main/VerificationBadge";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
@@ -264,10 +265,12 @@ export default function FindPartnerPage() {
|
||||
</p>
|
||||
|
||||
{users.length === 0 ? (
|
||||
<div className="py-6 text-center">
|
||||
<p className="text-sm text-neutral-500">{t("findPartner.empty")}</p>
|
||||
<p className="mt-2 text-xs text-neutral-400">{t("findPartner.emptyHint")}</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon="emoji-happy"
|
||||
title={t("findPartner.empty")}
|
||||
subtitle={t("findPartner.emptyHint")}
|
||||
align="right"
|
||||
/>
|
||||
) : (
|
||||
users.map((item) => {
|
||||
const age = ageFromBirthday(item.birthday);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useRouter } from "next/navigation";
|
||||
import Map, { Marker } from "react-map-gl";
|
||||
import "mapbox-gl/dist/mapbox-gl.css";
|
||||
import Container from "@/components/elements/Container";
|
||||
import EmptyState from "@/components/elements/EmptyState";
|
||||
import ProfileAvatar from "@/components/main/ProfileAvatar";
|
||||
import VerificationBadge from "@/components/main/VerificationBadge";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
@@ -479,9 +480,11 @@ export default function NearbyFriendsPage() {
|
||||
) : null}
|
||||
|
||||
{users.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-neutral-500">
|
||||
{t("nearbyFriends.empty")}
|
||||
</p>
|
||||
<EmptyState
|
||||
icon="location"
|
||||
title={t("nearbyFriends.empty")}
|
||||
subtitle={t("nearbyFriends.emptyHint")}
|
||||
/>
|
||||
) : (
|
||||
users.map((item) => (
|
||||
<Link
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import EmptyState from "@/components/elements/EmptyState";
|
||||
import RoundedInput from "@/components/elements/RoundedInput";
|
||||
import ProfileAvatar from "@/components/main/ProfileAvatar";
|
||||
import VerificationBadge from "@/components/main/VerificationBadge";
|
||||
@@ -199,9 +200,11 @@ function Chats() {
|
||||
</Link>
|
||||
|
||||
{isEmpty ? (
|
||||
<p className="py-8 text-center text-neutral-500">
|
||||
{t("chats.empty")}
|
||||
</p>
|
||||
<EmptyState
|
||||
icon="message"
|
||||
title={t("chats.empty")}
|
||||
subtitle={t("chats.emptyHint")}
|
||||
/>
|
||||
) : (
|
||||
data?.pages.map((page) =>
|
||||
page.filteredUsersData?.map((item: IMessage) => {
|
||||
@@ -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) => (
|
||||
|
||||
@@ -152,7 +152,6 @@ function ArchivePage() {
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="50vw"
|
||||
unoptimized
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
|
||||
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) ? (
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import Container from "@/components/elements/Container";
|
||||
import EmptyState from "@/components/elements/EmptyState";
|
||||
import PageTitle from "@/components/settings/PageTitle";
|
||||
import VisitorRow, { type ProfileVisitor } from "@/components/settings/VisitorRow";
|
||||
import ListDateSeparator from "@/components/settings/ListDateSeparator";
|
||||
@@ -60,9 +61,11 @@ export default function ProfileVisitorsPage() {
|
||||
) : error ? (
|
||||
<p className="py-8 text-center text-sm text-red-500">{error}</p>
|
||||
) : visitors.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-neutral-500">
|
||||
{t("settings.visitor.empty")}
|
||||
</p>
|
||||
<EmptyState
|
||||
icon="eye"
|
||||
title={t("settings.visitor.empty")}
|
||||
subtitle={t("settings.visitor.emptyHint")}
|
||||
/>
|
||||
) : (
|
||||
<div className="mt-2 font-semibold">
|
||||
{groupedVisitors.map((entry) =>
|
||||
|
||||
24
src/app/settings/shareable-links/page.tsx
Normal file
24
src/app/settings/shareable-links/page.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import PageTitle from "@/components/settings/PageTitle";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import ShareableLinksManager from "@/components/settings/ShareableLinksManager";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
function ShareableLinksSettingsPage() {
|
||||
const { t } = useTranslation("common");
|
||||
|
||||
return (
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<PageTitle>{t("settings.nav.shareableLinks")}</PageTitle>
|
||||
<div className="text-xs md:text-sm">
|
||||
<ShareableLinksManager />
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
export default ShareableLinksSettingsPage;
|
||||
@@ -8,8 +8,9 @@ import Header from "@/components/main/Header";
|
||||
import ShopListCard from "@/components/shops/ShopListCard";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type MyShop = {
|
||||
@@ -28,15 +29,19 @@ export default function ShopSettingsPage() {
|
||||
const { t } = useTranslation("common");
|
||||
const router = useRouter();
|
||||
const { request } = useAxios();
|
||||
const [myShops, setMyShops] = useState<MyShop[] | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
request<{ shops: MyShop[] }>("GET", "/shops/mine")
|
||||
.then((res) => setMyShops(res?.shops || []))
|
||||
.catch(() => setMyShops([]));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
// طبق خواستهی کاربر: بعدِ برگشت از ویرایشِ فروشگاه بدونِ رفرشِ دستی تغییرات دیده
|
||||
// بشن — قبلاً با useEffect(..., []) فقط یهبار موقعِ اولین mount فچ میشد؛ عینِ
|
||||
// useInfiniteScroll (لیستِ کالاها) با refetchOnMount:"always" هر بار برگردیم به
|
||||
// این صفحه دوباره فچ میشه
|
||||
const { data } = useQuery({
|
||||
queryKey: ["shops-mine"],
|
||||
queryFn: () => request<{ shops: MyShop[] }>("GET", "/shops/mine"),
|
||||
staleTime: 0,
|
||||
refetchOnMount: "always",
|
||||
});
|
||||
const myShops = data?.shops ?? null;
|
||||
|
||||
const filteredShops = useMemo(() => {
|
||||
if (!myShops) return [];
|
||||
|
||||
@@ -17,7 +17,7 @@ import ShopCartTab from "@/components/shops/ShopCartTab";
|
||||
import ShopFavoritesTab from "@/components/shops/ShopFavoritesTab";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import useInfiniteScroll from "@/hooks/useInfiniteScroll";
|
||||
import { toggleBtnClass, filterChipClass } from "@/lib/ui/buttonStyles";
|
||||
import { btnPrimary, btnDefault } from "@/lib/ui/buttonStyles";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { staticIconUrl } from "@/components/main/BaseUrl";
|
||||
import Image from "next/image";
|
||||
@@ -164,13 +164,13 @@ export default function ShopSettingsPage() {
|
||||
</div>
|
||||
<div className="mx-auto mt-4 grid w-full max-w-md grid-cols-2 gap-4">
|
||||
<RoundedButton
|
||||
className={cn(toggleBtnClass(filter === FILTER_SELLER), "h-9")}
|
||||
className={cn(filter === FILTER_SELLER ? btnPrimary : btnDefault, "h-10 text-xs")}
|
||||
onClick={() => setFilter(FILTER_SELLER)}
|
||||
>
|
||||
{t("shops.sellerTab")}
|
||||
</RoundedButton>
|
||||
<RoundedButton
|
||||
className={cn(toggleBtnClass(filter === FILTER_BUYER), "h-9")}
|
||||
className={cn(filter === FILTER_BUYER ? btnPrimary : btnDefault, "h-10 text-xs")}
|
||||
onClick={() => setFilter(FILTER_BUYER)}
|
||||
>
|
||||
{t("shops.buyerTab")}
|
||||
@@ -184,7 +184,7 @@ export default function ShopSettingsPage() {
|
||||
key={tabId}
|
||||
type="button"
|
||||
onClick={() => setSellerTab(tabId)}
|
||||
className={filterChipClass(sellerTab === tabId, "shrink-0")}
|
||||
className={cn(sellerTab === tabId ? btnPrimary : btnDefault, "h-10 text-xs shrink-0")}
|
||||
>
|
||||
{t(`shops.sellerTabs.${tabId}`)}
|
||||
</button>
|
||||
@@ -199,7 +199,7 @@ export default function ShopSettingsPage() {
|
||||
key={tabId}
|
||||
type="button"
|
||||
onClick={() => setBuyerTab(tabId)}
|
||||
className={filterChipClass(buyerTab === tabId, "shrink-0")}
|
||||
className={cn(buyerTab === tabId ? btnPrimary : btnDefault, "h-10 text-xs shrink-0")}
|
||||
>
|
||||
{t(`shops.buyerTabs.${tabId}`)}
|
||||
</button>
|
||||
|
||||
291
src/app/settings/sub-users/[id]/page.tsx
Normal file
291
src/app/settings/sub-users/[id]/page.tsx
Normal file
@@ -0,0 +1,291 @@
|
||||
"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 ProfileAvatar from "@/components/main/ProfileAvatar";
|
||||
import { buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import { SUB_USER_PERMISSION_KEYS } from "@/constants/subUserPermissions";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import Image from "next/image";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type SubUser = {
|
||||
_id: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
user_name: string;
|
||||
profile_image?: string | null;
|
||||
sub_user_active: boolean;
|
||||
sub_user_permissions: string[];
|
||||
};
|
||||
|
||||
export default function SubUserDetailPage() {
|
||||
const { t } = useTranslation("common");
|
||||
const { request } = useAxios();
|
||||
const params = useParams<{ id: string }>();
|
||||
const id = params?.id;
|
||||
|
||||
// نه router.back()/Link — چون توی این صفحه یه غیرفعال/فعالسازی یا ویرایش ممکنه
|
||||
// انجام شده باشه و لیستِ صفحهی قبل (که فقط با useEffect یهبار دیتاش رو میگیره)
|
||||
// بدونِ یه ناوبریِ کامل (نه soft) وضعیتِ تازه رو نشون نمیداد
|
||||
const backToList = () => {
|
||||
window.location.href = "/settings/sub-users";
|
||||
};
|
||||
|
||||
const [subUser, setSubUser] = useState<SubUser | null>(null);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [toggling, setToggling] = useState(false);
|
||||
|
||||
// فرمِ ویرایش
|
||||
const [firstName, setFirstName] = useState("");
|
||||
const [lastName, setLastName] = useState("");
|
||||
const [userName, setUserName] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [avatar, setAvatar] = useState<string | null>(null);
|
||||
const [avatarFile, setAvatarFile] = useState<File | null>(null);
|
||||
const [permissions, setPermissions] = useState<string[]>([]);
|
||||
|
||||
const load = () => {
|
||||
if (!id) return;
|
||||
request<{ subUser?: SubUser }>("GET", `/sub-users/${id}`, null, { noToast: true })
|
||||
.then((res) => setSubUser(res?.subUser ?? null))
|
||||
.catch(() => setSubUser(null));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [id]);
|
||||
|
||||
const startEditing = () => {
|
||||
if (!subUser) return;
|
||||
setFirstName(subUser.first_name || "");
|
||||
setLastName(subUser.last_name || "");
|
||||
setUserName(subUser.user_name || "");
|
||||
setPassword("");
|
||||
setAvatar(subUser.profile_image ? buildStorageUrl(subUser.profile_image) : null);
|
||||
setAvatarFile(null);
|
||||
setPermissions(subUser.sub_user_permissions || []);
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
const togglePermission = (key: string) => {
|
||||
setPermissions((prev) =>
|
||||
prev.includes(key) ? prev.filter((p) => p !== key) : [...prev, key]
|
||||
);
|
||||
};
|
||||
|
||||
const selectImage = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setAvatarFile(file);
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => setAvatar(reader.result as string);
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!id || !firstName.trim() || !userName.trim()) {
|
||||
toast.error(t("auth.usernamePasswordRequired"));
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("first_name", firstName.trim());
|
||||
formData.append("last_name", lastName.trim());
|
||||
formData.append("user_name", userName.trim());
|
||||
if (password) formData.append("password", password);
|
||||
formData.append("permissions", JSON.stringify(permissions));
|
||||
if (avatarFile) formData.append("profile_image", avatarFile);
|
||||
|
||||
await request("PATCH", `/sub-users/${id}`, formData);
|
||||
toast.success(t("subUsers.updateSuccess"));
|
||||
setEditing(false);
|
||||
load();
|
||||
} catch {
|
||||
// toast از interceptor
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleActive = async () => {
|
||||
if (!id || !subUser) return;
|
||||
setToggling(true);
|
||||
try {
|
||||
await request("PATCH", `/sub-users/${id}/active`, {
|
||||
active: !subUser.sub_user_active,
|
||||
});
|
||||
toast.success(
|
||||
t(subUser.sub_user_active ? "subUsers.disableSuccess" : "subUsers.enableSuccess")
|
||||
);
|
||||
load();
|
||||
} catch {
|
||||
// toast از interceptor
|
||||
} finally {
|
||||
setToggling(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!subUser) {
|
||||
return (
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<PageTitle onBack={backToList}>{t("subUsers.editTitle")}</PageTitle>
|
||||
<p className="py-10 text-center text-gray-500">{t("common.loading")}</p>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
const name = [subUser.first_name, subUser.last_name].filter(Boolean).join(" ") || subUser.user_name;
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<PageTitle onBack={backToList}>{t("subUsers.editTitle")}</PageTitle>
|
||||
<div className="mt-6 flex flex-col items-center gap-5 text-xs md:text-sm">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="relative flex h-[110px] w-[110px] items-center justify-center overflow-hidden rounded-full border border-[#676767]">
|
||||
{avatar ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={avatar} alt="" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<label htmlFor="subUserAvatarEditInput" className="flex h-full w-full cursor-pointer items-center justify-center">
|
||||
<Image src="/images/icons/gallery-add.svg" width={40} height={40} alt="" />
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
id="subUserAvatarEditInput"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={selectImage}
|
||||
/>
|
||||
<label htmlFor="subUserAvatarEditInput" className="cursor-pointer text-neutral-500 underline">
|
||||
{t("settings.edit.avatar.editImage")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="w-full space-y-3">
|
||||
<div>
|
||||
<p className="mb-2 font-semibold">{t("subUsers.nameLabel")}</p>
|
||||
<RoundedInput value={firstName} onChange={(e) => setFirstName(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-2 font-semibold">{t("subUsers.lastNameLabel")}</p>
|
||||
<RoundedInput value={lastName} onChange={(e) => setLastName(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-2 font-semibold">{t("subUsers.usernameLabel")}</p>
|
||||
<RoundedInput value={userName} onChange={(e) => setUserName(e.target.value)} dir="ltr" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-2 font-semibold">{t("subUsers.passwordLabel")}</p>
|
||||
<RoundedInput
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={t("subUsers.passwordEditHint")}
|
||||
type="password"
|
||||
dir="ltr"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
<p className="mb-3 font-semibold">{t("subUsers.permissionsLabel")}</p>
|
||||
<div className="space-y-2">
|
||||
{SUB_USER_PERMISSION_KEYS.map((key) => (
|
||||
<label
|
||||
key={key}
|
||||
className="flex items-center gap-3 rounded-2xl border border-neutral-200 px-4 py-3 dark:border-neutral-800"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={permissions.includes(key)}
|
||||
onChange={() => togglePermission(key)}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<span>{t(`subUsers.permissions.${key}`)}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full gap-3">
|
||||
<RoundedButton className="h-11 flex-1" onClick={() => setEditing(false)} disabled={saving}>
|
||||
{t("ads.backAction")}
|
||||
</RoundedButton>
|
||||
<RoundedButton variant="primary" className="h-11 flex-1" onClick={save} disabled={saving}>
|
||||
{saving ? t("subUsers.saving") : t("subUsers.save")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<PageTitle onBack={backToList}>{t("subUsers.editTitle")}</PageTitle>
|
||||
<div className="mt-6 flex flex-col items-center gap-4 text-xs md:text-sm">
|
||||
<ProfileAvatar src={subUser.profile_image} size="lg" rounded="full" />
|
||||
<div className="text-center">
|
||||
<p className="text-lg font-bold">{name}</p>
|
||||
<p className="text-neutral-500">@{subUser.user_name}</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
<p className="mb-3 font-semibold">{t("subUsers.permissionsTitle")}</p>
|
||||
<div className="space-y-3">
|
||||
{SUB_USER_PERMISSION_KEYS.map((key) => {
|
||||
const granted = subUser.sub_user_permissions?.includes(key);
|
||||
return (
|
||||
<div key={key}>
|
||||
<p className="mb-2 text-sm font-semibold">{t(`subUsers.permissions.${key}`)}</p>
|
||||
<div
|
||||
className={
|
||||
"flex min-h-[42px] w-full items-center rounded-2xl border px-4 py-2.5 text-sm " +
|
||||
(granted
|
||||
? "border-green-300 bg-green-50 text-green-700 dark:border-green-800 dark:bg-green-950/30 dark:text-green-400"
|
||||
: "border-[#C3C3C3] bg-white/80 text-neutral-500 dark:border-neutral-600 dark:bg-neutral-900/80")
|
||||
}
|
||||
>
|
||||
{t(granted ? "settings.yes" : "settings.no")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full gap-3 pt-2">
|
||||
<RoundedButton className="h-11 flex-1" onClick={startEditing}>
|
||||
{t("subUsers.edit")}
|
||||
</RoundedButton>
|
||||
<RoundedButton
|
||||
variant={subUser.sub_user_active ? "default" : "primary"}
|
||||
className="h-11 flex-1"
|
||||
onClick={toggleActive}
|
||||
disabled={toggling}
|
||||
>
|
||||
{t(subUser.sub_user_active ? "subUsers.disable" : "subUsers.enable")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
172
src/app/settings/sub-users/new/page.tsx
Normal file
172
src/app/settings/sub-users/new/page.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
"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 { SUB_USER_PERMISSION_KEYS } from "@/constants/subUserPermissions";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import Image from "next/image";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function NewSubUserPage() {
|
||||
const { t } = useTranslation("common");
|
||||
const { request } = useAxios();
|
||||
|
||||
const [firstName, setFirstName] = useState("");
|
||||
const [lastName, setLastName] = useState("");
|
||||
const [userName, setUserName] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [avatar, setAvatar] = useState<string | null>(null);
|
||||
const [avatarFile, setAvatarFile] = useState<File | null>(null);
|
||||
const [permissions, setPermissions] = useState<string[]>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const togglePermission = (key: string) => {
|
||||
setPermissions((prev) =>
|
||||
prev.includes(key) ? prev.filter((p) => p !== key) : [...prev, key]
|
||||
);
|
||||
};
|
||||
|
||||
const selectImage = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setAvatarFile(file);
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => setAvatar(reader.result as string);
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!firstName.trim() || !userName.trim() || !password) {
|
||||
toast.error(t("auth.usernamePasswordRequired"));
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("first_name", firstName.trim());
|
||||
formData.append("last_name", lastName.trim());
|
||||
formData.append("user_name", userName.trim());
|
||||
formData.append("password", password);
|
||||
formData.append("permissions", JSON.stringify(permissions));
|
||||
if (avatarFile) formData.append("profile_image", avatarFile);
|
||||
|
||||
await request("POST", "/sub-users", formData);
|
||||
toast.success(t("subUsers.createSuccess"));
|
||||
// router.push ساده کافی نیست — صفحهی لیست دیتاش رو فقط توی useEffect (یه بار
|
||||
// موقع mount) میگیره؛ چون Next.js موقعِ برگشت این صفحه رو از کشِ خودش (نه
|
||||
// واقعاً remount) نشون میده، useEffect دوباره اجرا نمیشه و کاربرِ تازهساخته
|
||||
// دیده نمیشه تا یه رفرشِ دستی. navigation کامل (نه soft) این رو تضمین میکنه.
|
||||
window.location.href = "/settings/sub-users";
|
||||
} catch {
|
||||
// خطا با toast interceptor خودِ useAxios نمایش داده میشه
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<PageTitle>{t("subUsers.createTitle")}</PageTitle>
|
||||
|
||||
<div className="mt-6 flex flex-col items-center gap-5 text-xs md:text-sm">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="relative flex h-[110px] w-[110px] items-center justify-center overflow-hidden rounded-full border border-[#676767]">
|
||||
{avatar ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={avatar} alt="" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<label htmlFor="subUserAvatarInput" className="flex h-full w-full cursor-pointer items-center justify-center">
|
||||
<Image src="/images/icons/gallery-add.svg" width={40} height={40} alt="" />
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
id="subUserAvatarInput"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={selectImage}
|
||||
/>
|
||||
<label htmlFor="subUserAvatarInput" className="cursor-pointer text-neutral-500 underline">
|
||||
{t("settings.edit.avatar.editImage")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="w-full space-y-3">
|
||||
<div>
|
||||
<p className="mb-2 font-semibold">{t("subUsers.nameLabel")}</p>
|
||||
<RoundedInput
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
placeholder={t("subUsers.namePlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-2 font-semibold">{t("subUsers.lastNameLabel")}</p>
|
||||
<RoundedInput
|
||||
value={lastName}
|
||||
onChange={(e) => setLastName(e.target.value)}
|
||||
placeholder={t("subUsers.lastNamePlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-2 font-semibold">{t("subUsers.usernameLabel")}</p>
|
||||
<RoundedInput
|
||||
value={userName}
|
||||
onChange={(e) => setUserName(e.target.value)}
|
||||
placeholder={t("subUsers.usernamePlaceholder")}
|
||||
autoCapitalize="none"
|
||||
dir="ltr"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-2 font-semibold">{t("subUsers.passwordLabel")}</p>
|
||||
<RoundedInput
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={t("subUsers.passwordPlaceholder")}
|
||||
type="password"
|
||||
dir="ltr"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
<p className="mb-3 font-semibold">{t("subUsers.permissionsLabel")}</p>
|
||||
<div className="space-y-2">
|
||||
{SUB_USER_PERMISSION_KEYS.map((key) => (
|
||||
<label
|
||||
key={key}
|
||||
className="flex items-center gap-3 rounded-2xl border border-neutral-200 px-4 py-3 dark:border-neutral-800"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={permissions.includes(key)}
|
||||
onChange={() => togglePermission(key)}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<span>{t(`subUsers.permissions.${key}`)}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RoundedButton
|
||||
variant="primary"
|
||||
className="mt-2 h-11 w-full"
|
||||
disabled={submitting}
|
||||
onClick={submit}
|
||||
>
|
||||
{submitting ? t("subUsers.saving") : t("subUsers.save")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
95
src/app/settings/sub-users/page.tsx
Normal file
95
src/app/settings/sub-users/page.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
"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 ProfileAvatar from "@/components/main/ProfileAvatar";
|
||||
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 SubUserRow = {
|
||||
_id: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
user_name: string;
|
||||
profile_image?: string | null;
|
||||
sub_user_active: boolean;
|
||||
};
|
||||
|
||||
export default function SubUsersListPage() {
|
||||
const { t } = useTranslation("common");
|
||||
const { request } = useAxios();
|
||||
const [subUsers, setSubUsers] = useState<SubUserRow[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
request<{ subUsers?: SubUserRow[] }>("GET", "/sub-users", null, { noToast: true })
|
||||
.then((res) => setSubUsers(res?.subUsers ?? []))
|
||||
.catch(() => setSubUsers([]));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<PageTitle
|
||||
action={
|
||||
<Link href="/settings/sub-users/new" aria-label={t("subUsers.createTitle")} className="p-2">
|
||||
<Image
|
||||
width={24}
|
||||
height={24}
|
||||
alt=""
|
||||
src={staticIconUrl("/images/icons/add.svg")}
|
||||
className="dark:brightness-0 dark:invert"
|
||||
/>
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
{t("subUsers.listTitle")}
|
||||
</PageTitle>
|
||||
|
||||
<div className="mt-6 space-y-3 text-xs md:text-sm">
|
||||
{subUsers === null ? (
|
||||
<p className="py-10 text-center text-gray-500">{t("common.loading")}</p>
|
||||
) : subUsers.length === 0 ? (
|
||||
<EmptyState
|
||||
icon="profile-2user"
|
||||
title={t("subUsers.emptyTitle")}
|
||||
subtitle={t("subUsers.emptyHint")}
|
||||
/>
|
||||
) : (
|
||||
subUsers.map((su) => {
|
||||
const name = [su.first_name, su.last_name].filter(Boolean).join(" ") || su.user_name;
|
||||
return (
|
||||
<Link
|
||||
key={su._id}
|
||||
href={`/settings/sub-users/${su._id}`}
|
||||
className="flex items-center gap-3 rounded-2xl border border-neutral-200 px-4 py-3 dark:border-neutral-800"
|
||||
>
|
||||
<ProfileAvatar src={su.profile_image} size="sm" rounded="full" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-semibold">{name}</p>
|
||||
<p className="truncate text-neutral-500">@{su.user_name}</p>
|
||||
</div>
|
||||
<span
|
||||
className={
|
||||
su.sub_user_active
|
||||
? "shrink-0 text-green-600"
|
||||
: "shrink-0 text-neutral-400"
|
||||
}
|
||||
>
|
||||
{t(su.sub_user_active ? "subUsers.active" : "subUsers.inactive")}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import EmptyState from "@/components/elements/EmptyState";
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import RoundedInput from "@/components/elements/RoundedInput";
|
||||
import PageTitle from "@/components/settings/PageTitle";
|
||||
@@ -57,9 +58,11 @@ function Tickets() {
|
||||
<div className="w-full mt-5">
|
||||
{data?.pages.length === 0 ||
|
||||
(data?.pages[0]?.tickets?.length === 0 && !isFetchingNextPage) ? (
|
||||
<p className="text-center text-gray-500">
|
||||
{t("settings.tickets.empty")}
|
||||
</p>
|
||||
<EmptyState
|
||||
icon="message-question"
|
||||
title={t("settings.tickets.empty")}
|
||||
subtitle={t("settings.tickets.emptyHint")}
|
||||
/>
|
||||
) : (
|
||||
data?.pages?.map((page, pageIndex) => (
|
||||
<React.Fragment key={pageIndex}>
|
||||
|
||||
@@ -8,7 +8,6 @@ import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { toggleBtnClass } from "@/lib/ui/buttonStyles";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -31,8 +30,6 @@ type WalletSummary = {
|
||||
|
||||
export default function WalletSettingsPage() {
|
||||
const { t } = useTranslation("common");
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { request, loading } = useAxios();
|
||||
|
||||
const [bucket, setBucket] = useState<string>(BUCKET_SHOP);
|
||||
@@ -48,9 +45,17 @@ export default function WalletSettingsPage() {
|
||||
|
||||
useEffect(() => {
|
||||
loadSummary();
|
||||
const charge = searchParams.get("charge");
|
||||
if (charge === "success") toast.success(t("shops.paymentSuccessTitle"));
|
||||
if (charge === "failed") toast.error(t("shops.paymentFailedTitle"));
|
||||
const onVisible = () => {
|
||||
if (document.visibilityState === "visible") loadSummary();
|
||||
};
|
||||
window.addEventListener("pageshow", loadSummary);
|
||||
window.addEventListener("focus", loadSummary);
|
||||
document.addEventListener("visibilitychange", onVisible);
|
||||
return () => {
|
||||
window.removeEventListener("pageshow", loadSummary);
|
||||
window.removeEventListener("focus", loadSummary);
|
||||
document.removeEventListener("visibilitychange", onVisible);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
@@ -82,14 +87,19 @@ export default function WalletSettingsPage() {
|
||||
const amount = Number(chargeAmount);
|
||||
if (!amount || amount <= 0) return;
|
||||
try {
|
||||
const response = await request<{ authority?: string }>(
|
||||
const response = await request<{ authority?: string; paymentUrl?: string }>(
|
||||
"POST",
|
||||
"/wallet/charge/initiate",
|
||||
{ amount }
|
||||
);
|
||||
if (response?.authority) {
|
||||
router.push(`https://www.zarinpal.com/pg/StartPay/${response.authority}`);
|
||||
}
|
||||
const target =
|
||||
response?.paymentUrl ||
|
||||
(response?.authority
|
||||
? `https://www.zarinpal.com/pg/StartPay/${response.authority}`
|
||||
: null);
|
||||
// لینک خارجی — router.push رفتارِ تضمینشدهای نداره؛ window.location.href
|
||||
// مطمئنتره (خصوصاً سافاری)
|
||||
if (target) window.location.href = target;
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
(err as { response?: { data?: { message?: string } } })?.response
|
||||
@@ -98,9 +108,21 @@ export default function WalletSettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const cta =
|
||||
bucket === BUCKET_CHARGED
|
||||
? { label: t("shops.chargeWallet"), onClick: handleCharge, disabled: loading }
|
||||
: {
|
||||
label: t("shops.requestWithdrawal"),
|
||||
onClick: handleWithdraw,
|
||||
disabled:
|
||||
loading ||
|
||||
(bucket === BUCKET_GIFT &&
|
||||
(!summary || summary.gift.available < summary.gift.minWithdrawal)),
|
||||
};
|
||||
|
||||
return (
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<Container className="pb-28">
|
||||
<PageTitle>{t("settings.nav.wallet")}</PageTitle>
|
||||
|
||||
<div className="mx-auto mt-4 max-w-md text-center">
|
||||
@@ -172,14 +194,6 @@ export default function WalletSettingsPage() {
|
||||
value={withdrawAmount}
|
||||
onChange={(e) => setWithdrawAmount(e.target.value)}
|
||||
/>
|
||||
<RoundedButton
|
||||
variant="primary"
|
||||
className="h-10 w-full max-w-[200px]"
|
||||
disabled={loading}
|
||||
onClick={handleWithdraw}
|
||||
>
|
||||
{t("shops.requestWithdrawal")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -205,14 +219,6 @@ export default function WalletSettingsPage() {
|
||||
value={withdrawAmount}
|
||||
onChange={(e) => setWithdrawAmount(e.target.value)}
|
||||
/>
|
||||
<RoundedButton
|
||||
variant="primary"
|
||||
className="h-10 w-full max-w-[200px]"
|
||||
disabled={loading || summary.gift.available < summary.gift.minWithdrawal}
|
||||
onClick={handleWithdraw}
|
||||
>
|
||||
{t("shops.requestWithdrawal")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -233,14 +239,6 @@ export default function WalletSettingsPage() {
|
||||
value={withdrawAmount}
|
||||
onChange={(e) => setWithdrawAmount(e.target.value)}
|
||||
/>
|
||||
<RoundedButton
|
||||
variant="primary"
|
||||
className="h-10 w-full max-w-[200px]"
|
||||
disabled={loading}
|
||||
onClick={handleWithdraw}
|
||||
>
|
||||
{t("shops.requestWithdrawal")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -261,14 +259,6 @@ export default function WalletSettingsPage() {
|
||||
value={withdrawAmount}
|
||||
onChange={(e) => setWithdrawAmount(e.target.value)}
|
||||
/>
|
||||
<RoundedButton
|
||||
variant="primary"
|
||||
className="h-10 w-full max-w-[200px]"
|
||||
disabled={loading}
|
||||
onClick={handleWithdraw}
|
||||
>
|
||||
{t("shops.requestWithdrawal")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -289,14 +279,6 @@ export default function WalletSettingsPage() {
|
||||
value={withdrawAmount}
|
||||
onChange={(e) => setWithdrawAmount(e.target.value)}
|
||||
/>
|
||||
<RoundedButton
|
||||
variant="primary"
|
||||
className="h-10 w-full max-w-[200px]"
|
||||
disabled={loading}
|
||||
onClick={handleWithdraw}
|
||||
>
|
||||
{t("shops.requestWithdrawal")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -318,19 +300,24 @@ export default function WalletSettingsPage() {
|
||||
value={chargeAmount}
|
||||
onChange={(e) => setChargeAmount(e.target.value)}
|
||||
/>
|
||||
<RoundedButton
|
||||
variant="primary"
|
||||
className="h-10 w-full max-w-[200px]"
|
||||
disabled={loading}
|
||||
onClick={handleCharge}
|
||||
>
|
||||
{t("shops.chargeWallet")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
{summary ? (
|
||||
<div className="pointer-events-none fixed bottom-28 left-0 right-0 z-[54] flex justify-center px-4">
|
||||
<RoundedButton
|
||||
variant="primary"
|
||||
className="pointer-events-auto h-10 w-full max-w-[200px]"
|
||||
disabled={cta.disabled}
|
||||
onClick={cta.onClick}
|
||||
>
|
||||
{cta.label}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
) : null}
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
103
src/app/settings/wallet/payment/failed/page.tsx
Normal file
103
src/app/settings/wallet/payment/failed/page.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import RoundedDiv from "@/components/elements/RoundedDiv";
|
||||
import PageTitle from "@/components/settings/PageTitle";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function WalletPaymentFailedPage() {
|
||||
const { t } = useTranslation("common");
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { request } = useAxios();
|
||||
const [paying, setPaying] = useState(false);
|
||||
|
||||
const amount = Number(searchParams.get("amount") || 0);
|
||||
|
||||
const retryPayment = async () => {
|
||||
if (!amount || amount <= 0 || paying) return;
|
||||
|
||||
setPaying(true);
|
||||
try {
|
||||
const response = await request<{ authority?: string; paymentUrl?: string }>(
|
||||
"POST",
|
||||
"/wallet/charge/initiate",
|
||||
{ amount },
|
||||
{ noToast: true }
|
||||
);
|
||||
|
||||
const paymentUrl =
|
||||
response.paymentUrl ||
|
||||
(response.authority
|
||||
? `https://www.zarinpal.com/pg/StartPay/${response.authority}`
|
||||
: null);
|
||||
|
||||
if (!paymentUrl) {
|
||||
toast.error(t("settings.paymentStartError"));
|
||||
setPaying(false);
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.href = paymentUrl;
|
||||
} catch (error: unknown) {
|
||||
const message =
|
||||
(error as { response?: { data?: { message?: string } } })?.response
|
||||
?.data?.message || t("settings.paymentStartError");
|
||||
toast.error(message);
|
||||
setPaying(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<PageTitle>{t("shops.paymentFailedTitle")}</PageTitle>
|
||||
|
||||
<div className="flex flex-col items-center text-sm">
|
||||
<Image
|
||||
width={50}
|
||||
height={50}
|
||||
alt=""
|
||||
src="/images/icons/failed.svg"
|
||||
className="mb-5 pb-1"
|
||||
/>
|
||||
<span>{t("shops.paymentFailedHint")}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 w-full px-4">
|
||||
{amount > 0 && (
|
||||
<RoundedDiv className="w-full p-3 text-center">
|
||||
{amount.toLocaleString()} {t("settings.toman")}
|
||||
</RoundedDiv>
|
||||
)}
|
||||
|
||||
<div className="mt-6 flex w-full max-w-sm flex-row items-center justify-center gap-3">
|
||||
<RoundedButton
|
||||
onClick={retryPayment}
|
||||
disabled={paying || !amount}
|
||||
variant="primary"
|
||||
className="h-9 flex-1"
|
||||
>
|
||||
{paying ? "..." : t("offerPage.paymentFailed.retry")}
|
||||
</RoundedButton>
|
||||
|
||||
<RoundedButton
|
||||
onClick={() => router.push("/settings/wallet")}
|
||||
className="h-9 flex-1"
|
||||
>
|
||||
{t("common.back")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user