Compare commits
6 Commits
3b89bbfec3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
551260a836 | ||
|
|
f364f989ed | ||
|
|
d6386b779f | ||
|
|
9fb454b0a7 | ||
|
|
6e76e367f3 | ||
|
|
200b387504 |
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "modstagram-next-dev",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"port": 3004
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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": "4.1.5",
|
||||
"version": "4.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "modstagram-next",
|
||||
"version": "4.1.5",
|
||||
"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": "4.1.5",
|
||||
"version": "4.4.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack --port 3004",
|
||||
|
||||
BIN
public/images/og-image.png
Normal file
BIN
public/images/og-image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
@@ -27,6 +27,9 @@ Disallow: /*/payment/
|
||||
Disallow: /projects/payment/
|
||||
Disallow: /billboards/payment/
|
||||
Disallow: /academy/payment/
|
||||
Disallow: /shops/new
|
||||
Disallow: /shops/checkout
|
||||
Disallow: /shops/orders
|
||||
|
||||
Host: https://modstagram.com
|
||||
Sitemap: https://modstagram.com/sitemap.xml
|
||||
Host: https://www.modstagram.com
|
||||
Sitemap: https://www.modstagram.com/sitemap.xml
|
||||
|
||||
@@ -22,10 +22,18 @@ const messaging = firebase.messaging.isSupported() ? firebase.messaging() : null
|
||||
if (messaging) {
|
||||
messaging.onBackgroundMessage((payload) => {
|
||||
const title = payload.notification?.title || "modstagram";
|
||||
// عکسِ فرستنده (یا بنرِ دستیِ ادمین برای admin_broadcast) — قبلاً همیشه لوگوی ثابتِ
|
||||
// اپ بود، صرفنظر از اینکه نوتیف مربوط به کدوم کاربره؛ notification.image رو
|
||||
// utils/pushNotification.js بکاند از قبل میفرسته (فقط اینجا نادیده گرفته میشد)
|
||||
const avatarOrBanner = payload.notification?.image;
|
||||
self.registration.showNotification(title, {
|
||||
body: payload.notification?.body,
|
||||
icon: "/images/icons/192x192.png",
|
||||
icon: avatarOrBanner || "/images/icons/192x192.png",
|
||||
badge: "/images/icons/192x192.png",
|
||||
// بنرِ بزرگِ زیرِ متن (برای admin_broadcast که واقعاً قراره «بنر» باشه)، غیر از
|
||||
// آیکونِ کوچیکِ گردِ کنارِ عنوان — مرورگرهایی که image رو ساپورت نکنن سرشون این رو
|
||||
// نادیده میگیرن، پس بیخطره
|
||||
image: avatarOrBanner || undefined,
|
||||
data: payload.data || {},
|
||||
});
|
||||
});
|
||||
@@ -33,7 +41,10 @@ if (messaging) {
|
||||
|
||||
self.addEventListener("notificationclick", (event) => {
|
||||
event.notification.close();
|
||||
const url = "/settings/notifications";
|
||||
// بکاند وقتی خودش دقیقاً میدونه مقصد کجاست (پیام چت، تگِ پست و...) یه مسیرِ نسبیِ
|
||||
// آماده میفرسته (نگاه کن به utils/notifyUser.js/chatHelpers.js: data.link) — وگرنه
|
||||
// مثلِ قبل میریم صفحهی عمومیِ اعلانها
|
||||
const url = event.notification.data?.link || "/settings/notifications";
|
||||
event.waitUntil(
|
||||
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clientsArr) => {
|
||||
const existing = clientsArr.find((c) => c.url.includes(url));
|
||||
@@ -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 =
|
||||
|
||||
@@ -101,7 +101,7 @@ function Sizes() {
|
||||
className={`p-1 rounded-2xl w-1/3 border text-sm flex items-center justify-center gap-1 ${
|
||||
selectedButton === 1
|
||||
? "btn-modern--selected text-white"
|
||||
: "bg-tertiary-light dark:bg-tertiary-dark text-black border-gray-400"
|
||||
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
|
||||
}`}
|
||||
onClick={() => handleButtonPress(1)}
|
||||
>
|
||||
|
||||
@@ -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,98 +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 بعدِ یه await در سافاری (دسکتاپ و خصوصاً iOS) پاپآپبلاک میشه —
|
||||
// چون دیگه synchronously به همون کلیکِ کاربر متصل نیست. window.location.href هم
|
||||
// مطمئنتره، هم با بقیهی مسیرهای پرداختِ این اپ (booking، پروژه، بیلبورد) یکی میمونه
|
||||
window.location.href = paymentUrl;
|
||||
return;
|
||||
} else {
|
||||
console.error("ساختار پاسخ نامعتبر:", response);
|
||||
toast.error(
|
||||
response?.data?.message ||
|
||||
response?.message ||
|
||||
t("academy.course.paymentStartError")
|
||||
);
|
||||
}
|
||||
|
||||
} catch (err: any) {
|
||||
// بستن لودینگ در صورت خطا
|
||||
if (loadingToastId) {
|
||||
toast.dismiss(loadingToastId);
|
||||
}
|
||||
|
||||
console.error("خطا در شروع پرداخت:", err);
|
||||
|
||||
// استخراج پیام خطا
|
||||
let errorMessage = t("academy.course.paymentStartError");
|
||||
|
||||
if (err?.response?.data?.message) {
|
||||
errorMessage = err.response.data.message;
|
||||
} else if (err?.response?.data?.error) {
|
||||
errorMessage = err.response.data.error;
|
||||
} else if (err?.message) {
|
||||
errorMessage = err.message;
|
||||
}
|
||||
|
||||
if (errorMessage.includes("amount") || errorMessage.includes("مبلغ")) {
|
||||
errorMessage = t("academy.course.invalidAmount");
|
||||
} else if (errorMessage.includes("merchant")) {
|
||||
errorMessage = t("academy.course.gatewayConfigError");
|
||||
}
|
||||
|
||||
toast.error(errorMessage);
|
||||
if (!token) {
|
||||
toast.error(t("academy.course.loginToBuy"));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if (!courses[0]?.is_free && walletBalance > 0) {
|
||||
setShowWalletPrompt(true);
|
||||
return;
|
||||
}
|
||||
|
||||
await startPayment(false);
|
||||
};
|
||||
|
||||
// پخش ویدیو
|
||||
const handlePlayVideo = (video: (typeof MOCK_VIDEOS)[0]) => {
|
||||
@@ -591,7 +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;
|
||||
@@ -756,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"
|
||||
@@ -770,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 && (
|
||||
@@ -855,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">
|
||||
@@ -873,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>
|
||||
)}
|
||||
@@ -1046,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",
|
||||
@@ -88,6 +92,7 @@ function FailedProject() {
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.log(error);
|
||||
setPaying(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -108,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")}
|
||||
@@ -122,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,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}` : "#";
|
||||
|
||||
@@ -130,25 +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"}
|
||||
/>
|
||||
) : (
|
||||
<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",
|
||||
@@ -72,6 +76,7 @@ function BillboardPayment({ params }: IBillboardProps) {
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.log(error);
|
||||
setPaying(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -103,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",
|
||||
@@ -62,10 +67,13 @@ function FailedBillboard() {
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.log(error);
|
||||
setPaying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const republishHandler = async () => {
|
||||
if (paying) return;
|
||||
setPaying(true);
|
||||
try {
|
||||
const response = await request<{ authority?: string }>(
|
||||
"POST",
|
||||
@@ -88,6 +96,7 @@ function FailedBillboard() {
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.log(error);
|
||||
setPaying(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -108,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")}
|
||||
@@ -126,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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { User } from "@/types/types";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import WalletUsagePrompt from "@/components/payment/WalletUsagePrompt";
|
||||
|
||||
const PRESET_AMOUNTS = [2000, 5000, 10000, 15000, 20000, 50000, 100000];
|
||||
const CUSTOM_MIN = 5000;
|
||||
@@ -33,6 +34,8 @@ function GiftPage({ params }: GiftPageProps) {
|
||||
const [isCustom, setIsCustom] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [walletBalance, setWalletBalance] = useState(0);
|
||||
const [showWalletPrompt, setShowWalletPrompt] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUser = async () => {
|
||||
@@ -43,6 +46,10 @@ function GiftPage({ params }: GiftPageProps) {
|
||||
setUserDetail(response?.user);
|
||||
};
|
||||
void fetchUser();
|
||||
request<{ charged?: { available?: number } }>("GET", "/wallet/summary")
|
||||
.then((res) => setWalletBalance(res?.charged?.available || 0))
|
||||
.catch(() => setWalletBalance(0));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [userId, request]);
|
||||
|
||||
const finalAmount = useMemo(() => {
|
||||
@@ -56,15 +63,24 @@ function GiftPage({ params }: GiftPageProps) {
|
||||
|
||||
const canSubmit = finalAmount > 0 && isCustomValid && !submitting;
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const handleSubmit = async (useWallet: boolean) => {
|
||||
if (!canSubmit) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const response = await request<{ authority?: string; paymentUrl?: string }>(
|
||||
"POST",
|
||||
"/gifts/initiate",
|
||||
{ receiverId: userId, amount: finalAmount, message }
|
||||
);
|
||||
const response = await request<{
|
||||
authority?: string;
|
||||
paymentUrl?: string;
|
||||
paidFromWallet?: boolean;
|
||||
}>("POST", "/gifts/initiate", {
|
||||
receiverId: userId,
|
||||
amount: finalAmount,
|
||||
message,
|
||||
useWallet,
|
||||
});
|
||||
if (response?.paidFromWallet) {
|
||||
toast.success(t("wallet.paidFromWalletSuccess"));
|
||||
return;
|
||||
}
|
||||
// لینک خارجی — router.push رفتارِ تضمینشدهای نداره؛ window.location.href
|
||||
// مطمئنتره (خصوصاً سافاری)
|
||||
if (response?.paymentUrl) {
|
||||
@@ -84,6 +100,15 @@ function GiftPage({ params }: GiftPageProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handlePayClick = () => {
|
||||
if (!canSubmit) return;
|
||||
if (walletBalance > 0) {
|
||||
setShowWalletPrompt(true);
|
||||
return;
|
||||
}
|
||||
void handleSubmit(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="mx-3 mb-10">
|
||||
@@ -167,11 +192,21 @@ function GiftPage({ params }: GiftPageProps) {
|
||||
variant="primary"
|
||||
className="px-8 py-2"
|
||||
disabled={!canSubmit}
|
||||
onClick={() => void handleSubmit()}
|
||||
onClick={handlePayClick}
|
||||
>
|
||||
{t("giftPage.payButton")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
|
||||
<WalletUsagePrompt
|
||||
isOpen={showWalletPrompt}
|
||||
amount={finalAmount}
|
||||
walletBalance={walletBalance}
|
||||
onAnswer={(useWallet) => {
|
||||
setShowWalletPrompt(false);
|
||||
void handleSubmit(useWallet);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import type { Metadata } from "next";
|
||||
import Header from "@/components/main/Header";
|
||||
import TabNavigation from "@/components/TabNavigation";
|
||||
|
||||
// فرم ارسال هدیه به یه کاربر خاص — محتوای یکتای قابلرتبهبندی نداره (خودِ
|
||||
// پروفایل کاربر جای درستیه که ایندکس بشه)، پس noindex میکنیم.
|
||||
export const metadata: Metadata = {
|
||||
robots: { index: false, follow: true },
|
||||
};
|
||||
|
||||
export default function GiftLayout({
|
||||
children,
|
||||
}: {
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -797,22 +815,20 @@ select {
|
||||
}
|
||||
|
||||
.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,280 +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 بعدِ await در سافاری (دسکتاپ/iOS) پاپآپبلاک میشه چون دیگه
|
||||
// synchronously به کلیکِ کاربر متصل نیست؛ ضمناً بازگشتِ ZarinPal (از طریق
|
||||
// offer/payment-web/route.js) کلِ همون تب رو به "/" ریدایرکت میکنه، نه تبِ
|
||||
// اصلی — پس با _blank دو تب باز میموند. window.location.href هم امنتره
|
||||
// هم با بقیهی مسیرهای پرداختِ این اپ یکی میمونه
|
||||
window.location.href = data.paymentUrl;
|
||||
} else if (data.authority) {
|
||||
window.location.href = `https://www.zarinpal.com/pg/StartPay/${data.authority}`;
|
||||
} else {
|
||||
toast.error(t("offerPage.paymentInfoError"));
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("Payment initiation error:", err);
|
||||
toast.error(
|
||||
err?.response?.data?.error ||
|
||||
err?.response?.data?.message ||
|
||||
t("offerPage.paymentStartError")
|
||||
);
|
||||
} 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) => {
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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
|
||||
sizes="44px"
|
||||
className="object-cover"
|
||||
/>
|
||||
</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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -81,7 +81,7 @@ function Sizes() {
|
||||
className={`p-1 rounded-2xl w-1/3 border text-sm flex items-center justify-center gap-1 ${
|
||||
selectedButton === 1
|
||||
? "btn-modern--selected text-white"
|
||||
: "bg-tertiary-light dark:bg-tertiary-dark text-black border-gray-400"
|
||||
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
|
||||
}`}
|
||||
onClick={() => setSelectedButton(1)}
|
||||
>
|
||||
|
||||
@@ -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,7 +102,7 @@ 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"
|
||||
|
||||
@@ -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;
|
||||
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 { useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -31,7 +30,6 @@ type WalletSummary = {
|
||||
|
||||
export default function WalletSettingsPage() {
|
||||
const { t } = useTranslation("common");
|
||||
const searchParams = useSearchParams();
|
||||
const { request, loading } = useAxios();
|
||||
|
||||
const [bucket, setBucket] = useState<string>(BUCKET_SHOP);
|
||||
@@ -47,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
|
||||
}, []);
|
||||
|
||||
@@ -81,16 +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 رفتارِ تضمینشدهای نداره؛ window.location.href
|
||||
// مطمئنتره (خصوصاً سافاری)
|
||||
window.location.href = `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
|
||||
@@ -99,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">
|
||||
@@ -173,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>
|
||||
</>
|
||||
)}
|
||||
@@ -206,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>
|
||||
</>
|
||||
)}
|
||||
@@ -234,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>
|
||||
</>
|
||||
)}
|
||||
@@ -262,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>
|
||||
</>
|
||||
)}
|
||||
@@ -290,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>
|
||||
</>
|
||||
)}
|
||||
@@ -319,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>
|
||||
);
|
||||
}
|
||||
55
src/app/settings/wallet/payment/success/page.tsx
Normal file
55
src/app/settings/wallet/payment/success/page.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
"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 Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function WalletPaymentSuccessPage() {
|
||||
const { t } = useTranslation("common");
|
||||
const searchParams = useSearchParams();
|
||||
const amount = Number(searchParams.get("amount") || 0);
|
||||
|
||||
return (
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<PageTitle>{t("shops.paymentSuccessTitle")}</PageTitle>
|
||||
|
||||
<div className="flex flex-col items-center text-sm">
|
||||
<Image
|
||||
width={50}
|
||||
height={50}
|
||||
alt=""
|
||||
src="/images/icons/success.svg"
|
||||
className="mb-5 pb-1"
|
||||
/>
|
||||
|
||||
<p className="text-center text-sm text-neutral-500">
|
||||
{t("shops.paymentSuccessHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{amount > 0 && (
|
||||
<div className="mt-8 w-full px-4">
|
||||
<RoundedDiv className="w-full p-3 text-center">
|
||||
{amount.toLocaleString()} {t("settings.toman")}
|
||||
</RoundedDiv>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8 flex justify-center">
|
||||
<Link href="/settings/wallet">
|
||||
?
|
||||
<RoundedButton variant="primary" className="h-9 w-40">
|
||||
?
|
||||
{t("settings.confirm")}
|
||||
</RoundedButton>
|
||||
</Link>
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
@@ -1,8 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Container from "@/components/elements/Container";
|
||||
import EmptyState from "@/components/elements/EmptyState";
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import MainProjectCard from "@/components/projects/MainProjectCard";
|
||||
import PageTitle from "@/components/settings/PageTitle";
|
||||
@@ -36,7 +39,8 @@ const Workroom = () => {
|
||||
const { t } = useTranslation("common");
|
||||
const router = useRouter();
|
||||
const user = useUser();
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
const searchParams = useSearchParams();
|
||||
const [statusFilter, setStatusFilter] = useState(searchParams.get("filter") === "unpaid" ? "unpaid" : "");
|
||||
const [showDrafts, setShowDrafts] = useState(false);
|
||||
const [drafts, setDrafts] = useState<ProjectDraft[]>([]);
|
||||
|
||||
@@ -162,9 +166,11 @@ const Workroom = () => {
|
||||
) : data?.pages.length === 0 ||
|
||||
(data?.pages[0]?.projects?.length === 0 &&
|
||||
!isFetchingNextPage) ? (
|
||||
<p className="text-center text-gray-500">
|
||||
{t("settings.workroom.empty")}
|
||||
</p>
|
||||
<EmptyState
|
||||
icon="briefcase"
|
||||
title={t("settings.workroom.empty")}
|
||||
subtitle={t("settings.workroom.emptyHint")}
|
||||
/>
|
||||
) : (
|
||||
data?.pages?.map((page, pageIndex) => (
|
||||
<div key={pageIndex}>
|
||||
|
||||
15
src/app/shops/[shopId]/layout.tsx
Normal file
15
src/app/shops/[shopId]/layout.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
// این مسیر داشبورد مدیریت محصولاتِ خودِ فروشندهست (نسخهی عمومیِ سئوشده در
|
||||
// /shops/profile/[shopId] هست) — پس noindex میکنیم تا محتوای تکراری ایندکس نشه.
|
||||
export const metadata: Metadata = {
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export default function ShopManageLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return children;
|
||||
}
|
||||
@@ -40,7 +40,6 @@ function SpecRow({
|
||||
<div className="flex flex-1 flex-wrap items-center justify-end gap-2 text-right">
|
||||
{children}
|
||||
</div>
|
||||
<BoldIcon name="arrow-down" size={16} tinted className="shrink-0 text-neutral-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -439,7 +438,7 @@ export default function ProductManagePage() {
|
||||
<span className="text-xs text-neutral-400 line-through">
|
||||
{matchedVariant.price.toLocaleString()} {t("settings.toman")}
|
||||
</span>
|
||||
<span className="text-sm font-bold text-red-500">
|
||||
<span className="text-sm font-bold text-red-500 dark:text-white">
|
||||
{matchedVariant.discount_price.toLocaleString()} {t("settings.toman")}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useEffect, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import WalletUsagePrompt from "@/components/payment/WalletUsagePrompt";
|
||||
|
||||
type OrderDetail = {
|
||||
_id: string;
|
||||
@@ -37,21 +38,31 @@ export default function CheckoutPage() {
|
||||
const params = useParams<{ orderId: string }>();
|
||||
const { request, loading } = useAxios();
|
||||
const [order, setOrder] = useState<OrderDetail | null>(null);
|
||||
const [walletBalance, setWalletBalance] = useState(0);
|
||||
const [showWalletPrompt, setShowWalletPrompt] = useState(false);
|
||||
const [paidFromWallet, setPaidFromWallet] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
request<{ order: OrderDetail }>("GET", `/orders/${params.orderId}`)
|
||||
.then((res) => setOrder(res?.order || null))
|
||||
.catch(() => setOrder(null));
|
||||
request<{ charged?: { available?: number } }>("GET", "/wallet/summary")
|
||||
.then((res) => setWalletBalance(res?.charged?.available || 0))
|
||||
.catch(() => setWalletBalance(0));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [params.orderId]);
|
||||
|
||||
const handlePay = async () => {
|
||||
const startPayment = async (useWallet: boolean) => {
|
||||
try {
|
||||
const response = await request<{ authority?: string }>(
|
||||
const response = await request<{ authority?: string; paidFromWallet?: boolean }>(
|
||||
"POST",
|
||||
"/payment/shop-order/initiate",
|
||||
{ orderId: params.orderId }
|
||||
{ orderId: params.orderId, useWallet }
|
||||
);
|
||||
if (response?.paidFromWallet) {
|
||||
setPaidFromWallet(true);
|
||||
return;
|
||||
}
|
||||
if (response?.authority) {
|
||||
// router.push (next/navigation) برای URLهای بیرونی رفتارِ تضمینشدهای نداره —
|
||||
// window.location.href مطمئنترین راهه (خصوصاً سافاری) و با بقیهی مسیرهای
|
||||
@@ -66,8 +77,29 @@ export default function CheckoutPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handlePay = () => {
|
||||
if (walletBalance > 0) {
|
||||
setShowWalletPrompt(true);
|
||||
return;
|
||||
}
|
||||
void startPayment(false);
|
||||
};
|
||||
|
||||
if (!order) return null;
|
||||
|
||||
if (paidFromWallet) {
|
||||
return (
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<PageTitle>{t("shops.checkoutTitle")}</PageTitle>
|
||||
<p className="mx-auto mt-10 max-w-md text-center text-sm font-semibold text-green-600">
|
||||
{t("wallet.paidFromWalletSuccess")}
|
||||
</p>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
const primaryImage =
|
||||
order.listing.images?.[order.listing.primaryImageIndex] ||
|
||||
order.listing.images?.[0];
|
||||
@@ -151,6 +183,16 @@ export default function CheckoutPage() {
|
||||
{t("shops.proceedToPayment")}
|
||||
</AuthNextButton>
|
||||
</div>
|
||||
|
||||
<WalletUsagePrompt
|
||||
isOpen={showWalletPrompt}
|
||||
amount={order.total_amount}
|
||||
walletBalance={walletBalance}
|
||||
onAnswer={(useWallet) => {
|
||||
setShowWalletPrompt(false);
|
||||
void startPayment(useWallet);
|
||||
}}
|
||||
/>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
|
||||
23
src/app/shops/discover/layout.tsx
Normal file
23
src/app/shops/discover/layout.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { Metadata } from "next";
|
||||
import { generatePageMetadata } from "@/utils/generatePageMetadata";
|
||||
import { getServerLanguage } from "@/lib/i18n/server";
|
||||
import { translateCommon } from "@/lib/i18n/translate";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const lang = await getServerLanguage();
|
||||
|
||||
return generatePageMetadata({
|
||||
title: translateCommon(lang, "shopsDiscoverMeta.title"),
|
||||
description: translateCommon(lang, "shopsDiscoverMeta.description"),
|
||||
path: "/shops/discover",
|
||||
lang,
|
||||
});
|
||||
}
|
||||
|
||||
export default function ShopsDiscoverLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return children;
|
||||
}
|
||||
@@ -44,7 +44,6 @@ function SpecRow({ label, children }: { label: string; children: React.ReactNode
|
||||
<div className="flex flex-1 flex-wrap items-center justify-end gap-2 text-right">
|
||||
{children}
|
||||
</div>
|
||||
<BoldIcon name="arrow-down" size={16} tinted className="shrink-0 text-neutral-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -285,12 +284,15 @@ export default function ListingDetailClient() {
|
||||
<BackButton />
|
||||
</div>
|
||||
{/* طبق خواستهی کاربر: دکمهی اشتراکگذاریِ خودِ کالا (نه فروشگاه) روبروی نام
|
||||
فروشگاه سمت چپ — عینِ ProductDetailScreen.tsx اندروید */}
|
||||
فروشگاه سمت چپ — عینِ ProductDetailScreen.tsx اندروید. Link (آواتار+نام)
|
||||
flex-1 داره تا فضای خالی رو خودش ببلعه؛ وگرنه justify-between روی سه فرزند
|
||||
(این Link + دو دکمه) این فضا رو مساوی پخش میکرد و فاصلهی دو دکمه هم بهاندازهی
|
||||
فاصلهشون تا نام فروشگاه (خیلی بیشتر از حد معمول) میشد — عینِ shops/[shopId]/page.tsx */}
|
||||
{shopInfo?._id ? (
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<Link
|
||||
href={`/shops/profile/${shopInfo._id}`}
|
||||
className="flex items-center gap-2"
|
||||
className="flex min-w-0 flex-1 items-center gap-2"
|
||||
>
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-full bg-neutral-100 dark:bg-neutral-800">
|
||||
{shopInfo?.logo ? (
|
||||
@@ -310,7 +312,7 @@ export default function ListingDetailClient() {
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-sm font-bold">{shopInfo?.name}</span>
|
||||
<span className="truncate text-sm font-bold">{shopInfo?.name}</span>
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
@@ -524,7 +526,9 @@ export default function ListingDetailClient() {
|
||||
<span className="text-xs text-neutral-400 line-through">
|
||||
{(matchedVariant.price * quantity + shippingCost).toLocaleString()} {t("settings.toman")}
|
||||
</span>
|
||||
<span className="text-sm font-bold text-red-500">
|
||||
{/* توی تم تاریک قرمزِ ثابت روی پسزمینهی تیره کنتراست کمی داره — طبق
|
||||
خواستهی کاربر توی تاریک سفید بشه */}
|
||||
<span className="text-sm font-bold text-red-500 dark:text-white">
|
||||
{(matchedVariant.discount_price * quantity + shippingCost).toLocaleString()} {t("settings.toman")}
|
||||
</span>
|
||||
</span>
|
||||
@@ -560,7 +564,7 @@ export default function ListingDetailClient() {
|
||||
<TabNavigation currentPage="/settings" />
|
||||
|
||||
{showAddressModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-transparent p-4">
|
||||
<div className="w-full max-w-sm rounded-2xl bg-white p-6 text-center dark:bg-neutral-900">
|
||||
<p className="mb-4 text-sm">{t("shops.addressRequiredHint")}</p>
|
||||
<button
|
||||
|
||||
@@ -26,7 +26,10 @@ function uniqueValues(values: (string | null | undefined)[]): string[] {
|
||||
|
||||
// Deduped per request — see loadUser in users/[username]/page.tsx for rationale.
|
||||
const loadListing = cache(async (listingId: string) => {
|
||||
const { data } = await fetchApiJson<ListingResponse>(`/shop-products/${listingId}`);
|
||||
const { data } = await fetchApiJson<ListingResponse>(
|
||||
`/shop-products/${listingId}`,
|
||||
{ next: { revalidate: 45 } }
|
||||
);
|
||||
return data?.listing ?? null;
|
||||
});
|
||||
|
||||
|
||||
@@ -66,22 +66,22 @@ export default function ShopPaymentFailedPage() {
|
||||
/>
|
||||
<span>{t("shops.paymentFailedHint")}</span>
|
||||
</div>
|
||||
<div className="mt-10 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">
|
||||
<RoundedButton
|
||||
variant="primary"
|
||||
className="h-9 w-32"
|
||||
className="h-9 flex-1"
|
||||
onClick={retry}
|
||||
disabled={loading || !orderId}
|
||||
>
|
||||
{t("shops.retryPayment")}
|
||||
</RoundedButton>
|
||||
<RoundedButton
|
||||
className="!border-transparent h-9 w-32 !bg-neutral-100 text-neutral-600 dark:!bg-neutral-800 dark:text-neutral-300"
|
||||
className="!border-transparent h-9 flex-1 !bg-neutral-100 text-neutral-600 dark:!bg-neutral-800 dark:text-neutral-300"
|
||||
onClick={() =>
|
||||
router.push(shopId ? `/shops/profile/${shopId}` : "/shops/discover")
|
||||
router.push("/settings/shop")
|
||||
}
|
||||
>
|
||||
{t("shops.backToShopList")}
|
||||
{t("common.back")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
@@ -32,17 +32,9 @@ export default function ShopPaymentSuccessPage() {
|
||||
<RoundedButton
|
||||
variant="primary"
|
||||
className="h-9 w-48"
|
||||
onClick={() =>
|
||||
router.push(orderId ? `/shops/orders/${orderId}` : "/settings/shop")
|
||||
}
|
||||
onClick={() => router.push("/settings/shop")}
|
||||
>
|
||||
{t("shops.viewOrder")}
|
||||
</RoundedButton>
|
||||
<RoundedButton
|
||||
className="!border-transparent h-9 w-48 !bg-neutral-100 text-neutral-600 dark:!bg-neutral-800 dark:text-neutral-300"
|
||||
onClick={() => router.push("/")}
|
||||
>
|
||||
{t("shops.backToHome")}
|
||||
{t("settings.confirm")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
@@ -30,7 +30,10 @@ type PublicShop = {
|
||||
// this shop; cache() keeps them to a single backend fetch (see loadUser in
|
||||
// users/[username]/page.tsx for the full rationale).
|
||||
const loadShop = cache(async (shopId: string) => {
|
||||
const { data } = await fetchApiJson<{ shop?: PublicShop }>(`/shops/public/${shopId}`);
|
||||
const { data } = await fetchApiJson<{ shop?: PublicShop }>(
|
||||
`/shops/public/${shopId}`,
|
||||
{ next: { revalidate: 45 } }
|
||||
);
|
||||
return data?.shop ?? null;
|
||||
});
|
||||
|
||||
@@ -97,6 +100,7 @@ export default async function ShopProfilePage({ params }: IShopProfilePageProps)
|
||||
|
||||
return (
|
||||
<>
|
||||
{shop && <h1 className="sr-only">{shop.name}</h1>}
|
||||
{jsonLd && (
|
||||
<script
|
||||
type="application/ld+json"
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
countCoursesPages,
|
||||
countPostsPages,
|
||||
countProjectsPages,
|
||||
countShopListingsPages,
|
||||
countShopProfilesPages,
|
||||
countUsersPages,
|
||||
} from "@/lib/sitemap/builders";
|
||||
|
||||
@@ -35,6 +37,8 @@ export async function GET() {
|
||||
billboardsPages,
|
||||
coursesPages,
|
||||
academiesPages,
|
||||
shopListingsPages,
|
||||
shopProfilesPages,
|
||||
] = await Promise.all([
|
||||
countPostsPages().catch(() => 1),
|
||||
countUsersPages().catch(() => 1),
|
||||
@@ -42,6 +46,8 @@ export async function GET() {
|
||||
countBillboardsPages().catch(() => 1),
|
||||
countCoursesPages().catch(() => 1),
|
||||
countAcademiesPages().catch(() => 1),
|
||||
countShopListingsPages().catch(() => 1),
|
||||
countShopProfilesPages().catch(() => 1),
|
||||
]);
|
||||
|
||||
const parts: string[] = [
|
||||
@@ -67,6 +73,12 @@ export async function GET() {
|
||||
for (let i = 1; i <= academiesPages; i++) {
|
||||
parts.push(sitemapLoc(`/sitemaps/academies/${i}.xml`));
|
||||
}
|
||||
for (let i = 1; i <= shopListingsPages; i++) {
|
||||
parts.push(sitemapLoc(`/sitemaps/shop-listings/${i}.xml`));
|
||||
}
|
||||
for (let i = 1; i <= shopProfilesPages; i++) {
|
||||
parts.push(sitemapLoc(`/sitemaps/shop-profiles/${i}.xml`));
|
||||
}
|
||||
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user