Compare commits

..

6 Commits

Author SHA1 Message Date
payacom
551260a836 reels 2026-10-09 15:44:12 +03:30
payacom
f364f989ed pay 2026-10-06 17:07:08 +03:30
payacom
d6386b779f e2ee 2026-10-02 18:12:58 +03:30
payacom
9fb454b0a7 reels 2026-10-01 21:11:37 +03:30
payacom
6e76e367f3 4.2.1 2026-09-25 13:35:34 +03:30
payacom
200b387504 full app 2026-09-24 20:56:23 +03:30
231 changed files with 9240 additions and 4373 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

4
package-lock.json generated
View File

@@ -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",

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,
});

View File

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

View File

@@ -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>

View File

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

View File

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

View File

@@ -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 =

View File

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

View File

@@ -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>
);
}

View File

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

View File

@@ -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>

View File

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

View File

@@ -1,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 />
</>
);
}

View 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>
);
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 ? (

View File

@@ -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;

View File

@@ -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")}

View File

@@ -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>

View File

@@ -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>

View File

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

View File

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

View File

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

View File

@@ -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 />
</>
);
}

View File

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

View File

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

View File

@@ -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>
);

View File

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

View File

@@ -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 {

View File

@@ -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>
);
}

View File

@@ -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
View File

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

View File

@@ -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;

View File

@@ -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>
);

View File

@@ -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>
);

View File

@@ -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>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ import React, { useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import Container from "@/components/elements/Container";
import EmptyState from "@/components/elements/EmptyState";
import BackButton from "@/components/ui/BackButton";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import VerificationBadge from "@/components/main/VerificationBadge";
@@ -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>
);

View File

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

View File

@@ -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">

View File

@@ -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"
: ""
}`}

View File

@@ -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")

View 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>
);
}

View File

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

View File

@@ -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"

View File

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

View File

@@ -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={() =>

View File

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

View File

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

View File

@@ -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) ? (

View File

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

View File

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

View File

@@ -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>
);
}

View 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>
);
}

View 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>
);
}

View File

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

View File

@@ -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>
);
}

View 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>
);
}

View 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>

View File

@@ -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}>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>

View File

@@ -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>

View File

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

View File

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

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