Compare commits

..

31 Commits

Author SHA1 Message Date
payacom
76ef4e32a9 order 2026-09-01 13:51:50 +03:30
payacom
1ba6646bf9 project 2026-08-18 20:22:07 +03:30
payacom
0c6e1a7125 lang full 2026-08-16 11:42:39 +03:30
payacom
c879976c56 reels 2026-08-14 21:31:11 +03:30
payacom
a92a3ca5e6 lang 2026-08-13 21:20:23 +03:30
payacom
ef2e86861a instagram 2026-08-13 13:09:25 +03:30
payacom
54623fb720 verify 2026-08-11 19:39:10 +03:30
payacom
491040f752 rezerv 2026-08-05 15:12:39 +03:30
payacom
89320d57b9 shop full 2026-08-04 21:12:03 +03:30
payacom
dc4467d43d shop 2026-08-02 21:20:08 +03:30
payacom
5ecbcd1f26 shop 2026-07-31 21:20:13 +03:30
payacom
67f59eefdf post 2026-07-28 10:06:42 +03:30
payacom
a1d7db5e7e lang 2026-07-23 15:58:15 +03:30
payacom
af5b27b610 full update 2026-07-18 14:29:05 +03:30
payacom
17f334d0f1 chat 2026-07-16 21:31:59 +03:30
payacom
58314d0ab5 login 2026-07-16 20:08:49 +03:30
payacom
39b12a0986 chat 2026-07-12 19:01:04 +03:30
payacom
20c780f104 icone 2026-07-12 12:48:02 +03:30
payacom
94b8ddc589 seo 2026-07-12 11:52:53 +03:30
payacom
d83e91dd2d updata 2026-07-11 21:38:41 +03:30
payacom
402f199e58 icons 2026-07-11 20:59:27 +03:30
payacom
0864ae4723 explor 2026-07-11 20:48:36 +03:30
payacom
8e75913441 pic 2026-07-11 18:05:22 +03:30
payacom
5dfbbdc33b post pic 2026-07-11 17:00:41 +03:30
payacom
c6db3cee05 post 2026-07-11 16:48:45 +03:30
payacom
2863cdf490 updata 2026-07-10 18:17:11 +03:30
Amirreza
ce1becc392 .. 2026-07-09 23:27:25 +03:30
Amirreza
a7cdd18ab7 test 2026-07-09 23:13:16 +03:30
Amirreza
ceddb54019 .. 2026-07-09 23:09:32 +03:30
Amirreza
349f87b069 test 2026-07-09 22:56:18 +03:30
payacom
edb094e323 Initial commit 2026-07-09 15:26:46 +03:30
802 changed files with 744588 additions and 19938 deletions

View File

@@ -15,8 +15,8 @@ COPY . .
ENV NEXT_PUBLIC_SITE_URL=https://modstagram.com
ENV NODE_ENV=production
# Build با ignore errors
RUN npm run build || true
# Build (fail loudly on error)
RUN npm run build
# مرحله اجرا
FROM node:20-alpine AS runner
@@ -35,9 +35,9 @@ COPY --from=builder /app/next-sitemap.config.js ./
ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=3001
ENV NEXT_PUBLIC_SITE_URL=https://modstagram.com
EXPOSE 3000
EXPOSE 3001
# اجرا با ignore errors
CMD ["sh", "-c", "npm start || true"]
CMD ["npm", "start"]

View File

@@ -1,24 +1,24 @@
"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;
"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,142 +1,151 @@
"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, 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";
const validationSchema = Yup.object({
stateId: Yup.string().required("انتخاب استان الزامی است"),
cityId: Yup.string().required("انتخاب شهر الزامی است"),
categoryId: Yup.string().required("لطفا دسته‌بندی را انتخاب کنید"),
adsTitle: Yup.string().required("لطفا عنوان وارد کنید"),
description: Yup.string().required("توضیحات الزامی است"),
neighbourhood: Yup.string().required("محله الزامی است"),
address: Yup.string().required("آدرس الزامی است"),
images: Yup.array()
.min(1, "حداقل یک عکس انتخاب کنید")
.max(5, "حداکثر ۵ عکس می‌توانید انتخاب کنید")
.required("انتخاب تصویر الزامی است"),
markerCoordinate: Yup.array()
.of(Yup.number().required())
.length(2, "انتخاب موقعیت روی نقشه الزامی است")
.required("انتخاب موقعیت روی نقشه الزامی است"),
});
const Step1 = ({ nextStep }: { nextStep: () => void }) => {
const { formData, updateForm } = useBillboardForm();
const [categories, setCategories] = useState<IAdvertisingCategory[]>([]);
const { request } = useAxios();
useEffect(() => {
const fetchData = async () => {
try {
const response = await request<{ categories: IAdvertisingCategory[] }>(
"GET",
"/advertising/categories"
);
setCategories(response?.categories);
} catch (err) {
console.log(err);
}
};
fetchData();
}, []);
console.log(categories);
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();
},
});
console.log(formik?.values);
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">ثبت بیلبورد</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="">
دسته بندی
</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="عنوان"
{...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="توضیحات"
{...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" className="p-2 px-8 rounded mt-4">
ثبت و ادامه
</RoundedButton>
</form>
);
};
export default Step1;
"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,87 +1,89 @@
"use client";
import RoundedButton from "@/components/elements/RoundedButton";
import { useFormik } from "formik";
import * as Yup from "yup";
import { useBillboardForm } from "@/contexts/BillboardFormContext";
import { useState } from "react";
import { Service } from "@/types/types";
import ServiceItem from "@/components/main/Services/ServiceItem";
import AddServiceModal from "@/components/main/Services/AddServiceModal";
const validationSchema = Yup.object({
services: Yup.array().min(1, "حداقل یک خدمت یا کالا انتخاب کنید"),
});
const Step2 = ({ nextStep }: { nextStep: () => void }) => {
const { formData, updateForm } = useBillboardForm();
const [isModalOpen, setModalOpen] = useState(false);
const formik = useFormik({
initialValues: {
services: formData.services || [],
},
validationSchema,
onSubmit: (values) => {
updateForm(values);
nextStep();
},
});
// لیست سرویس‌ها از فرمیک مقداردهی می‌شود
const services = formik.values.services;
const handleAddService = (newService: Service) => {
const updatedServices = [...services, newService];
formik.setFieldValue("services", updatedServices);
};
const handleDeleteService = (id: string) => {
const updatedServices = services.filter((service) => service.id !== id);
formik.setFieldValue("services", updatedServices);
};
console.log(formik?.values);
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">ثبت بیلبورد</h6>
<div className="gap-4 min-h-96">
{services.map((service) => (
<ServiceItem
onDelete={handleDeleteService}
key={crypto.randomUUID()}
service={service}
/>
))}
</div>
{/* {formik.touched.services && formik.errors.services && (
<p className="text-red-500 text-xs">{formik.errors.services}</p>
)} */}
<hr />
<div className="flex gap-4 mb-4 ">
<RoundedButton type="submit" className="w-32 rounded h-9">
ثبت و ادامه
</RoundedButton>
<RoundedButton
onClick={() => setModalOpen(true)}
className="!text-[#0066FF] !border-[#0066FF] w-32 h-9"
type="button"
>
افزودن
</RoundedButton>
</div>
{isModalOpen && (
<AddServiceModal
onClose={() => setModalOpen(false)}
onAdd={handleAddService}
isModalOpen={isModalOpen}
/>
)}
</form>
);
};
export default Step2;
"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,174 +1,176 @@
"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, useState } from "react";
export interface IFeatureFetch {
title?: string;
value: boolean;
_id: string;
}
const validationSchema = Yup.object({
selectedFeatures: Yup.array().min(
1,
"حداقل یکی از امکانات را باید انتخاب کنید."
),
});
const Step3 = ({ nextStep }: { nextStep: () => void }) => {
const { formData, updateForm } = useBillboardForm();
const { request } = useAxios();
const [features, setFeatures] = useState<IFeatureFetch[]>([]);
useEffect(() => {
const fetchData = async () => {
try {
const response = await request<{ features: IFeatureFetch[] }>(
"GET",
"/advertising/features"
);
setFeatures(response.features);
} catch (err) {
console.log(err);
}
};
fetchData();
}, []);
const formik = useFormik({
initialValues: {
selectedFeatures: formData.selectedFeatures || [],
},
validationSchema,
onSubmit: (values) => {
updateForm(values);
nextStep();
},
enableReinitialize: true, // اضافه کردن این برای همگام‌سازی initialValues با formData
});
const handleSelect = (
featureId: string,
value: boolean,
title: string | undefined
) => {
// به‌روزرسانی ویژگی انتخاب‌شده در Formik
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">ثبت بیلبورد</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>دارد</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>ندارد</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" className="w-32 rounded h-9 mt-4">
ثبت و ادامه
</RoundedButton>
</form>
);
};
export default Step3;
"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,191 +1,193 @@
"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";
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 { formData, updateForm } = useBillboardForm();
const { request } = useAxios();
const [isCheckedOne, setIsCheckedOne] = useState<boolean>(false);
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();
}, []);
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("لطفا حداقل یک راه ارتباطی را وارد کنید.");
} else {
updateForm(values);
nextStep();
}
},
});
const toggleCheckBox = () => {
formik.setFieldValue("saveData", !isCheckedOne);
setIsCheckedOne(!isCheckedOne);
};
console.log(formik?.values);
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">ثبت بیلبورد</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="تلفن ثابت"
{...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="شماره موبایل"
{...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="آیدی تلگرام"
{...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="شماره واتساپ"
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="آیدی اینستاگرام"
{...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>اطلاعات من را برای ویترین های بعدی ذخیره کن</span>
</label>
<hr />
<RoundedButton type="submit" className="p-2 px-8 rounded mt-4">
ثبت و ادامه
</RoundedButton>
</form>
);
};
export default Step4;
"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,281 +1,268 @@
/* 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, 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 { useRouter } from "next/navigation";
import MainBillboardCard from "../MainBillboardCard/MainBillboardCard";
import { dataURLtoBlob } from "@/helpers/helpers";
import { useBillboardForm } from "@/contexts/BillboardFormContext";
const validationSchema = Yup.object({
selectedType: Yup.string().required(" انتخاب کردن نوع پروژه الزامی است"),
});
interface CreateAdvertisingResponse {
success: boolean;
message: string;
data: {
id: string;
};
}
const Step5 = () => {
const router = useRouter();
const { request } = useAxios();
const { formData, updateForm, isEditing } = useBillboardForm();
const [selectedType, setSelectedType] = useState("normal");
const [showDiscount, setShowDiscount] = useState<boolean>(false); // New state for checkbox
const [typeList, setTypeList] = useState<IAdvertisingType[] | null>(null);
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) => {
// بررسی و تبدیل مقدار discountPercentage به عدد
const discount =
typeof service.discountPercentage === "number"
? service.discountPercentage
: 0;
return discount > max ? discount : max;
}, 0)
: 0;
if (mostDiscountPercentage) {
data.append("mostDiscountPercentage", String(mostDiscountPercentage));
}
// const modifiedServices = services.map((service) => ({
// ...service,
// image: "", // جایگزین کردن مقدار image با یک رشته خالی
// }));
// data.append("services", JSON.stringify(modifiedServices));
// data.append("services", JSON.stringify(services));
// افزودن تصاویر به فرم داده‌ها
images?.forEach((image) => {
if (typeof image === "string" && image.startsWith("/advertising/")) {
data.append("existingImages[]", image);
} else {
const blob = dataURLtoBlob(image); // تبدیل Base64 به Blob
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); // تبدیل Base64 به Blob
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");
// console.log(response?.data?.id);
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">نوع نمایش</h6>
<p>نوع نمایش بیلبورد خود برای کاربران را مشخص کنید</p>
<div className="flex items-center gap-2 mt-10">
<input
className="scale-125"
type="checkbox"
onChange={toggleCheckBox}
/>
<span className="text-xs font-bold">
نمایش درصد تخفیف (+20،000 تومان)
</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={`w-full max-w-full p-2 ${
selectedType == item?.name ? "bg-[#FC8EAC]" : ""
}`}
>
{item.name === "normal"
? "نمایش ساده"
: item.name === "free"
? "نمایش ساده"
: item.name === "special"
? "نمایش با آیکون ویژه"
: "نمایش با رنگ پس زمینه متفاوت"}
:
{item.price !== 0
? !showDiscount
? Number(item.price).toLocaleString() + "تومان"
: (Number(item.price) + 20000).toLocaleString() + "تومان"
: " رایگان"}
</RoundedDiv>
</div>
);
})}
{formik.touched.selectedType && formik.errors.selectedType && (
<p className="text-red-500">{formik.errors.selectedType}</p>
)}
<RoundedButton type="submit" className="p-2 px-8 rounded mt-4">
ثبت درخواست
</RoundedButton>
</form>
);
};
export default Step5;
/* 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,86 +1,90 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl";
import { useState } from "react";
type ImageUploaderProps = {
formik: any;
};
const ImageUploader: React.FC<ImageUploaderProps> = ({ formik }) => {
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("حداکثر ۵ عکس می‌توانید انتخاب کنید");
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;
/* 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,199 +1,202 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
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";
interface LocationSelectorProps {
formik: any;
}
const LocationSelector: React.FC<LocationSelectorProps> = ({ formik }) => {
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); // Fetch cities for the selected province
};
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="">
استان
</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="">
شهر
</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="محله "
{...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 border-border-secondary-light dark:border-border-secondary-dark bg-secondary-light dark:bg-secondary-dark font-medium`}
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="آدرس"
{...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;
/* 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

@@ -6,10 +6,11 @@ services:
container_name: modstagram-next
restart: unless-stopped
ports:
- "3004:3000"
- "3001:3001"
environment:
- NODE_ENV=production
- HOST=0.0.0.0
- PORT=3001
volumes:
- ./:/app
- /app/node_modules

29
ecosystem.config.cjs Normal file
View File

@@ -0,0 +1,29 @@
/** PM2 — production frontend (nginx upstream — usually port 3004 on server) */
module.exports = {
apps: [
{
name: "modstagram-next",
cwd: __dirname,
script: "node_modules/next/dist/bin/next",
args: "start --port 3004 --hostname 0.0.0.0",
instances: 1,
exec_mode: "fork",
autorestart: true,
max_memory_restart: "800M",
env: {
NODE_ENV: "production",
PORT: "3004",
HOSTNAME: "0.0.0.0",
UPSTREAM_API_URL: "https://api.modstagram.com",
NEXT_PUBLIC_SOCKET_URL: "https://api.modstagram.com",
NEXT_PUBLIC_IMAGE_BASE_URL: "https://api.modstagram.com/storage",
NEXT_PUBLIC_BASE_URL: "/api/v1",
NEXT_PUBLIC_SITE_URL: "https://modstagram.com",
GOOGLE_CLIENT_ID:
"91028483292-vba2ktgbvus1p5ce18ir04h1b5c50gnn.apps.googleusercontent.com",
NEXT_PUBLIC_GOOGLE_CLIENT_ID:
"91028483292-vba2ktgbvus1p5ce18ir04h1b5c50gnn.apps.googleusercontent.com",
},
},
],
};

View File

@@ -1,218 +1,13 @@
// next-sitemap.config.js
/** @type {import('next-sitemap').IConfig} */
/**
* DISABLED — sitemap داینامیک از App Router سرو می‌شود:
* /sitemap.xml
* /sitemaps/static.xml | filters.xml | posts/N.xml | ...
*
* اسکریپت postbuild دیگر next-sitemap را اجرا نمی‌کند.
* این فایل فقط برای مرجع نگه داشته شده است.
*/
module.exports = {
siteUrl: 'https://modstagram.com', // دامنه اصلی سایت شما
generateRobotsTxt: true, // تولید فایل robots.txt
sitemapSize: 7000, // حداکثر تعداد URL در هر فایل سایت‌مپ
changefreq: 'daily', // فرکانس پیش‌فرض تغییر صفحات
priority: 0.7, // اولویت پیش‌فرض صفحات
// تابع transform برای فیلتر کردن و تنظیمات خاص هر مسیر
transform: async (config, path) => {
// مسیرهایی که نباید در سایت‌مپ باشند و نیازی به ایندکس شدن ندارند
const excludedPaths = [
'/settings',
'/auth',
'/new-post',
'/offer',
'/private/', // مسیرهای خصوصی
// صفحات مربوط به پرداخت و ثبت‌نام/ورود
'/projects/payment/success',
'/projects/payment/failed',
'/billboards/payment/success',
'/billboards/payment/failed',
'/forget-password',
'/change-passowrd',
'/login-with-username',
'/login',
'/register-otp',
'/verify-otp',
'/register',
'/forget-password-otp',
'/register/password',
'/register/fullname',
'/register/usertype',
'/register/username',
'/verify/avatar',
'/verify/auth',
'/verify/colors',
'/verify/confirm',
'/verify/expertise',
'/verify/gender',
'/verify/national-cart',
'/verify/location',
'/verify/public-relations',
'/verify/cooperation-type',
'/verify/services',
'/verify/sizes',
'/new-project', // اگر این یک صفحه فرم داخلی است که نیازی به ایندکس شدن ندارد
'/billboards/new', // اگر این یک صفحه فرم داخلی است که نیازی به ایندکس شدن ندارد
'/search', // اگر صفحه جستجو محتوای قابل ایندکس ندارد
];
// اگر مسیر در لیست excludedPaths باشد، آن را از سایت‌مپ حذف کن
if (excludedPaths.some(p => path.startsWith(p))) {
return null;
}
let pageChangefreq = config.changefreq;
let pagePriority = config.priority;
// تنظیمات خاص برای مسیرهای مختلف
if (path.startsWith('/users/')) { // مسیر برای پروفایل‌های کاربران
pageChangefreq = 'weekly';
pagePriority = 0.6;
}
if (path.startsWith('/projects/') || path.startsWith('/billboards/')) {
pageChangefreq = 'daily';
pagePriority = 0.8;
}
if (path === '/') { // صفحه اصلی
pageChangefreq = 'daily';
pagePriority = 1.0;
}
if (path === '/about-us') { // صفحه درباره ما
pageChangefreq = 'monthly';
pagePriority = 0.8;
}
return {
loc: path, // آدرس کامل صفحه
changefreq: pageChangefreq, // فرکانس تغییر صفحه
priority: pagePriority, // اولویت صفحه
lastmod: config.autoLastmod ? new Date().toISOString() : undefined, // تاریخ آخرین تغییر
alternateRefs: config.alternateRefs ?? [],
};
},
// تابع additionalPaths برای اضافه کردن مسیرهای داینامیک از API
additionalPaths: async (config) => {
const paths = [];
const BASE_URL_API = "https://app.modstagram.ir/api/v1"; // آدرس پایه API شما
paths.push({
loc: '/',
changefreq: 'daily',
priority: 1.0
});
paths.push({
loc: '/projects',
changefreq: 'daily',
priority: 0.9
});
paths.push({
loc: '/billboards',
changefreq: 'daily', // یا weekly بسته به میزان تغییر محتوا
priority: 0.9
});
try {
// واکشی داده‌های پروژه‌ها
const projectsRes = await fetch(`${BASE_URL_API}/projects?limit=500`);
const projectsData = await projectsRes.json();
if (projectsData && Array.isArray(projectsData.projects)) {
projectsData.projects.forEach((project) => {
if (project.id && project.title) {
paths.push({
// مسیر پروژه‌ها (با استفاده از تمپلیت لیتِرال صحیح)
loc: `/projects/${project.id}/${encodeURIComponent(project.title)}`,
changefreq: 'daily',
priority: 0.8
});
}
});
}
// واکشی داده‌های بیلبوردها
const billboardsRes = await fetch(`${BASE_URL_API}/advertising/web?limit=500`);
const billboardsData = await billboardsRes.json();
if (billboardsData && Array.isArray(billboardsData.advertisings)) {
billboardsData.advertisings.forEach((billboard) => {
if (billboard._id && billboard.title) {
paths.push({
// مسیر بیلبوردها (با استفاده از تمپلیت لیتِرال صحیح)
loc: `/billboards/${billboard._id}/${encodeURIComponent(billboard.title)}`,
changefreq: 'daily',
priority: 0.8
});
}
});
}
// واکشی داده‌های کاربران (مدل‌ها)
const usersRes = await fetch(`${BASE_URL_API}/users/web?limit=500`);
const usersData = await usersRes.json();
if (usersData && Array.isArray(usersData.users)) {
usersData.users.forEach((user) => {
if (user.user_name) {
paths.push({
// مسیر پروفایل کاربران (مسیر در Next.js شما /users/[username] است)
loc: `/users/${encodeURIComponent(user.user_name)}`,
changefreq: 'weekly',
priority: 0.6
});
}
});
}
} catch (error) {
// لاگ کردن خطا در صورت مشکل در واکشی داده‌ها از API
console.error('Error fetching additional paths for sitemap:', error);
}
return paths; // بازگرداندن آرایه‌ای از مسیرهای داینامیک
},
// تنظیمات مربوط به فایل robots.txt
robotsTxtOptions: {
policies: [
{
userAgent: '*', // اعمال سیاست‌ها برای همه ربات‌ها
allow: '/', // اجازه دسترسی به کل سایت
disallow: [
'/api/', // مسیرهای API
'/_next/', // فایل‌های داخلی Next.js
'/settings', // صفحات تنظیمات
'/auth', // صفحات احراز هویت (ورود، ثبت‌نام)
'/private/', // مسیرهای خصوصی موجود
'/new-post', // صفحات ایجاد پست جدید
'/offer', // صفحات پیشنهاد
// اضافه کردن مسیرهای پرداخت و ثبت‌نام/ورود به disallow
'/projects/payment/success',
'/projects/payment/failed',
'/billboards/payment/success',
'/billboards/payment/failed',
'/forget-password',
'/change-passowrd',
'/login-with-username',
'/login',
'/register-otp',
'/verify-otp',
'/register',
'/forget-password-otp',
'/register/password',
'/register/fullname',
'/register/usertype',
'/register/username',
'/verify/avatar',
'/verify/auth',
'/verify/colors',
'/verify/confirm',
'/verify/expertise',
'/verify/gender',
'/verify/national-cart',
'/verify/location',
'/verify/public-relations',
'/verify/cooperation-type',
'/verify/services',
'/verify/sizes',
'/new-project',
'/billboards/new',
'/search', // اگر این صفحه برای ایندکس شدن نامناسب است
],
},
],
// اضافه کردن آدرس سایت‌مپ به robots.txt
additionalSitemaps: [
'https://modstagram.com/sitemap.xml',
],
},
};
siteUrl: "https://modstagram.com",
generateRobotsTxt: false,
generateIndexSitemap: false,
};

View File

@@ -13,20 +13,50 @@ const nextConfig = {
remotePatterns: [
{
protocol: 'https',
hostname: 'app.modstagram.ir',
hostname: 'api.modstagram.ir',
port: '',
pathname: '/**',
pathname: '/**',
},
{
protocol: 'https',
hostname: 'blog.modstagram.com', // اجازه دادن به نمایش تصاویر وبلاگ
hostname: 'app.modstagram.ir',
port: '',
pathname: '/**',
},
{
protocol: 'https',
hostname: 'api.modstagram.com',
port: '',
pathname: '/**',
},
{
protocol: 'https',
hostname: 'app.modstagram.com',
port: '',
pathname: '/**',
},
{
protocol: 'https',
hostname: 'panel.modstagram.com',
port: '',
pathname: '/**',
},
{
protocol: 'https',
hostname: 'cdn.modstagram.com',
port: '',
pathname: '/**',
},
{
protocol: 'https',
hostname: 'api.qrserver.com',
port: '',
pathname: '/**',
},
{
protocol: 'http',
hostname: 'localhost',
port: '',
port: '3004',
pathname: '/**',
},
],
@@ -36,27 +66,74 @@ const nextConfig = {
minimumCacheTTL: 31536000,
},
// ۳. هدایت ترافیک ساب‌فولدر به وردپرس (بخش جدید)
async rewrites() {
// ۳. ریدایرکت دائمی دامنه قدیمی .ir به دامنه اصلی .com
async redirects() {
return [
{
source: '/blog',
destination: 'https://blog.modstagram.com/blog',
source: '/:path*',
has: [{ type: 'host', value: 'modstagram.ir' }],
destination: 'https://modstagram.com/:path*',
permanent: true,
},
{
source: '/blog/:path*',
destination: 'https://blog.modstagram.com/blog/:path*',
source: '/:path*',
has: [{ type: 'host', value: 'www.modstagram.ir' }],
destination: 'https://modstagram.com/:path*',
permanent: true,
},
{
source: '/:path*',
has: [{ type: 'host', value: 'app.modstagram.ir' }],
destination: 'https://modstagram.com/:path*',
permanent: true,
},
];
},
server: {
port: 3004,
},
// ۴. تنظیمات هدرها
async headers() {
return [
{
source: '/(.*)',
source: "/service-worker.js",
headers: [
{
key: "Content-Type",
value: "application/javascript; charset=utf-8",
},
{
key: "Cache-Control",
value: "public, max-age=0, must-revalidate",
},
{
key: "Service-Worker-Allowed",
value: "/",
},
],
},
{
source: "/manifest.webmanifest",
headers: [
{
key: "Content-Type",
value: "application/manifest+json; charset=utf-8",
},
{
key: "Cache-Control",
value: "no-cache, no-store, must-revalidate",
},
],
},
{
source: "/manifest.json",
headers: [
{
key: "Content-Type",
value: "application/manifest+json; charset=utf-8",
},
],
},
{
source: "/(.*)",
headers: [
{ key: 'X-DNS-Prefetch-Control', value: 'on' },
{ key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' },
@@ -64,6 +141,8 @@ server: {
{ key: 'X-Frame-Options', value: 'SAMEORIGIN' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Referrer-Policy', value: 'origin-when-cross-origin' },
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=(self), payment=()' },
{ key: 'X-Permitted-Cross-Domain-Policies', value: 'none' },
{ key: 'Cache-Control', value: 'public, s-maxage=1, stale-while-revalidate=59' },
],
},

977
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,9 +5,11 @@
"scripts": {
"dev": "next dev --turbopack --port 3004",
"build": "next build",
"start": "next start --port 3004",
"start": "next start --port 3004 --hostname 0.0.0.0",
"start:local": "next start --port 3004 --hostname 0.0.0.0",
"pm2:prod": "pm2 start ecosystem.config.cjs --update-env",
"lint": "next lint",
"postbuild": "next-sitemap"
"generate:pwa-icons": "node scripts/generate-pwa-icons.mjs"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
@@ -39,6 +41,7 @@
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@radix-ui/themes": "^3.2.1",
"@react-oauth/google": "^0.13.5",
"@shadcn/ui": "^0.0.4",
"@tabler/icons-react": "^3.34.1",
"@tanstack/react-query": "^5.87.1",
@@ -49,6 +52,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"embla-carousel-react": "^8.6.0",
"firebase": "^12.18.0",
"formik": "^2.4.6",
"framer-motion": "^12.23.12",
"i18next": "^25.3.6",

BIN
public/favicon-16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 616 B

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -0,0 +1,6 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.24 2H5.34C3.15 2 2 3.15 2 5.33V7.23C2 9.41 3.15 10.56 5.33 10.56H7.23C9.41 10.56 10.56 9.41 10.56 7.23V5.33C10.57 3.15 9.42 2 7.24 2Z" fill="#292D32"/>
<path d="M18.6699 2H16.7699C14.5899 2 13.4399 3.15 13.4399 5.33V7.23C13.4399 9.41 14.5899 10.56 16.7699 10.56H18.6699C20.8499 10.56 21.9999 9.41 21.9999 7.23V5.33C21.9999 3.15 20.8499 2 18.6699 2Z" fill="#292D32"/>
<path d="M18.6699 13.4302H16.7699C14.5899 13.4302 13.4399 14.5802 13.4399 16.7602V18.6602C13.4399 20.8402 14.5899 21.9902 16.7699 21.9902H18.6699C20.8499 21.9902 21.9999 20.8402 21.9999 18.6602V16.7602C21.9999 14.5802 20.8499 13.4302 18.6699 13.4302Z" fill="#292D32"/>
<path d="M7.24 13.4302H5.34C3.15 13.4302 2 14.5802 2 16.7602V18.6602C2 20.8502 3.15 22.0002 5.33 22.0002H7.23C9.41 22.0002 10.56 20.8502 10.56 18.6702V16.7702C10.57 14.5802 9.42 13.4302 7.24 13.4302Z" fill="#292D32"/>
</svg>

After

Width:  |  Height:  |  Size: 969 B

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16.19 2H7.81C4.17 2 2 4.17 2 7.81V16.18C2 19.83 4.17 22 7.81 22H16.18C19.82 22 21.99 19.83 21.99 16.19V7.81C22 4.17 19.83 2 16.19 2ZM17.26 9.96L14.95 12.94C14.66 13.31 14.25 13.55 13.78 13.6C13.31 13.66 12.85 13.53 12.48 13.24L10.65 11.8C10.58 11.74 10.5 11.74 10.46 11.75C10.42 11.75 10.35 11.77 10.29 11.85L7.91 14.94C7.76 15.13 7.54 15.23 7.32 15.23C7.16 15.23 7 15.18 6.86 15.07C6.53 14.82 6.47 14.35 6.72 14.02L9.1 10.93C9.39 10.56 9.8 10.32 10.27 10.26C10.73 10.2 11.2 10.33 11.57 10.62L13.4 12.06C13.47 12.12 13.54 12.12 13.59 12.11C13.63 12.11 13.7 12.09 13.76 12.01L16.07 9.03C16.32 8.7 16.8 8.64 17.12 8.9C17.45 9.17 17.51 9.64 17.26 9.96Z" fill="#292D32"/>
</svg>

After

Width:  |  Height:  |  Size: 781 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

View File

@@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.9299 2.5V8.4C14.9299 8.84 14.4099 9.06 14.0899 8.77L12.3399 7.16C12.1499 6.98 11.8499 6.98 11.6599 7.16L9.90995 8.76C9.58995 9.06 9.06995 8.83 9.06995 8.4V2.5C9.06995 2.22 9.28995 2 9.56995 2H14.4299C14.7099 2 14.9299 2.22 14.9299 2.5Z" fill="#292D32"/>
<path d="M16.98 2.05989C16.69 2.01989 16.43 2.26989 16.43 2.55989V8.57989C16.43 9.33989 15.98 10.0299 15.28 10.3399C14.58 10.6399 13.77 10.5099 13.21 9.98989L12.34 9.18989C12.15 9.00989 11.86 9.00989 11.66 9.18989L10.79 9.98989C10.43 10.3299 9.96 10.4999 9.49 10.4999C9.23 10.4999 8.97 10.4499 8.72 10.3399C8.02 10.0299 7.57 9.33989 7.57 8.57989V2.55989C7.57 2.26989 7.31 2.01989 7.02 2.05989C4.22 2.40989 3 4.29989 3 6.99989V16.9999C3 19.9999 4.5 21.9999 8 21.9999H16C19.5 21.9999 21 19.9999 21 16.9999V6.99989C21 4.29989 19.78 2.40989 16.98 2.05989ZM17.5 18.7499H9C8.59 18.7499 8.25 18.4099 8.25 17.9999C8.25 17.5899 8.59 17.2499 9 17.2499H17.5C17.91 17.2499 18.25 17.5899 18.25 17.9999C18.25 18.4099 17.91 18.7499 17.5 18.7499ZM17.5 14.7499H13.25C12.84 14.7499 12.5 14.4099 12.5 13.9999C12.5 13.5899 12.84 13.2499 13.25 13.2499H17.5C17.91 13.2499 18.25 13.5899 18.25 13.9999C18.25 14.4099 17.91 14.7499 17.5 14.7499Z" fill="#292D32"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.9101 20.67C8.7201 20.67 8.5301 20.6 8.3801 20.45C8.0901 20.16 8.0901 19.68 8.3801 19.39L14.9001 12.87C15.3801 12.39 15.3801 11.61 14.9001 11.13L8.3801 4.60999C8.0901 4.31999 8.0901 3.83999 8.3801 3.54999C8.6701 3.25999 9.1501 3.25999 9.4401 3.54999L15.9601 10.07C16.4701 10.58 16.7601 11.27 16.7601 12C16.7601 12.73 16.4801 13.42 15.9601 13.93L9.4401 20.45C9.2901 20.59 9.1001 20.67 8.9101 20.67Z" fill="#292D32"/>
</svg>

After

Width:  |  Height:  |  Size: 530 B

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16.8199 1.91016H7.17995C5.05995 1.91016 3.31995 3.65016 3.31995 5.77016V19.8602C3.31995 21.6602 4.60995 22.4202 6.18995 21.5502L11.0699 18.8402C11.5899 18.5502 12.4299 18.5502 12.9399 18.8402L17.8199 21.5502C19.3999 22.4302 20.6899 21.6702 20.6899 19.8602V5.77016C20.6799 3.65016 18.9499 1.91016 16.8199 1.91016ZM15.6199 9.03016L11.6199 13.0302C11.4699 13.1802 11.2799 13.2502 11.0899 13.2502C10.8999 13.2502 10.7099 13.1802 10.5599 13.0302L9.05995 11.5302C8.76995 11.2402 8.76995 10.7602 9.05995 10.4702C9.34995 10.1802 9.82995 10.1802 10.1199 10.4702L11.0899 11.4402L14.5599 7.97016C14.8499 7.68016 15.3299 7.68016 15.6199 7.97016C15.9099 8.26016 15.9099 8.74016 15.6199 9.03016Z" fill="#292D32"/>
</svg>

After

Width:  |  Height:  |  Size: 813 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 744 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 743 B

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16.19 2H7.81C4.17 2 2 4.17 2 7.81V16.18C2 19.83 4.17 22 7.81 22H16.18C19.82 22 21.99 19.83 21.99 16.19V7.81C22 4.17 19.83 2 16.19 2ZM9.11 16.9C9.11 17.18 8.89 17.4 8.61 17.4H5.82C5.54 17.4 5.32 17.18 5.32 16.9V12.28C5.32 11.65 5.83 11.14 6.46 11.14H8.61C8.89 11.14 9.11 11.36 9.11 11.64V16.9ZM13.89 16.9C13.89 17.18 13.67 17.4 13.39 17.4H10.6C10.32 17.4 10.1 17.18 10.1 16.9V7.74C10.1 7.11 10.61 6.6 11.24 6.6H12.76C13.39 6.6 13.9 7.11 13.9 7.74V16.9H13.89ZM18.68 16.9C18.68 17.18 18.46 17.4 18.18 17.4H15.39C15.11 17.4 14.89 17.18 14.89 16.9V13.35C14.89 13.07 15.11 12.85 15.39 12.85H17.54C18.17 12.85 18.68 13.36 18.68 13.99V16.9Z" fill="#292D32"/>
</svg>

After

Width:  |  Height:  |  Size: 764 B

View File

@@ -1,3 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2C6.49 2 2 6.49 2 12C2 17.51 6.49 22 12 22C17.51 22 22 17.51 22 12C22 6.49 17.51 2 12 2ZM14.79 12.53L11.26 16.06C11.11 16.21 10.92 16.28 10.73 16.28C10.54 16.28 10.35 16.21 10.2 16.06C9.91 15.77 9.91 15.29 10.2 15L13.2 12L10.2 9C9.91 8.71 9.91 8.23 10.2 7.94C10.49 7.65 10.97 7.65 11.26 7.94L14.79 11.47C15.09 11.76 15.09 12.24 14.79 12.53Z" fill="#292D32"/>
<path d="M8.9101 20.67C8.7201 20.67 8.5301 20.6 8.3801 20.45C8.0901 20.16 8.0901 19.68 8.3801 19.39L14.9001 12.87C15.3801 12.39 15.3801 11.61 14.9001 11.13L8.3801 4.60999C8.0901 4.31999 8.0901 3.83999 8.3801 3.54999C8.6701 3.25999 9.1501 3.25999 9.4401 3.54999L15.9601 10.07C16.4701 10.58 16.7601 11.27 16.7601 12C16.7601 12.73 16.4801 13.42 15.9601 13.93L9.4401 20.45C9.2901 20.59 9.1001 20.67 8.9101 20.67Z" fill="#292D32"/>
</svg>

Before

Width:  |  Height:  |  Size: 475 B

After

Width:  |  Height:  |  Size: 530 B

View File

@@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15.1299 19.06H7.12988C6.71988 19.06 6.37988 18.72 6.37988 18.31C6.37988 17.9 6.71988 17.56 7.12988 17.56H15.1299C17.4699 17.56 19.3799 15.65 19.3799 13.31C19.3799 10.97 17.4699 9.06 15.1299 9.06H4.12988C3.71988 9.06 3.37988 8.72 3.37988 8.31C3.37988 7.9 3.71988 7.56 4.12988 7.56H15.1299C18.2999 7.56 20.8799 10.14 20.8799 13.31C20.8799 16.48 18.2999 19.06 15.1299 19.06Z" fill="#292D32"/>
<path d="M6.43006 11.56C6.24006 11.56 6.05006 11.49 5.90006 11.34L3.34006 8.78C3.05006 8.49 3.05006 8.01 3.34006 7.72L5.90006 5.16C6.19006 4.87 6.67006 4.87 6.96006 5.16C7.25006 5.45 7.25006 5.93 6.96006 6.22L4.93006 8.25001L6.96006 10.28C7.25006 10.57 7.25006 11.05 6.96006 11.34C6.82006 11.49 6.62006 11.56 6.43006 11.56Z" fill="#292D32"/>
</svg>

After

Width:  |  Height:  |  Size: 845 B

View File

@@ -0,0 +1,6 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 5.12012C5.59 5.12012 5.25 4.78012 5.25 4.37012V2.62012C5.25 2.21012 5.59 1.87012 6 1.87012C6.41 1.87012 6.75 2.21012 6.75 2.62012V4.37012C6.75 4.79012 6.41 5.12012 6 5.12012Z" fill="#292D32"/>
<path d="M10 5.12012C9.59 5.12012 9.25 4.78012 9.25 4.37012V2.62012C9.25 2.21012 9.59 1.87012 10 1.87012C10.41 1.87012 10.75 2.21012 10.75 2.62012V4.37012C10.75 4.79012 10.41 5.12012 10 5.12012Z" fill="#292D32"/>
<path d="M14 5.12012C13.59 5.12012 13.25 4.78012 13.25 4.37012V2.62012C13.25 2.21012 13.59 1.87012 14 1.87012C14.41 1.87012 14.75 2.21012 14.75 2.62012V4.37012C14.75 4.79012 14.41 5.12012 14 5.12012Z" fill="#292D32"/>
<path d="M22.25 13.2498C22.25 10.6298 20.22 8.50977 17.66 8.28977C16.92 7.07977 15.6 6.25977 14.08 6.25977H6.71C4.39 6.25977 2.5 8.14977 2.5 10.4698V10.9998H18.29V10.4698C18.29 10.2798 18.26 10.0898 18.23 9.90977C19.68 10.3398 20.75 11.6598 20.75 13.2498C20.75 14.8198 19.71 16.1298 18.29 16.5698V11.9998H2.5V17.7898C2.5 20.1098 4.39 21.9998 6.71 21.9998H14.08C16.28 21.9998 18.07 20.2998 18.25 18.1398C20.53 17.6798 22.25 15.6598 22.25 13.2498Z" fill="#292D32"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 24 24" style="enable-background:new 0 0 24 24;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FF0000;}
</style>
<path class="st0" d="M21.6,10.7l-1.4-1.6c-0.3-0.3-0.5-0.9-0.5-1.3V6.2c0-1.1-0.9-1.9-1.9-1.9h-1.7c-0.4,0-1-0.2-1.3-0.5l-1.6-1.4
c-0.7-0.6-1.8-0.6-2.5,0L9.2,3.8C8.9,4.1,8.3,4.3,7.9,4.3H6.2c-1.1,0-1.9,0.9-1.9,1.9v1.7c0,0.4-0.2,0.9-0.5,1.2l-1.3,1.6
c-0.6,0.7-0.6,1.8,0,2.5l1.3,1.6c0.2,0.3,0.5,0.9,0.5,1.2v1.7c0,1.1,0.9,1.9,1.9,1.9h1.7c0.4,0,1,0.2,1.3,0.5l1.6,1.4
c0.7,0.6,1.8,0.6,2.5,0l1.6-1.4c0.3-0.3,0.9-0.5,1.3-0.5h1.7c1.1,0,1.9-0.9,1.9-1.9v-1.7c0-0.4,0.2-1,0.5-1.3l1.4-1.6
C22.1,12.6,22.1,11.4,21.6,10.7z"/>
</svg>

After

Width:  |  Height:  |  Size: 823 B

View File

@@ -1,3 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18.94 5.41994L13.77 2.42994C12.78 1.85994 11.23 1.85994 10.24 2.42994L5.01996 5.43994C2.94996 6.83994 2.82996 7.04994 2.82996 9.27994V14.7099C2.82996 16.9399 2.94996 17.1599 5.05996 18.5799L10.23 21.5699C10.73 21.8599 11.37 21.9999 12 21.9999C12.63 21.9999 13.27 21.8599 13.76 21.5699L18.98 18.5599C21.05 17.1599 21.17 16.9499 21.17 14.7199V9.27994C21.17 7.04994 21.05 6.83994 18.94 5.41994ZM12 15.2499C10.21 15.2499 8.74996 13.7899 8.74996 11.9999C8.74996 10.2099 10.21 8.74994 12 8.74994C13.79 8.74994 15.25 10.2099 15.25 11.9999C15.25 13.7899 13.79 15.2499 12 15.2499Z" fill="#292D32"/>
<path d="M16.19 2H7.81C4.17 2 2 4.17 2 7.81V16.18C2 19.83 4.17 22 7.81 22H16.18C19.82 22 21.99 19.83 21.99 16.19V7.81C22 4.17 19.83 2 16.19 2ZM10.95 17.51C10.66 17.8 10.11 18.08 9.71 18.14L7.25 18.49C7.16 18.5 7.07 18.51 6.98 18.51C6.57 18.51 6.19 18.37 5.92 18.1C5.59 17.77 5.45 17.29 5.53 16.76L5.88 14.3C5.94 13.89 6.21 13.35 6.51 13.06L10.97 8.6C11.05 8.81 11.13 9.02 11.24 9.26C11.34 9.47 11.45 9.69 11.57 9.89C11.67 10.06 11.78 10.22 11.87 10.34C11.98 10.51 12.11 10.67 12.19 10.76C12.24 10.83 12.28 10.88 12.3 10.9C12.55 11.2 12.84 11.48 13.09 11.69C13.16 11.76 13.2 11.8 13.22 11.81C13.37 11.93 13.52 12.05 13.65 12.14C13.81 12.26 13.97 12.37 14.14 12.46C14.34 12.58 14.56 12.69 14.78 12.8C15.01 12.9 15.22 12.99 15.43 13.06L10.95 17.51ZM17.37 11.09L16.45 12.02C16.39 12.08 16.31 12.11 16.23 12.11C16.2 12.11 16.16 12.11 16.14 12.1C14.11 11.52 12.49 9.9 11.91 7.87C11.88 7.76 11.91 7.64 11.99 7.57L12.92 6.64C14.44 5.12 15.89 5.15 17.38 6.64C18.14 7.4 18.51 8.13 18.51 8.89C18.5 9.61 18.13 10.33 17.37 11.09Z" fill="#292D32"/>
</svg>

Before

Width:  |  Height:  |  Size: 703 B

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 2H6C4.34 2 3 3.33 3 4.97V15.88C3 17.52 4.34 18.85 6 18.85H6.76C7.56 18.85 8.32 19.16 8.88 19.72L10.59 21.41C11.37 22.18 12.64 22.18 13.42 21.41L15.13 19.72C15.69 19.16 16.46 18.85 17.25 18.85H18C19.66 18.85 21 17.52 21 15.88V4.97C21 3.33 19.66 2 18 2ZM12 5.75C13.29 5.75 14.33 6.79 14.33 8.08C14.33 9.37 13.29 10.41 12 10.41C10.71 10.41 9.67 9.36 9.67 8.08C9.67 6.79 10.71 5.75 12 5.75ZM14.68 15.06H9.32C8.51 15.06 8.04 14.16 8.49 13.49C9.17 12.48 10.49 11.8 12 11.8C13.51 11.8 14.83 12.48 15.51 13.49C15.96 14.16 15.48 15.06 14.68 15.06Z" fill="#292D32"/>
</svg>

After

Width:  |  Height:  |  Size: 673 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 640 B

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 62" role="img" aria-label="Modstagram">
<style>.st0{fill:#FF107D;}</style>
<path class="st0" d="M42.1,47.6h-0.3c-1.7,0-3.2-1.4-3.2-3.2V31.8c0-1.3-0.3-2.5-0.8-3.6c-0.5-1.1-1.1-2.1-2-3c-0.8-0.8-1.8-1.5-3-2
c-1.1-0.5-2.3-0.8-3.6-0.8c-1.4,0-2.6,0.3-3.8,0.8c-1.1,0.5-2,1.2-2.9,2c-0.1,0.1-0.2,0.1-0.3,0c0,0,0,0-0.1,0
c-0.2-0.2-0.4-0.4-0.7-0.6c0,0,0,0-0.1-0.1C20.9,24,20,23.5,19,23.1c-1-0.4-2-0.6-3.1-0.6c-1.3,0-2.5,0.3-3.7,0.8
c-1.1,0.5-2.1,1.1-2.9,2c-0.8,0.8-1.5,1.8-2,3c-0.5,1.1-0.7,2.3-0.7,3.6v12.6c0,1.7-1.4,3.2-3.2,3.2H3.2c-1.7,0-3.2-1.4-3.2-3.2
V31.8v-0.4c0-2.1,0.5-4.1,1.3-6c0.8-1.9,2-3.5,3.4-4.9c1.4-1.4,3.1-2.5,5-3.3c1.9-0.8,4-1.2,6.1-1.2c1.2,0,2.4,0.2,3.5,0.4
c1.1,0.3,2.2,0.6,3.2,1.1c1-0.5,2.1-0.8,3.3-1.1c1.1-0.3,2.3-0.4,3.5-0.4c2.2,0,4.3,0.5,6.2,1.3c1.9,0.8,3.6,2,5.1,3.4
c1.4,1.4,2.6,3.1,3.4,5.1c0.8,1.9,1.2,3.9,1.2,6.1v12.6C45.3,46.2,43.9,47.6,42.1,47.6z M64.6,16c2.1,0,4.2,0.4,6.1,1.3
c1.9,0.8,3.6,1.9,5.1,3.3c1.4,1.4,2.6,3.1,3.4,5.1c0.8,1.9,1.3,3.9,1.3,6.1c0,2.2-0.4,4.3-1.3,6.2c-0.8,1.9-2,3.6-3.4,5
c-1.4,1.4-3.1,2.5-5.1,3.3c-1.9,0.8-3.9,1.2-6.1,1.2c-2.2,0-4.3-0.4-6.2-1.2c-1.9-0.8-3.6-2-5-3.3c-1.4-1.4-2.5-3.1-3.3-5
c-0.8-1.9-1.2-4-1.2-6.2c0-2.2,0.4-4.2,1.2-6.1c0.8-1.9,2-3.6,3.3-5.1c1.4-1.4,3.1-2.6,5-3.3C60.3,16.4,62.4,16,64.6,16z M64.6,41
c1.3,0,2.5-0.2,3.6-0.7c1.1-0.5,2.1-1.2,2.9-2c0.8-0.8,1.5-1.8,2-2.9c0.5-1.1,0.8-2.3,0.8-3.6c0-1.3-0.3-2.5-0.8-3.6
c-0.5-1.1-1.1-2.1-2-2.9c-0.8-0.8-1.8-1.5-2.9-2c-1.1-0.5-2.3-0.8-3.6-0.8s-2.5,0.3-3.6,0.8c-1.1,0.5-2.1,1.1-3,2
c-0.8,0.8-1.5,1.8-2,2.9c-0.5,1.1-0.7,2.3-0.7,3.6c0,1.3,0.2,2.5,0.7,3.6c0.5,1.1,1.2,2.1,2,2.9c0.8,0.8,1.8,1.5,3,2
C62.1,40.7,63.3,41,64.6,41z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.9999 15C6.98991 15 2.90991 18.36 2.90991 22.5C2.90991 22.78 3.12991 23 3.40991 23H20.5899C20.8699 23 21.0899 22.78 21.0899 22.5C21.0899 18.36 17.0099 15 11.9999 15Z" fill="#292D32"/>
<path d="M15.71 3.66C14.81 2.64 13.47 2 12 2C10.6 2 9.32 2.57 8.41 3.51C7.54 4.41 7 5.65 7 7C7 7.94 7.26 8.82 7.73 9.57C7.98 10 8.3 10.39 8.68 10.71C9.55 11.51 10.71 12 12 12C13.83 12 15.41 11.02 16.28 9.57C16.54 9.14 16.74 8.66 16.85 8.16C16.95 7.79 17 7.4 17 7C17 5.72 16.51 4.55 15.71 3.66ZM13.87 7.92H12.94V8.89C12.94 9.41 12.52 9.83 12 9.83C11.48 9.83 11.06 9.41 11.06 8.89V7.92H10.13C9.61 7.92 9.19 7.5 9.19 6.98C9.19 6.46 9.61 6.04 10.13 6.04H11.06V5.15C11.06 4.63 11.48 4.21 12 4.21C12.52 4.21 12.94 4.63 12.94 5.15V6.04H13.87C14.39 6.04 14.81 6.46 14.81 6.98C14.81 7.5 14.39 7.92 13.87 7.92Z" fill="#292D32"/>
</svg>

After

Width:  |  Height:  |  Size: 918 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 904 B

View File

@@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20.97 1H18.03C17.16 1 16.52 1.36 16.23 2C16.07 2.29 16 2.63 16 3.03V5.97C16 7.24 16.76 8 18.03 8H20.97C21.37 8 21.71 7.93 22 7.77C22.64 7.48 23 6.84 23 5.97V3.03C23 1.76 22.24 1 20.97 1ZM21.91 4.93C21.81 5.03 21.66 5.1 21.5 5.11H20.09V5.62L20.1 6.5C20.09 6.67 20.03 6.81 19.91 6.93C19.81 7.03 19.66 7.1 19.5 7.1C19.17 7.1 18.9 6.83 18.9 6.5V5.1L17.5 5.11C17.17 5.11 16.9 4.83 16.9 4.5C16.9 4.17 17.17 3.9 17.5 3.9L18.38 3.91H18.9V2.51C18.9 2.18 19.17 1.9 19.5 1.9C19.83 1.9 20.1 2.18 20.1 2.51L20.09 3.22V3.9H21.5C21.83 3.9 22.1 4.17 22.1 4.5C22.09 4.67 22.02 4.81 21.91 4.93Z" fill="#292D32"/>
<path d="M9 10.3801C10.3144 10.3801 11.38 9.31456 11.38 8.00012C11.38 6.68568 10.3144 5.62012 9 5.62012C7.68556 5.62012 6.62 6.68568 6.62 8.00012C6.62 9.31456 7.68556 10.3801 9 10.3801Z" fill="#292D32"/>
<path d="M20.97 8H20.5V12.61L20.37 12.5C19.59 11.83 18.33 11.83 17.55 12.5L13.39 16.07C12.61 16.74 11.35 16.74 10.57 16.07L10.23 15.79C9.52 15.17 8.39 15.11 7.59 15.65L3.85 18.16C3.63 17.6 3.5 16.95 3.5 16.19V7.81C3.5 4.99 4.99 3.5 7.81 3.5H16V3.03C16 2.63 16.07 2.29 16.23 2H7.81C4.17 2 2 4.17 2 7.81V16.19C2 17.28 2.19 18.23 2.56 19.03C3.42 20.93 5.26 22 7.81 22H16.19C19.83 22 22 19.83 22 16.19V7.77C21.71 7.93 21.37 8 20.97 8Z" fill="#292D32"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16.14 2.96004L7.11 5.96004C1.04 7.99004 1.04 11.3 7.11 13.32L9.79 14.21L10.68 16.89C12.7 22.96 16.02 22.96 18.04 16.89L21.05 7.87004C22.39 3.82004 20.19 1.61004 16.14 2.96004ZM16.46 8.34004L12.66 12.16C12.51 12.31 12.32 12.38 12.13 12.38C11.94 12.38 11.75 12.31 11.6 12.16C11.31 11.87 11.31 11.39 11.6 11.1L15.4 7.28004C15.69 6.99004 16.17 6.99004 16.46 7.28004C16.75 7.57004 16.75 8.05004 16.46 8.34004Z" fill="#292D32"/>
</svg>

After

Width:  |  Height:  |  Size: 536 B

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18.54 4.11984L13.04 2.05984C12.47 1.84984 11.54 1.84984 10.97 2.05984L5.47005 4.11984C4.41005 4.51984 3.55005 5.75984 3.55005 6.88984V14.9898C3.55005 15.7998 4.08005 16.8698 4.73005 17.3498L10.23 21.4598C11.2 22.1898 12.79 22.1898 13.76 21.4598L19.26 17.3498C19.91 16.8598 20.4401 15.7998 20.4401 14.9898V6.88984C20.4501 5.75984 19.59 4.51984 18.54 4.11984ZM15.48 9.71984L11.18 14.0198C11.03 14.1698 10.84 14.2398 10.65 14.2398C10.46 14.2398 10.27 14.1698 10.12 14.0198L8.52005 12.3998C8.23005 12.1098 8.23005 11.6298 8.52005 11.3398C8.81005 11.0498 9.29005 11.0498 9.58005 11.3398L10.66 12.4198L14.43 8.64984C14.72 8.35984 15.2 8.35984 15.49 8.64984C15.78 8.93984 15.78 9.42984 15.48 9.71984Z" fill="#292D32"/>
</svg>

After

Width:  |  Height:  |  Size: 825 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 436 B

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 26.0.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 24 24" style="enable-background:new 0 0 24 24;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFFF00;}
</style>
<path class="st0" d="M13.9,2.9l1.9,3.8c0.3,0.5,0.9,1,1.5,1.1l3.4,0.6c2.2,0.4,2.7,1.9,1.1,3.5l-2.7,2.7c-0.4,0.4-0.7,1.3-0.6,1.9
l0.8,3.3c0.6,2.6-0.8,3.6-3.1,2.3l-3.2-1.9c-0.6-0.3-1.5-0.3-2.1,0l-3.2,1.9c-2.3,1.4-3.7,0.3-3.1-2.3l0.8-3.3
c0.1-0.6-0.1-1.5-0.6-2l-2.7-2.7c-1.6-1.6-1.1-3.2,1.1-3.5l3.4-0.6C7.3,7.7,8,7.2,8.2,6.6l1.9-3.8C11.2,0.8,12.8,0.8,13.9,2.9z"/>
</svg>

After

Width:  |  Height:  |  Size: 768 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.9999 14C6.98991 14 2.90991 17.36 2.90991 21.5C2.90991 21.78 3.12991 22 3.40991 22H20.5899C20.8699 22 21.0899 21.78 21.0899 21.5C21.0899 17.36 17.0099 14 11.9999 14Z" fill="#292D32"/>
<path d="M16.8501 5.80008C16.7301 5.31008 16.5301 4.83008 16.2501 4.39008C16.0601 4.07008 15.8101 3.75008 15.5401 3.47008C14.6401 2.57008 13.4701 2.08008 12.2701 2.02008C10.9101 1.93008 9.52009 2.43008 8.47009 3.47008C7.48009 4.45008 6.98009 5.76008 7.00009 7.08008C7.01009 8.33008 7.51009 9.58008 8.46009 10.5401C9.12009 11.2001 9.93009 11.6401 10.8001 11.8401C11.2701 11.9601 11.7701 12.0201 12.2701 11.9801C13.4601 11.9301 14.6201 11.4601 15.5301 10.5401C16.8201 9.25008 17.2601 7.44008 16.8501 5.80008ZM14.0001 9.00008C13.6401 9.36008 13.0401 9.36008 12.6801 9.00008L11.9901 8.31008L11.3301 8.97008C10.9701 9.33008 10.3701 9.33008 10.0101 8.97008C9.64009 8.60008 9.64009 8.01008 10.0001 7.65008L10.6601 6.99008L10.0201 6.37008C9.66009 6.00008 9.66009 5.41008 10.0201 5.03008C10.3901 4.67008 10.9801 4.67008 11.3601 5.03008L11.9801 5.67008L12.6501 5.00008C13.0101 4.64008 13.6001 4.64008 13.9701 5.00008C14.3301 5.36008 14.3301 5.96008 13.9701 6.32008L13.3101 6.98008L14.0001 7.68008C14.3601 8.04008 14.3601 8.64008 14.0001 9.00008Z" fill="#292D32"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 24 24" style="enable-background:new 0 0 24 24;" xml:space="preserve">
<style type="text/css">
.st0{fill:#002FC6;}
</style>
<path class="st0" d="M21.6,10.7l-1.4-1.6c-0.3-0.3-0.5-0.9-0.5-1.3V6.2c0-1.1-0.9-1.9-1.9-1.9h-1.7c-0.4,0-1-0.2-1.3-0.5l-1.6-1.4
c-0.7-0.6-1.8-0.6-2.5,0L9.2,3.8C8.9,4.1,8.3,4.3,7.9,4.3H6.2c-1.1,0-1.9,0.9-1.9,1.9v1.7c0,0.4-0.2,0.9-0.5,1.2l-1.3,1.6
c-0.6,0.7-0.6,1.8,0,2.5l1.3,1.6c0.2,0.3,0.5,0.9,0.5,1.2v1.7c0,1.1,0.9,1.9,1.9,1.9h1.7c0.4,0,1,0.2,1.3,0.5l1.6,1.4
c0.7,0.6,1.8,0.6,2.5,0l1.6-1.4c0.3-0.3,0.9-0.5,1.3-0.5h1.7c1.1,0,1.9-0.9,1.9-1.9v-1.7c0-0.4,0.2-1,0.5-1.3l1.4-1.6
C22.1,12.6,22.1,11.4,21.6,10.7z M16.2,10.1l-4.8,4.8c-0.1,0.1-0.3,0.2-0.5,0.2s-0.4-0.1-0.5-0.2l-2.4-2.4c-0.3-0.3-0.3-0.8,0-1.1
c0.3-0.3,0.8-0.3,1.1,0l1.9,1.9l4.3-4.3c0.3-0.3,0.8-0.3,1.1,0C16.5,9.3,16.5,9.8,16.2,10.1z"/>
</svg>

After

Width:  |  Height:  |  Size: 1008 B

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 24 24" style="enable-background:new 0 0 24 24;" xml:space="preserve">
<style type="text/css">
.st0{fill:#EDCB00;}
</style>
<path class="st0" d="M21.6,10.7l-1.4-1.6c-0.3-0.3-0.5-0.9-0.5-1.3V6.2c0-1.1-0.9-1.9-1.9-1.9h-1.7c-0.4,0-1-0.2-1.3-0.5l-1.6-1.4
c-0.7-0.6-1.8-0.6-2.5,0L9.2,3.8C8.9,4.1,8.3,4.3,7.9,4.3H6.2c-1.1,0-1.9,0.9-1.9,1.9v1.7c0,0.4-0.2,0.9-0.5,1.2l-1.3,1.6
c-0.6,0.7-0.6,1.8,0,2.5l1.3,1.6c0.2,0.3,0.5,0.9,0.5,1.2v1.7c0,1.1,0.9,1.9,1.9,1.9h1.7c0.4,0,1,0.2,1.3,0.5l1.6,1.4
c0.7,0.6,1.8,0.6,2.5,0l1.6-1.4c0.3-0.3,0.9-0.5,1.3-0.5h1.7c1.1,0,1.9-0.9,1.9-1.9v-1.7c0-0.4,0.2-1,0.5-1.3l1.4-1.6
C22.1,12.6,22.1,11.4,21.6,10.7z M16.2,10.1l-4.8,4.8c-0.1,0.1-0.3,0.2-0.5,0.2s-0.4-0.1-0.5-0.2l-2.4-2.4c-0.3-0.3-0.3-0.8,0-1.1
c0.3-0.3,0.8-0.3,1.1,0l1.9,1.9l4.3-4.3c0.3-0.3,0.8-0.3,1.1,0C16.5,9.3,16.5,9.8,16.2,10.1z"/>
</svg>

After

Width:  |  Height:  |  Size: 1008 B

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 24 24" style="enable-background:new 0 0 24 24;" xml:space="preserve">
<style type="text/css">
.st0{fill:#636360;}
</style>
<path class="st0" d="M21.6,10.7l-1.4-1.6c-0.3-0.3-0.5-0.9-0.5-1.3V6.2c0-1.1-0.9-1.9-1.9-1.9h-1.7c-0.4,0-1-0.2-1.3-0.5l-1.6-1.4
c-0.7-0.6-1.8-0.6-2.5,0L9.2,3.8C8.9,4.1,8.3,4.3,7.9,4.3H6.2c-1.1,0-1.9,0.9-1.9,1.9v1.7c0,0.4-0.2,0.9-0.5,1.2l-1.3,1.6
c-0.6,0.7-0.6,1.8,0,2.5l1.3,1.6c0.2,0.3,0.5,0.9,0.5,1.2v1.7c0,1.1,0.9,1.9,1.9,1.9h1.7c0.4,0,1,0.2,1.3,0.5l1.6,1.4
c0.7,0.6,1.8,0.6,2.5,0l1.6-1.4c0.3-0.3,0.9-0.5,1.3-0.5h1.7c1.1,0,1.9-0.9,1.9-1.9v-1.7c0-0.4,0.2-1,0.5-1.3l1.4-1.6
C22.1,12.6,22.1,11.4,21.6,10.7z M16.2,10.1l-4.8,4.8c-0.1,0.1-0.3,0.2-0.5,0.2s-0.4-0.1-0.5-0.2l-2.4-2.4c-0.3-0.3-0.3-0.8,0-1.1
c0.3-0.3,0.8-0.3,1.1,0l1.9,1.9l4.3-4.3c0.3-0.3,0.8-0.3,1.1,0C16.5,9.3,16.5,9.8,16.2,10.1z"/>
</svg>

After

Width:  |  Height:  |  Size: 1008 B

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 24 24" style="enable-background:new 0 0 24 24;" xml:space="preserve">
<style type="text/css">
.st0{fill:#06CC00;}
</style>
<path class="st0" d="M21.6,10.7l-1.4-1.6c-0.3-0.3-0.5-0.9-0.5-1.3V6.2c0-1.1-0.9-1.9-1.9-1.9h-1.7c-0.4,0-1-0.2-1.3-0.5l-1.6-1.4
c-0.7-0.6-1.8-0.6-2.5,0L9.2,3.8C8.9,4.1,8.3,4.3,7.9,4.3H6.2c-1.1,0-1.9,0.9-1.9,1.9v1.7c0,0.4-0.2,0.9-0.5,1.2l-1.3,1.6
c-0.6,0.7-0.6,1.8,0,2.5l1.3,1.6c0.2,0.3,0.5,0.9,0.5,1.2v1.7c0,1.1,0.9,1.9,1.9,1.9h1.7c0.4,0,1,0.2,1.3,0.5l1.6,1.4
c0.7,0.6,1.8,0.6,2.5,0l1.6-1.4c0.3-0.3,0.9-0.5,1.3-0.5h1.7c1.1,0,1.9-0.9,1.9-1.9v-1.7c0-0.4,0.2-1,0.5-1.3l1.4-1.6
C22.1,12.6,22.1,11.4,21.6,10.7z M16.2,10.1l-4.8,4.8c-0.1,0.1-0.3,0.2-0.5,0.2s-0.4-0.1-0.5-0.2l-2.4-2.4c-0.3-0.3-0.3-0.8,0-1.1
c0.3-0.3,0.8-0.3,1.1,0l1.9,1.9l4.3-4.3c0.3-0.3,0.8-0.3,1.1,0C16.5,9.3,16.5,9.8,16.2,10.1z"/>
</svg>

After

Width:  |  Height:  |  Size: 1008 B

View File

@@ -1,21 +1,49 @@
{
"name": "Modstagram",
"short_name": "Modstagram",
"description": "Modstagram App",
"name": "مدستاگرام",
"short_name": "مدستاگرام",
"description": "شبکه تخصصی مدل، عکاس و آرایشگر",
"lang": "fa",
"dir": "rtl",
"scope": "/",
"start_url": "/?source=pwa&lang=fa",
"id": "modstagram-pwa",
"display": "standalone",
"display_override": ["standalone", "minimal-ui", "browser"],
"orientation": "portrait-primary",
"prefer_related_applications": false,
"icons": [
{
"src": "/images/icons/192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/images/icons/256x256.png",
"sizes": "256x256",
"type": "image/png"
"type": "image/png",
"purpose": "any"
},
{
"src": "/images/icons/512x512.png",
"sizes": "512x512",
"type": "image/png"
"type": "image/png",
"purpose": "any"
},
{
"src": "/images/icons/maskable-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/images/icons/maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#ffffff",
"theme_color": "#FF107D",
"background_color": "#ffffff",
"display": "standalone",
"start_url": "/"
"categories": ["social", "business"]
}

View File

@@ -1,47 +1,32 @@
# *
# Generated to mirror src/app/robots.ts — Next.js serves app/robots.ts preferentially.
User-agent: *
Allow: /
Disallow: /api/
Disallow: /_next/
Disallow: /settings
Disallow: /settings/
Disallow: /auth
Disallow: /private/
Disallow: /new-post
Disallow: /offer
Disallow: /projects/payment/success
Disallow: /projects/payment/failed
Disallow: /billboards/payment/success
Disallow: /billboards/payment/failed
Disallow: /forget-password
Disallow: /change-passowrd
Disallow: /login-with-username
Disallow: /login
Disallow: /register-otp
Disallow: /verify-otp
Disallow: /register
Disallow: /forget-password-otp
Disallow: /register/password
Disallow: /register/fullname
Disallow: /register/usertype
Disallow: /register/username
Disallow: /verify/avatar
Disallow: /verify/auth
Disallow: /verify/colors
Disallow: /verify/confirm
Disallow: /verify/expertise
Disallow: /verify/gender
Disallow: /verify/national-cart
Disallow: /verify/location
Disallow: /verify/public-relations
Disallow: /verify/cooperation-type
Disallow: /verify/services
Disallow: /verify/sizes
Disallow: /new-project
Disallow: /billboards/new
Disallow: /search
Disallow: /verify/
Disallow: /register
Disallow: /register/
Disallow: /login
Disallow: /login-with-username
Disallow: /login-2fa
Disallow: /verify-otp
Disallow: /forget-password
Disallow: /forget-password-otp
Disallow: /change-passowrd
Disallow: /offer
Disallow: /offer/
Disallow: /*/payment/
Disallow: /projects/payment/
Disallow: /billboards/payment/
Disallow: /academy/payment/
# Host
Host: https://modstagram.com
# Sitemaps
Sitemap: https://modstagram.com/sitemap.xml

View File

@@ -1,62 +1,131 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
// نصب Service Worker
self.addEventListener("install", (event) => {
console.log("Service Worker installing...");
// عین google-services.json/pushToken.ts اندروید — این مقادیر عمومی و امن برای
// مرورگرند. سرویس‌ورکرِ استاتیک نمی‌تونه process.env بخونه، پس مستقیم اینجا نوشته
// شدن (دقیقاً همون چیزی که NEXT_PUBLIC_FIREBASE_* توی باندلِ کلاینت هم inline می‌شه)
importScripts("https://www.gstatic.com/firebasejs/10.13.2/firebase-app-compat.js");
importScripts("https://www.gstatic.com/firebasejs/10.13.2/firebase-messaging-compat.js");
firebase.initializeApp({
apiKey: "AIzaSyDpUh8AlafwyM931o519oX3z7JYnXZJyBg",
authDomain: "modstagram-com.firebaseapp.com",
projectId: "modstagram-com",
storageBucket: "modstagram-com.firebasestorage.app",
messagingSenderId: "767614062479",
appId: "1:767614062479:web:ec2aea23cc8ce17dfe45fa",
});
// پیامِ پوش وقتی تب بسته/پس‌زمینه‌ست — عین رفتار پیش‌فرضِ اندروید (نوتیفِ سیستمی).
// وقتی تب باز و فعاله، Firebase این رویداد رو صدا نمی‌زنه — اونجا onMessage
// (pushNotifications.ts سمتِ کلاینت) با toast جایگزینش می‌کنه
const messaging = firebase.messaging.isSupported() ? firebase.messaging() : null;
if (messaging) {
messaging.onBackgroundMessage((payload) => {
const title = payload.notification?.title || "modstagram";
self.registration.showNotification(title, {
body: payload.notification?.body,
icon: "/images/icons/192x192.png",
badge: "/images/icons/192x192.png",
data: payload.data || {},
});
});
}
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const url = "/settings/notifications";
event.waitUntil(
caches.open("pwa-cache-v1").then((cache) => {
return cache.addAll([
"/",
"/manifest.json",
"/images/icons/256x256.png",
"/images/icons/512x512.png"
]);
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clientsArr) => {
const existing = clientsArr.find((c) => c.url.includes(url));
if (existing) return existing.focus();
return self.clients.openWindow(url);
})
);
});
// فعال‌سازی Service Worker
self.addEventListener("activate", (event) => {
console.log("Service Worker activated.");
// پاک کردن cacheهای قدیمی
const CACHE_NAME = "pwa-cache-v11";
const PRECACHE_URLS = [
"/",
"/manifest.webmanifest",
"/manifest.json",
"/images/icons/192x192.png",
"/images/icons/256x256.png",
"/images/icons/512x512.png",
"/images/icons/apple-touch-icon.png",
"/images/icons/maskable-192.png",
"/images/icons/maskable-512.png",
"/favicon.png",
];
self.addEventListener("install", (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys
.filter((key) => key !== "pwa-cache-v1")
.map((key) => caches.delete(key))
caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_URLS))
);
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches
.keys()
.then((keys) =>
Promise.all(
keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))
)
)
)
.then(() => self.clients.claim())
);
});
// هندل درخواست‌ها
self.addEventListener("fetch", (event) => {
const url = new URL(event.request.url);
// فقط برای فایل‌های استاتیک از cache استفاده کن
if (event.request.mode === "navigate") {
event.respondWith(fetch(event.request));
return;
}
if (
url.pathname.startsWith("/images/icons/") ||
url.pathname.endsWith("manifest.webmanifest") ||
url.pathname.endsWith("manifest.json") ||
url.pathname.endsWith("favicon.png")
) {
event.respondWith(
fetch(event.request)
.then((res) => {
if (res.ok) {
const clone = res.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
}
return res;
})
.catch(() => caches.match(event.request))
);
return;
}
if (
url.pathname.startsWith("/images/") ||
url.pathname.endsWith(".png") ||
url.pathname.endsWith(".jpg") ||
url.pathname.endsWith(".jpeg") ||
url.pathname.endsWith(".gif") ||
url.pathname.endsWith(".webp") ||
url.pathname.endsWith("manifest.json")
url.pathname.endsWith(".webp")
) {
event.respondWith(
caches.match(event.request).then((response) => {
return (
response ||
fetch(event.request).then((res) => {
// فایل جدید رو هم ذخیره کن
return caches.open("pwa-cache-v1").then((cache) => {
cache.put(event.request, res.clone());
return res;
});
})
);
caches.match(event.request).then((cached) => {
const networkFetch = fetch(event.request).then((res) => {
if (res.ok) {
const clone = res.clone();
caches
.open(CACHE_NAME)
.then((cache) => cache.put(event.request, clone));
}
return res;
});
return cached || networkFetch;
})
);
}

View File

@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:mobile="http://www.google.com/schemas/sitemap-mobile/1.0" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">
<url><loc>https://modstagram.com/robots.txt</loc><lastmod>2026-07-08T17:38:00.874Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://modstagram.com/sitemap.xml</loc><lastmod>2026-07-08T17:38:00.875Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://modstagram.com/explore</loc><lastmod>2026-07-08T17:38:00.875Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://modstagram.com/academy/payment/failed</loc><lastmod>2026-07-08T17:38:00.875Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://modstagram.com/about-us</loc><lastmod>2026-07-08T17:38:00.875Z</lastmod><changefreq>monthly</changefreq><priority>0.8</priority></url>
<url><loc>https://modstagram.com/academy/payment/success</loc><lastmod>2026-07-08T17:38:00.875Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://modstagram.com</loc><changefreq>daily</changefreq><priority>1</priority></url>
<url><loc>https://modstagram.com/projects</loc><changefreq>daily</changefreq><priority>0.9</priority></url>
<url><loc>https://modstagram.com/billboards</loc><changefreq>daily</changefreq><priority>0.9</priority></url>
</urlset>

View File

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<sitemap><loc>https://modstagram.com/sitemap-0.xml</loc></sitemap>
<sitemap><loc>https://modstagram.com/sitemap.xml</loc></sitemap>
</sitemapindex>

22
robots.txt Normal file
View File

@@ -0,0 +1,22 @@
# Legacy file — live robots are generated by src/app/robots.ts
# Keep in sync with public/robots.txt if this file is ever served by a reverse proxy.
User-agent: *
Allow: /
Disallow: /api/
Disallow: /_next/
Disallow: /settings
Disallow: /settings/
Disallow: /auth
Disallow: /private/
Disallow: /new-post
Disallow: /new-project
Disallow: /billboards/new
Disallow: /search
Disallow: /verify/
Disallow: /register
Disallow: /login
Disallow: /offer
Disallow: /*/payment/
Host: https://modstagram.com
Sitemap: https://modstagram.com/sitemap.xml

48
scripts/deploy-server.sh Normal file
View File

@@ -0,0 +1,48 @@
#!/usr/bin/env bash
# Deploy modstagram-next on Ubuntu server (after git pull / upload)
set -euo pipefail
APP_DIR="${APP_DIR:-/root/modstagram-next}"
cd "$APP_DIR"
echo ">>> Node: $(node -v) | npm: $(npm -v)"
if [ ! -f .env.production ] && [ ! -f .env.local ]; then
echo "WARN: no .env.production — copy .env.production.example and fill values"
fi
echo ">>> npm ci ..."
npm ci
echo ">>> npm run build ..."
npm run build
if [ ! -d .next ]; then
echo "ERROR: .next missing — build failed"
exit 1
fi
echo ">>> PM2 — stop old process (may be on port 3004) ..."
pm2 delete modstagram-next 2>/dev/null || true
echo ">>> PM2 start on port 3004 (nginx upstream) ..."
pm2 start ecosystem.config.cjs --update-env
pm2 save
echo ">>> Verify listen ports ..."
ss -tlnp | grep -E '3001|3004' || true
echo ">>> Health check (port 3004) ..."
sleep 3
curl -sf -o /dev/null -w "HTTP %{http_code}\n" http://127.0.0.1:3004/ || {
echo "ERROR: Next.js not responding on 127.0.0.1:3004"
pm2 logs modstagram-next --lines 40 --nostream
exit 1
}
if ss -tlnp | grep -q ':3004'; then
echo "WARN: something still listens on 3004 — check pm2 list / duplicate processes"
fi
echo ">>> Done. Reload nginx if you changed config:"
echo " nginx -t && systemctl reload nginx"

View File

@@ -0,0 +1,94 @@
import sharp from "sharp";
import path from "path";
import { fileURLToPath } from "url";
import fs from "fs";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const iconsDir = path.join(__dirname, "../public/images/icons");
const publicDir = path.join(__dirname, "../public");
const source = path.join(iconsDir, "logo-mark.svg");
const BRAND = { r: 255, g: 16, b: 125, alpha: 1 };
const WHITE = { r: 255, g: 255, b: 255, alpha: 1 };
async function renderSquareIcon(size, paddingRatio = 0.18) {
const inner = Math.round(size * (1 - paddingRatio * 2));
const padding = Math.round((size - inner) / 2);
const mark = await sharp(source, { density: 512 })
.resize(inner, inner, {
fit: "contain",
background: { r: 0, g: 0, b: 0, alpha: 0 },
})
.png()
.toBuffer();
return sharp({
create: {
width: size,
height: size,
channels: 4,
background: WHITE,
},
})
.composite([{ input: mark, left: padding, top: padding }])
.png()
.toFile(path.join(iconsDir, `${size}x${size}.png`));
}
async function renderMaskableIcon(size) {
const inner = Math.round(size * 0.52);
const padding = Math.round((size - inner) / 2);
const mark = await sharp(source, { density: 512 })
.resize(inner, inner, {
fit: "contain",
background: { r: 0, g: 0, b: 0, alpha: 0 },
})
.png()
.toBuffer();
await sharp({
create: {
width: size,
height: size,
channels: 4,
background: BRAND,
},
})
.composite([{ input: mark, left: padding, top: padding }])
.png()
.toFile(path.join(iconsDir, `maskable-${size}.png`));
console.log(`Created maskable-${size}.png`);
}
if (!fs.existsSync(source)) {
console.error("Missing logo-mark.svg");
process.exit(1);
}
for (const size of [192, 256, 512]) {
await renderSquareIcon(size);
console.log(`Created ${size}x${size}.png`);
}
await sharp(path.join(iconsDir, "512x512.png"))
.resize(180, 180)
.png()
.toFile(path.join(iconsDir, "apple-touch-icon.png"));
console.log("Created apple-touch-icon.png");
await renderMaskableIcon(192);
await renderMaskableIcon(512);
await sharp(path.join(iconsDir, "192x192.png"))
.resize(32, 32)
.png()
.toFile(path.join(publicDir, "favicon.png"));
await sharp(path.join(publicDir, "favicon.png"))
.resize(16, 16)
.toFile(path.join(publicDir, "favicon-16.png"));
console.log("Created favicon.png");

105
scripts/server-fix-502.sh Normal file
View File

@@ -0,0 +1,105 @@
#!/usr/bin/env bash
# Run on server as root:
# cd /root/modstagram-next && bash scripts/server-fix-502.sh
set -euo pipefail
APP_DIR="${APP_DIR:-/root/modstagram-next}"
cd "$APP_DIR"
echo ">>> modstagram-next deploy | $APP_DIR"
# Stop anything blocking ports
pm2 delete modstagram-next 2>/dev/null || true
fuser -k 3004/tcp 2>/dev/null || true
fuser -k 3001/tcp 2>/dev/null || true
# PM2 config — port 3004 (nginx upstream on this server)
cat > "$APP_DIR/ecosystem.config.cjs" << EOF
module.exports = {
apps: [{
name: "modstagram-next",
cwd: "$APP_DIR",
script: "$APP_DIR/node_modules/next/dist/bin/next",
args: "start --port 3004 --hostname 0.0.0.0",
instances: 1,
exec_mode: "fork",
autorestart: true,
max_restarts: 20,
min_uptime: 5000,
max_memory_restart: "900M",
env: {
NODE_ENV: "production",
PORT: "3004",
HOSTNAME: "0.0.0.0",
UPSTREAM_API_URL: "https://api.modstagram.com",
NEXT_PUBLIC_SOCKET_URL: "https://api.modstagram.com",
NEXT_PUBLIC_IMAGE_BASE_URL: "https://api.modstagram.com/storage",
NEXT_PUBLIC_BASE_URL: "/api/v1",
NEXT_PUBLIC_SITE_URL: "https://modstagram.com",
},
}],
};
EOF
# Production env
if [ ! -f .env.production ]; then
cat > .env.production << 'ENVEOF'
PORT=3004
HOSTNAME=0.0.0.0
UPSTREAM_API_URL=https://api.modstagram.com
NEXT_PUBLIC_SOCKET_URL=https://api.modstagram.com
NEXT_PUBLIC_IMAGE_BASE_URL=https://api.modstagram.com/storage
NEXT_PUBLIC_BASE_URL=/api/v1
NEXT_PUBLIC_SITE_URL=https://modstagram.com
ENVEOF
fi
# Copy Google Client ID from .env.local if present
if [ -f .env.local ] && grep -q GOOGLE .env.local 2>/dev/null; then
grep GOOGLE .env.local >> .env.production 2>/dev/null || true
fi
npm pkg set scripts.start="next start --port 3004 --hostname 0.0.0.0"
echo ">>> npm install (do NOT run npm ci — lock file on server may be old) ..."
npm install @react-oauth/google@^0.13.5 --save
npm install
echo ">>> npm run build ..."
if ! npm run build; then
echo ""
echo "!!! BUILD FAILED — site stays 502 until build succeeds."
echo " Send the error lines above to fix the code/deps."
exit 1
fi
if [ ! -f .next/BUILD_ID ]; then
echo "ERROR: .next/BUILD_ID missing after build"
exit 1
fi
echo ">>> PM2 start ..."
pm2 start "$APP_DIR/ecosystem.config.cjs" --update-env
pm2 save
sleep 3
echo ""
echo ">>> PM2 status:"
pm2 list | grep -E "modstagram|name" || pm2 list
echo ""
echo ">>> Ports:"
ss -tlnp | grep -E '3001|3004' || echo "(nothing on 3001/3004)"
echo ""
HTTP=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:3004/ || echo "000")
echo ">>> curl 127.0.0.1:3004 → HTTP $HTTP"
if [ "$HTTP" != "200" ] && [ "$HTTP" != "304" ] && [ "$HTTP" != "307" ]; then
echo ""
echo "!!! Next.js not healthy. Last logs:"
pm2 logs modstagram-next --lines 40 --nostream
exit 1
fi
echo ""
echo ">>> OK — modstagram.com should work (nginx → 3004)."

View File

@@ -0,0 +1,68 @@
import { getApiBaseUrl } from "@/components/main/BaseUrl";
export type AcademyExploreItem = {
_id: string;
courseId: string;
type: "image" | "video";
files?: { path?: string; type?: string }[];
course_video?: string | null;
course_images?: string[];
course_image?: string;
course_name?: string;
teacher_name?: string;
file_name?: string;
caption?: string;
user_id?: string;
user_name?: string;
first_name?: string;
last_name?: string;
profile_image?: string;
likesCount?: number;
createdAt?: string;
status?: string;
};
type AcademyExploreResponse = {
success?: boolean;
data?: {
items: AcademyExploreItem[];
pagination?: {
currentPage: number;
totalPages: number;
totalItems: number;
hasNextPage: boolean;
};
};
};
export async function fetchAcademyExplore(
page: number,
limit: number,
token: string
): Promise<{
items: AcademyExploreItem[];
hasNextPage: boolean;
}> {
const apiUrl = `${getApiBaseUrl()}/academy/academy/explore/free-content?page=${page}&limit=${limit}`;
const response = await fetch(apiUrl, {
cache: "no-store",
headers: {
Authorization: token ? `Bearer ${token}` : "",
},
});
if (response.status === 404) {
return { items: [], hasNextPage: false };
}
if (!response.ok) {
throw new Error(`Academy explore failed: ${response.status}`);
}
const json = (await response.json()) as AcademyExploreResponse;
const items = json.data?.items ?? [];
const hasNextPage = json.data?.pagination?.hasNextPage ?? false;
return { items, hasNextPage };
}

View File

@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { BASE_URL } from "@/components/main/BaseUrl";
import { getApiBaseUrl } from "@/components/main/BaseUrl";
export async function fetchBillboards(
page: number,
@@ -27,7 +27,7 @@ export async function fetchBillboards(
timestamp: Date.now().toString(), // اضافه کردن timestamp برای جلوگیری از کش
}).toString();
const apiUrl = `${BASE_URL}/advertising/web?${queryParams}`;
const apiUrl = `${getApiBaseUrl()}/advertising/web?${queryParams}`;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 90_000);

View File

@@ -0,0 +1,32 @@
import { getApiBaseUrl } from "@/components/main/BaseUrl";
import { Post } from "@/types/types";
export async function fetchBookmarkPosts(
page: number,
limit: number,
token: string
): Promise<{ posts: Post[]; hasMore: boolean; totalItems: number }> {
const base = getApiBaseUrl();
const res = await fetch(
`${base}/posts/bookmarks?page=${page}&limit=${limit}`,
{
cache: "no-store",
headers: { Authorization: `Bearer ${token}` },
}
);
if (!res.ok) {
return { posts: [], hasMore: false, totalItems: 0 };
}
const data = await res.json();
const posts = (data?.posts ?? []) as Post[];
const totalItems = data?.totalItems ?? posts.length;
const totalPages = data?.totalPages ?? 1;
return {
posts,
hasMore: page < totalPages,
totalItems,
};
}

View File

@@ -1,22 +1,38 @@
import { BASE_URL } from "@/components/main/BaseUrl";
import { getApiBaseUrl } from "@/components/main/BaseUrl";
import { Post } from "@/types/types";
/** Fetch a single post by id (tries dedicated endpoint, then list fallback) */
/** Fetch a single post by id */
export async function fetchPostById(
id: string,
token?: string
): Promise<Post | null> {
const base = getApiBaseUrl();
const headers: HeadersInit = token
? { Authorization: `Bearer ${token}` }
: {};
const tryUrls = [
`${BASE_URL}/posts/${id}`,
`${BASE_URL}/posts/web/${id}`,
`${BASE_URL}/posts/get/${id}`,
const primaryUrl = `${base}/posts/web/${id}`;
try {
const res = await fetch(primaryUrl, {
cache: "no-store",
headers,
});
if (res.ok) {
const data = await res.json();
const post = data?.post ?? data?.data ?? data;
if (post?._id) return post as Post;
}
} catch {
/* try fallbacks */
}
const fallbackUrls = [
`${base}/posts/${id}`,
`${base}/posts/get/${id}`,
];
for (const url of tryUrls) {
for (const url of fallbackUrls) {
try {
const res = await fetch(url, {
cache: "no-store",
@@ -31,19 +47,5 @@ export async function fetchPostById(
}
}
try {
const res = await fetch(
`${BASE_URL}/users/web?page=1&limit=50&_id=${id}`,
{ cache: "no-store", headers }
);
if (res.ok) {
const data = await res.json();
const found = (data?.posts as Post[])?.find((p) => p._id === id);
if (found) return found;
}
} catch {
/* ignore */
}
return null;
}

View File

@@ -1,4 +1,4 @@
import { BASE_URL } from "@/components/main/BaseUrl";
import { getApiBaseUrl } from "@/components/main/BaseUrl";
import { normalizeUserLevel } from "@/lib/userLevel";
export async function fetchPosts(
@@ -11,7 +11,27 @@ export async function fetchPosts(
userLevel?: string;
rateFilter?: string;
_id?: string;
type?: string;
type?: "image" | "video";
exploreFilter?: string;
subExpertise?: string;
lat?: string;
lng?: string;
feedMode?: "grid" | "reels";
seedPostId?: string;
sort?: "latest";
reelsTab?: "for_you" | "following" | "saved" | "near_me";
lat?: string;
lng?: string;
heightMin?: string;
heightMax?: string;
weightMin?: string;
weightMax?: string;
sizeMin?: string;
sizeMax?: string;
hair_color?: string;
eye_color?: string;
q?: string;
hashtag?: string;
},
token: string
) {
@@ -33,7 +53,7 @@ export async function fetchPosts(
...validFilters,
}).toString();
const apiUrl = `${BASE_URL}/users/web?${queryParams}`;
const apiUrl = `${getApiBaseUrl()}/users/web?${queryParams}`;
const response = await fetch(apiUrl, {
cache: "no-store",
@@ -46,5 +66,16 @@ export async function fetchPosts(
throw new Error(`Network response was not ok: ${response.status}`);
}
return response.json();
return response.json() as Promise<{
posts: unknown[];
totalPages?: number;
totalItems?: number;
feedMeta?: {
isColdStart?: boolean;
emptyFollowing?: boolean;
emptySaved?: boolean;
requiresAuth?: boolean;
suggestedUsers?: unknown[];
};
}>;
}

23
src/api/fetchProject.ts Normal file
View File

@@ -0,0 +1,23 @@
import { getApiBaseUrl } from "@/components/main/BaseUrl";
import { Project } from "@/types/types";
export async function fetchProject(
id: string,
token = ""
): Promise<Project | null> {
try {
const response = await fetch(`${getApiBaseUrl()}/projects/get/web/${id}`, {
cache: "no-store",
headers: {
Authorization: token ? `Bearer ${token}` : "",
},
});
if (!response.ok) return null;
const data = await response.json();
return data?.project ?? null;
} catch {
return null;
}
}

View File

@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { BASE_URL } from "@/components/main/BaseUrl";
import { getApiBaseUrl } from "@/components/main/BaseUrl";
export async function fetchProjects(
page: number,
@@ -28,7 +28,7 @@ export async function fetchProjects(
timestamp: Date.now().toString(), // اضافه کردن timestamp برای جلوگیری از کش
}).toString();
const apiUrl = `${BASE_URL}/projects?${queryParams}`;
const apiUrl = `${getApiBaseUrl()}/projects?${queryParams}`;
const response = await fetch(apiUrl, {
cache: "no-store", // غیرفعال کردن کش

View File

@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { BASE_URL } from "@/components/main/BaseUrl";
import { getApiBaseUrl } from "@/components/main/BaseUrl";
export async function fetchSearch(
page: number,
@@ -25,7 +25,7 @@ export async function fetchSearch(
timestamp: Date.now().toString(), // اضافه کردن timestamp برای جلوگیری از کش
}).toString();
const apiUrl = `${BASE_URL}/search-web?${queryParams}`;
const apiUrl = `${getApiBaseUrl()}/search-web?${queryParams}`;
const response = await fetch(apiUrl, {
cache: 'no-store', // غیرفعال کردن کش

204
src/api/fetchStories.ts Normal file
View File

@@ -0,0 +1,204 @@
import { getApiBaseUrl } from "@/components/main/BaseUrl";
import type { PostLinkKind } from "@/lib/postLinkCaption";
export type StoryTextOverlay = {
type?: "text";
text: string;
x: number;
y: number;
font_size: number;
color: string;
background?: string;
rotation?: number;
align?: "left" | "center" | "right";
};
/** اورلی لینک روی استوری — همون ۶ مورد قابل‌لینک ثبت پست (پکیج/پروژه/بیلبورد/فروشگاه/کالا/رزرو) */
export type StoryLinkOverlay = {
type: "link";
link_kind: PostLinkKind;
link_id: string;
link_title: string;
x: number;
y: number;
rotation?: number;
};
export type StoryOverlay = StoryTextOverlay | StoryLinkOverlay;
export type StoryItem = {
_id: string;
media_path: string;
media_type: "image" | "video";
linked_post_id?: string | null;
overlays?: StoryOverlay[];
createdAt?: string;
expires_at?: string;
viewed?: boolean;
liked?: boolean;
likes_count?: number;
};
export type StoryFeedUser = {
user: {
_id: string;
user_name: string;
first_name: string;
last_name: string;
profile_image?: string;
verify_badge?: string;
};
stories: StoryItem[];
has_unviewed: boolean;
is_following?: boolean;
};
export type StoriesFeedResponse = {
feed: StoryFeedUser[];
my_story: StoryFeedUser | null;
viewer_id: string | null;
};
export async function fetchStoriesFeed(
token: string
): Promise<StoriesFeedResponse> {
const res = await fetch(`${getApiBaseUrl()}/stories/feed`, {
cache: "no-store",
headers: {
Authorization: token ? `Bearer ${token}` : "",
},
});
if (!res.ok) throw new Error("Failed to load stories");
return res.json();
}
export async function markStoryViewed(
storyId: string,
token: string
): Promise<void> {
await fetch(`${getApiBaseUrl()}/stories/view`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ storyId }),
});
}
export async function createStoryBase64(
file: { name: string; type: string; data: string },
token: string,
overlays: StoryOverlay[] = []
): Promise<void> {
const res = await fetch(`${getApiBaseUrl()}/stories/create-base64`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ file, overlays }),
});
if (!res.ok) {
const json = await res.json().catch(() => ({}));
throw new Error(json?.message || "خطا در انتشار استوری");
}
}
export async function updateStoryOverlays(
storyId: string,
overlays: StoryOverlay[],
token: string
): Promise<void> {
const res = await fetch(`${getApiBaseUrl()}/stories/${storyId}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ overlays }),
});
if (!res.ok) {
const json = await res.json().catch(() => ({}));
throw new Error(json?.message || "خطا در ویرایش استوری");
}
}
export type StoryViewerUser = {
_id: string;
user_name: string;
first_name: string;
last_name: string;
profile_image?: string;
viewed_at?: string;
};
export async function likeStory(
storyId: string,
token: string
): Promise<{ liked: boolean; already?: boolean }> {
const res = await fetch(`${getApiBaseUrl()}/stories/like`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ storyId }),
});
if (!res.ok) {
const json = await res.json().catch(() => ({}));
throw new Error(json?.message || "خطا در لایک استوری");
}
return res.json();
}
export async function commentOnStory(
storyId: string,
comment: string,
token: string
): Promise<void> {
const res = await fetch(`${getApiBaseUrl()}/stories/comment`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ storyId, comment }),
});
if (!res.ok) {
const json = await res.json().catch(() => ({}));
throw new Error(json?.message || "خطا در ارسال کامنت");
}
}
export async function fetchStoryViewers(
storyId: string,
token: string
): Promise<{ viewers: StoryViewerUser[]; total: number }> {
const res = await fetch(`${getApiBaseUrl()}/stories/${storyId}/viewers`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
const json = await res.json().catch(() => ({}));
throw new Error(json?.message || "خطا در دریافت بازدیدکنندگان");
}
return res.json();
}
export async function sharePostToStory(
postId: string,
token: string
): Promise<void> {
const res = await fetch(`${getApiBaseUrl()}/stories/share-post`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ postId }),
});
if (!res.ok) {
const json = await res.json().catch(() => ({}));
throw new Error(json?.message || "خطا در انتشار استوری");
}
}

View File

@@ -0,0 +1,45 @@
import { getApiBaseUrl } from "@/components/main/BaseUrl";
export type ExploreInteractionPayload = {
targetType:
| "post"
| "profile"
| "academy"
| "shop_product"
| "academy_course"
| "billboard";
targetId: string;
authorId?: string;
contentType?: "image" | "video" | "academy" | "shop_product" | "billboard";
action?:
| "view"
| "profile_visit"
| "share"
| "like"
| "comment"
| "offer"
| "watch_complete"
| "skip"
| "dwell";
dwellMs?: number;
};
export async function trackExploreInteraction(
payload: ExploreInteractionPayload,
token: string
): Promise<void> {
if (!token) return;
try {
await fetch(`${getApiBaseUrl()}/users/explore/interaction`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(payload),
});
} catch {
/* non-blocking */
}
}

View File

@@ -1,127 +1,193 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
"use client";
import AuthButton from "@/components/auth/AuthButton";
import AuthHead from "@/components/auth/AuthHead";
import AuthPasswordInput from "@/components/auth/AuthPasswordInput";
import AuthOtpInput from "@/components/auth/AuthOtpInput";
import Container from "@/components/elements/Container";
import AuthRules from "@/components/auth/AuthRules";
import Link from "next/link";
import React, { useState } from "react";
import React, { useMemo, useState } from "react";
import useAxios from "@/hooks/useAxios";
import { useFormik } from "formik";
import * as yup from "yup";
import { useRouter } from "next/navigation";
import { IVerifyOtp } from "@/types/types";
// Validation schema using Yup
const schema = yup.object().shape({
password: yup
.string()
.required("کلمه عبور نمی\u200Cتواند خالی باشد")
.min(8, "کلمه عبور نباید کوتاه تر از 8 کاراکتر باشد")
.matches(
/^(?=.*[a-zA-Zآ-ی])(?=.*\d).{8,}$/,
"کلمه عبور باید شامل حروف و اعداد باشد"
),
confirmPassword: yup
.string()
.required("تکرار کلمه عبور نمی\u200Cتواند خالی باشد")
.oneOf(
[yup.ref("password")],
"تکرار کلمه عبور باید با کلمه عبور مطابقت داشته باشد"
)
.required("تکرار کلمه عبور الزامی است"),
});
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import LocalePageShell from "@/components/i18n/LocalePageShell";
function ChangePassword() {
const { t } = useTranslation("common");
const router = useRouter();
const [isModalOpen, setModalOpen] = useState(false);
const { request, loading } = useAxios();
const mobile =
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
const otp =
typeof window !== "undefined" ? localStorage.getItem("otp") : null;
const [otpError, setOtpError] = useState(false);
const resetIdentifier = useMemo(
() =>
typeof window !== "undefined"
? localStorage.getItem("reset_identifier")
: null,
[]
);
const resetChannel = useMemo(
() =>
typeof window !== "undefined"
? localStorage.getItem("reset_channel")
: null,
[]
);
const isEmailReset = resetChannel === "email";
const schema = useMemo(
() =>
yup.object().shape({
otp: yup
.string()
.matches(/^\d{6}$/, t("auth.otpInvalid"))
.required(t("auth.otpRequired")),
password: yup
.string()
.required(t("auth.passwordRequired"))
.min(8, t("auth.passwordMin"))
.matches(
/^(?=.*[a-zA-Zآ-ی])(?=.*\d).{8,}$/,
t("auth.passwordLettersNumbers")
),
confirmPassword: yup
.string()
.required(t("auth.confirmPasswordRequired"))
.oneOf([yup.ref("password")], t("auth.confirmPasswordMismatch")),
}),
[t]
);
const formik = useFormik({
initialValues: {
otp: "",
password: "",
confirmPassword: "",
},
validationSchema: schema,
onSubmit: async (values) => {
if (!resetIdentifier) {
toast.error(t("auth.getResetCodeFirst"));
router.push("/forget-password");
return;
}
const isMobile = /^09\d{9}$/.test(resetIdentifier);
try {
(await request("POST", "/login/change_password", {
mobile,
otp,
await request("POST", "/login/change_password", {
...(isMobile
? { mobile: resetIdentifier }
: { email: resetIdentifier }),
otp: values.otp.trim(),
new_password: values.password,
})) as IVerifyOtp;
router.push("/login");
} catch (err: any) {
console.log("Unhandled error:", err?.message);
});
localStorage.removeItem("reset_identifier");
localStorage.removeItem("reset_channel");
toast.success(t("auth.passwordChangedSuccess"));
router.push("/login-with-username");
} catch (err: unknown) {
setOtpError(true);
const message =
(err as { response?: { data?: { message?: string } } })?.response?.data
?.message || t("auth.changePasswordFailed");
toast.error(message);
}
},
});
return (
<Container>
<div className="p-4 flex flex-col items-center h-screen justify-center">
<AuthHead title="تغییر کلمه عبور" />
<form
onSubmit={formik.handleSubmit}
className="w-full max-w-sm flex flex-col items-center"
>
<AuthPasswordInput
name="password"
placeholder="کلمه عبور جدید"
wrapperClassName="mt-4"
className={`border ${
formik.touched.password && formik.errors.password
? "border-red-500 dark:border-red-500"
: "border-gray-300"
}`}
value={formik.values.password}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
/>
{formik.touched.password && formik.errors.password && (
<small className="text-red-500 mt-2 block">
{formik.errors.password}
<LocalePageShell>
<Container>
<div className="p-4 flex flex-col items-center h-dvh justify-center">
<AuthHead title={t("auth.changePasswordTitle")} />
<p className="text-sm text-gray-500 text-center mb-4 max-w-sm">
{isEmailReset
? t("auth.changePasswordEmailHint")
: t("auth.changePasswordMobileHint")}
</p>
<form
onSubmit={formik.handleSubmit}
className="w-full max-w-sm flex flex-col items-center"
>
<AuthOtpInput
className="mt-3"
value={formik.values.otp}
onChange={(otp) => {
formik.setFieldValue("otp", otp);
setOtpError(false);
}}
error={
otpError || Boolean(formik.touched.otp && formik.errors.otp)
}
disabled={loading}
/>
{formik.touched.otp && formik.errors.otp && (
<small className="text-red-500 mt-2">{formik.errors.otp}</small>
)}
<AuthPasswordInput
name="password"
placeholder={t("auth.newPasswordPlaceholder")}
wrapperClassName="mt-4"
className={`border ${
formik.touched.password && formik.errors.password
? "border-red-500 dark:border-red-500"
: "border-gray-300"
}`}
value={formik.values.password}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
/>
{formik.touched.password && formik.errors.password && (
<small className="text-red-500 mt-2 block">
{formik.errors.password}
</small>
)}
<AuthPasswordInput
name="confirmPassword"
placeholder={t("auth.confirmPasswordPlaceholder")}
wrapperClassName="mt-4"
className={`border ${
formik.touched.confirmPassword && formik.errors.confirmPassword
? "border-red-500 dark:border-red-500"
: "border-gray-300"
}`}
value={formik.values.confirmPassword}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
/>
{formik.touched.confirmPassword && formik.errors.confirmPassword && (
<small className="text-red-500 mt-2 block">
{formik.errors.confirmPassword}
</small>
)}
<AuthButton
type="submit"
className="mt-5"
loading={loading}
disabled={loading || formik.values.otp.length !== 6}
>
{t("auth.changePassword")}
</AuthButton>
</form>
<Link href="/forget-password">
<small className="text-[#667AC1] font-bold text-xs mt-8 block">
{t("auth.resendCodeLink")}
</small>
)}
<AuthPasswordInput
name="confirmPassword"
placeholder="تکرار کلمه عبور"
wrapperClassName="mt-4"
className={`border ${
formik.touched.confirmPassword && formik.errors.confirmPassword
? "border-red-500 dark:border-red-500"
: "border-gray-300"
}`}
value={formik.values.confirmPassword}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
/>
{formik.touched.confirmPassword && formik.errors.confirmPassword && (
<small className="text-red-500 mt-2 block">
{formik.errors.confirmPassword}
</Link>
<Link href="/login-with-username">
<small className="text-[#FF5C00] font-bold text-xs mt-4 block">
{t("auth.backToLogin")}
</small>
)}
<AuthButton type="submit" className="mt-5" loading={loading} disabled={loading}>
تغییر کلمه عبور
</AuthButton>
</form>
<Link href={"/register"}>
<small className="text-[#292D32] font-bold text-xs mt-8 block">
حساب کاربری ندارید؟ <span className="text-[#0033EA]">ثبت نام</span>
</small>
</Link>
<AuthRules isModalOpen={isModalOpen} setModalOpen={setModalOpen} />
</div>
</Container>
</Link>
</div>
</Container>
</LocalePageShell>
);
}

View File

@@ -4,9 +4,8 @@
import AuthButton from "@/components/auth/AuthButton";
import AuthHead from "@/components/auth/AuthHead";
import Container from "@/components/elements/Container";
import AuthRules from "@/components/auth/AuthRules";
import Link from "next/link";
import React, { useState } from "react";
import React, { useMemo, useState } from "react";
import AuthOtpInput from "@/components/auth/AuthOtpInput";
import AuthOtpStatus from "@/components/auth/AuthOtpStatus";
import useAxios from "@/hooks/useAxios";
@@ -16,18 +15,12 @@ import { useRouter } from "next/navigation";
import { IVerifyOtp } from "@/types/types";
import { setAuthSession } from "@/lib/auth/session";
import { AUTH_SUCCESS_DELAY_MS, pause } from "@/lib/auth/feedback";
// Validation schema using Yup
const schema = yup.object({
otp: yup
.string()
.matches(/^\d{6}$/, "کد وارد شده صحیح نیست")
.required("کد تایید را وارد کنید"),
});
import { useTranslation } from "react-i18next";
import LocalePageShell from "@/components/i18n/LocalePageShell";
function ForgetPasswordOtp() {
const { t } = useTranslation("common");
const router = useRouter();
const [isModalOpen, setModalOpen] = useState(false);
const [otpExpired, setOtpExpired] = useState(false);
const [resendLoading, setResendLoading] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
@@ -35,6 +28,18 @@ function ForgetPasswordOtp() {
const { request, loading } = useAxios();
const mobile =
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
const schema = useMemo(
() =>
yup.object({
otp: yup
.string()
.matches(/^\d{6}$/, t("auth.otpInvalid"))
.required(t("auth.otpRequired")),
}),
[t]
);
const formik = useFormik({
initialValues: {
otp: "",
@@ -67,67 +72,74 @@ function ForgetPasswordOtp() {
});
return (
<Container>
<div className="p-4 flex flex-col items-center h-screen justify-center">
<AuthHead title="فراموش کردن کلمه عبور" />
<AuthOtpStatus
mobile={mobile}
resendLoading={resendLoading}
onExpiredChange={setOtpExpired}
onResend={async () => {
if (!mobile) return;
setResendLoading(true);
try {
await request("POST", "/login", { mobile });
formik.setFieldValue("otp", "");
setOtpError(false);
setIsSuccess(false);
} finally {
setResendLoading(false);
}
}}
/>
<form
onSubmit={formik.handleSubmit}
className="w-full max-w-sm flex flex-col items-center"
>
<AuthOtpInput
className="mt-3"
value={formik.values.otp}
onChange={(otp) => {
formik.setFieldValue("otp", otp);
setOtpError(false);
setIsSuccess(false);
<LocalePageShell>
<Container>
<div className="p-4 flex flex-col items-center h-dvh justify-center">
<AuthHead title={t("auth.forgetPasswordTitle")} />
<AuthOtpStatus
mobile={mobile}
resendLoading={resendLoading}
onExpiredChange={setOtpExpired}
onResend={async () => {
if (!mobile) return;
setResendLoading(true);
try {
await request("POST", "/login", { mobile });
formik.setFieldValue("otp", "");
setOtpError(false);
setIsSuccess(false);
} finally {
setResendLoading(false);
}
}}
onComplete={() => setTimeout(() => formik.submitForm(), 400)}
error={otpError || Boolean(formik.touched.otp && formik.errors.otp)}
success={isSuccess}
disabled={loading || otpExpired || isSuccess}
/>
{formik.touched.otp && formik.errors.otp && (
<small className="text-red-500 mt-2">{formik.errors.otp}</small>
)}
<AuthButton
type="submit"
className="mt-5"
loading={loading} disabled={loading || otpExpired || isSuccess || formik.values.otp.length !== 6}
<form
onSubmit={formik.handleSubmit}
className="w-full max-w-sm flex flex-col items-center"
>
تایید کد
</AuthButton>
</form>
<Link href={"/login-with-username"}>
<small className="text-[#FF5C00] font-bold text-xs mt-8 block">
با نام کاربری و کلمه عبور خود وارد شوید
</small>
</Link>
<Link href={"/forget-password"}>
<small className="text-[#667AC1] font-bold text-xs mt-8 block">
اصلاح شماره موبایل
</small>
</Link>
<AuthRules isModalOpen={isModalOpen} setModalOpen={setModalOpen} />
</div>
</Container>
<AuthOtpInput
className="mt-3"
value={formik.values.otp}
onChange={(otp) => {
formik.setFieldValue("otp", otp);
setOtpError(false);
setIsSuccess(false);
}}
onComplete={() => setTimeout(() => formik.submitForm(), 400)}
error={otpError || Boolean(formik.touched.otp && formik.errors.otp)}
success={isSuccess}
disabled={loading || otpExpired || isSuccess}
/>
{formik.touched.otp && formik.errors.otp && (
<small className="text-red-500 mt-2">{formik.errors.otp}</small>
)}
<AuthButton
type="submit"
className="mt-5"
loading={loading}
disabled={
loading ||
otpExpired ||
isSuccess ||
formik.values.otp.length !== 6
}
>
{t("auth.verifyCode")}
</AuthButton>
</form>
<Link href={"/login-with-username"}>
<small className="text-[#FF5C00] font-bold text-xs mt-8 block">
{t("auth.loginWithUsername")}
</small>
</Link>
<Link href={"/forget-password"}>
<small className="text-[#667AC1] font-bold text-xs mt-8 block">
{t("auth.editMobile")}
</small>
</Link>
</div>
</Container>
</LocalePageShell>
);
}

View File

@@ -5,82 +5,128 @@ import AuthHead from "@/components/auth/AuthHead";
import AuthInput from "@/components/auth/AuthInput";
import Container from "@/components/elements/Container";
import Link from "next/link";
import React from "react";
import { useRouter } from "next/navigation";
import React, { useMemo, useState } from "react";
import useAxios from "@/hooks/useAxios";
import { useFormik } from "formik";
import * as yup from "yup";
import { markOtpSent } from "@/lib/auth/otpTimer";
import { useRouter } from "next/navigation";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import LocalePageShell from "@/components/i18n/LocalePageShell";
// Validation schema using Yup
const schema = yup.object({
mobile: yup
.string()
.matches(/^(09\d{9})$/, "شماره موبایل معتبر نیست")
.required("شماره موبایل الزامی است"),
});
function ForgetPassword() {
function ForgetPasswordPage() {
const { t } = useTranslation("common");
const router = useRouter();
const { request, loading } = useAxios();
const [destination, setDestination] = useState("");
const schema = useMemo(
() =>
yup.object({
identifier: yup
.string()
.required(t("auth.identifierRequired"))
.test("identifier", t("auth.identifierInvalid"), (value) => {
if (!value) return false;
const trimmed = value.trim();
return (
/^(09\d{9})$/.test(trimmed) ||
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)
);
}),
}),
[t]
);
const formik = useFormik({
initialValues: {
mobile: "",
},
initialValues: { identifier: "" },
validationSchema: schema,
onSubmit: async (values) => {
try {
await request("POST", "/login", { mobile: values.mobile });
await localStorage.setItem("mobile", values.mobile);
markOtpSent();
router.push("/forget-password-otp");
} catch (err) {
console.log(err);
const identifier = values.identifier.trim();
const isMobile = /^09\d{9}$/.test(identifier);
const response = (await request("POST", "/login/forgot_password", {
identifier,
...(isMobile ? { mobile: identifier } : { email: identifier }),
})) as {
channel?: "sms" | "email";
destination?: string;
message?: string;
};
localStorage.setItem("reset_identifier", identifier);
if (response.channel) {
localStorage.setItem("reset_channel", response.channel);
}
if (response.destination) {
setDestination(response.destination);
}
toast.success(response.message || t("auth.resetCodeSentDefault"));
router.push("/change-passowrd");
} catch (err: unknown) {
const message =
(err as { response?: { data?: { message?: string } } })?.response?.data
?.message || t("auth.sendCodeFailed");
toast.error(message);
}
},
});
return (
<Container>
<div className="p-4 flex flex-col items-center h-screen justify-center">
<AuthHead title="فراموش کردن کلمه عبور" />
<form
onSubmit={formik.handleSubmit}
className="w-full max-w-sm flex flex-col items-center"
>
<AuthInput
name="mobile"
maxLength={11}
type="text"
dir="ltr"
placeholder="09 _ _ _ _ _ _ _ _ _"
className={`border ${
formik.touched.mobile && formik.errors.mobile
? "border-red-500 dark:border-red-500"
: "border-gray-300"
}`}
value={formik.values.mobile}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
/>
{formik.touched.mobile && formik.errors.mobile && (
<small className="text-red-500 mt-2 block">
{formik.errors.mobile}
<LocalePageShell>
<Container>
<div className="p-4 flex flex-col items-center h-dvh justify-center">
<AuthHead title={t("auth.forgetPasswordTitle")} />
<p className="text-sm text-gray-500 text-center mb-4 max-w-sm">
{t("auth.forgetPasswordDesc")}
</p>
<form
onSubmit={formik.handleSubmit}
className="w-full max-w-sm flex flex-col items-center"
>
<AuthInput
name="identifier"
type="text"
dir="ltr"
placeholder={t("auth.identifierPlaceholder")}
className={`border ${
formik.touched.identifier && formik.errors.identifier
? "border-red-500 dark:border-red-500"
: "border-gray-300"
}`}
value={formik.values.identifier}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
/>
{formik.touched.identifier && formik.errors.identifier && (
<small className="text-red-500 mt-2 block">
{formik.errors.identifier}
</small>
)}
{destination && (
<small className="text-green-600 mt-2 block text-center">
{t("auth.codeSentTo", { destination })}
</small>
)}
<AuthButton
type="submit"
className="mt-5"
loading={loading}
disabled={loading}
>
{t("auth.sendResetCode")}
</AuthButton>
</form>
<Link href="/login-with-username">
<small className="text-[#667AC1] font-bold text-xs mt-8 block">
{t("auth.backToLogin")}
</small>
)}
<AuthButton type="submit" className="mt-5" loading={loading} disabled={loading}>
ارسال کد
</AuthButton>
</form>
<Link href={"/register"}>
<small className="text-[#292D32] font-bold text-xs mt-8 block">
حساب کاربری ندارید؟ <span className="text-[#0033EA]">ثبت نام</span>
</small>
</Link>
</div>
</Container>
</Link>
</div>
</Container>
</LocalePageShell>
);
}
export default ForgetPassword;
export default ForgetPasswordPage;

View File

@@ -0,0 +1,85 @@
"use client";
import AuthHead from "@/components/auth/AuthHead";
import AuthOtpInput from "@/components/auth/AuthOtpInput";
import AuthButton from "@/components/auth/AuthButton";
import Container from "@/components/elements/Container";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import useAxios from "@/hooks/useAxios";
import { completeLogin, getSafeRedirectPath } from "@/lib/auth/postLogin";
import { IVerifyOtp } from "@/types/types";
import { useRouter } from "next/navigation";
import React, { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
export default function LoginTwoFactorPage() {
const { t } = useTranslation("common");
const router = useRouter();
const { request, loading } = useAxios();
const [code, setCode] = useState("");
const [tempToken, setTempToken] = useState("");
useEffect(() => {
const token =
typeof window !== "undefined"
? sessionStorage.getItem("login_2fa_temp")
: null;
if (!token) {
router.replace("/login");
return;
}
setTempToken(token);
}, [router]);
const handleVerify = async () => {
if (!tempToken || code.length !== 6) return;
try {
const response = (await request("POST", "/auth/totp/verify-login", {
temp_token: tempToken,
code,
})) as IVerifyOtp;
sessionStorage.removeItem("login_2fa_temp");
const loggedIn = await completeLogin(router, response, {
redirectTo: getSafeRedirectPath(),
});
if (!loggedIn) {
toast.error(t("auth.invalidServerResponse"));
}
} catch {
toast.error(t("settings.edit.twoFactor.invalidCode"));
}
};
return (
<LocalePageShell>
<Container>
<div className="flex h-dvh flex-col items-center justify-center p-4">
<AuthHead title={t("auth.twoFactorTitle")} />
<p className="mb-6 max-w-sm text-center text-sm text-neutral-500">
{t("auth.twoFactorHint")}
</p>
<AuthOtpInput
value={code}
onChange={setCode}
onComplete={() => setTimeout(handleVerify, 300)}
disabled={loading || !tempToken}
/>
<AuthButton
type="button"
className="mt-5"
loading={loading}
disabled={loading || code.length !== 6 || !tempToken}
onClick={handleVerify}
>
{t("auth.verifyCode")}
</AuthButton>
</div>
</Container>
</LocalePageShell>
);
}

View File

@@ -6,38 +6,44 @@ import AuthHead from "@/components/auth/AuthHead";
import AuthInput from "@/components/auth/AuthInput";
import AuthPasswordInput from "@/components/auth/AuthPasswordInput";
import Container from "@/components/elements/Container";
import AuthRules from "@/components/auth/AuthRules";
import Link from "next/link";
import React, { useState } from "react";
import React, { useMemo, useState } from "react";
import useAxios from "@/hooks/useAxios";
import { useFormik } from "formik";
import * as yup from "yup";
import { useRouter } from "next/navigation";
import { IVerifyOtp } from "@/types/types";
import { setAuthSession } from "@/lib/auth/session";
import { AUTH_SUCCESS_DELAY_MS, pause } from "@/lib/auth/feedback";
// Validation schema using Yup
const schema = yup.object().shape({
password: yup
.string()
.required("کلمه عبور نمی\u200Cتواند خالی باشد")
.min(8, "کلمه عبور نباید کوتاه تر از 8 کاراکتر باشد")
.matches(
/^(?=.*[a-zA-Zآ-ی])(?=.*\d).{8,}$/,
"کلمه عبور باید شامل حروف و اعداد باشد"
),
username: yup.string().required("نام کاربری الزامی است"),
});
import {
completeLogin,
getAxiosErrorMessage,
getSafeRedirectPath,
} from "@/lib/auth/postLogin";
import toast from "react-hot-toast";
import { useAuthSessionRedirect } from "@/hooks/useAuthSessionRedirect";
import { useTranslation } from "react-i18next";
import LocalePageShell from "@/components/i18n/LocalePageShell";
function LoginWithUsername() {
const { t, i18n } = useTranslation("common");
const router = useRouter();
const [isModalOpen, setModalOpen] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [authError, setAuthError] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const { request, loading } = useAxios();
const mobile =
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
const redirectPath = getSafeRedirectPath();
useAuthSessionRedirect();
const schema = useMemo(
() =>
yup.object().shape({
username: yup.string().required(t("auth.usernameRequired")),
password: yup
.string()
.required(t("auth.passwordRequired"))
.min(8, t("auth.passwordMin")),
}),
[t]
);
const formik = useFormik({
initialValues: {
@@ -46,109 +52,131 @@ function LoginWithUsername() {
},
validationSchema: schema,
onSubmit: async (values) => {
setAuthError(false);
setErrorMessage("");
try {
const response = (await request("POST", "/login/login_username", {
mobile,
user_name: values.username,
password: values.password,
})) as IVerifyOtp;
switch (response?.page) {
case "home":
await setAuthSession(response.token, {
id: response.id,
user_type: response.user_type,
step: response.step,
});
setIsSuccess(true);
await pause(AUTH_SUCCESS_DELAY_MS);
router.refresh();
router.push("/");
break;
case "auth-page":
await setAuthSession(response.token, {
id: response.id,
step: response.step,
});
setIsSuccess(true);
await pause(AUTH_SUCCESS_DELAY_MS);
router.push("/auth");
break;
default:
break;
const response = (await request(
"POST",
"/login/login_username",
{
user_name: values.username.trim(),
password: values.password,
},
{ noToast: true }
)) as IVerifyOtp;
const loggedIn = await completeLogin(router, response, {
redirectTo: redirectPath,
});
if (!loggedIn) {
const message = t("auth.invalidServerResponse");
setAuthError(true);
setErrorMessage(message);
toast.error(message);
return;
}
setIsSuccess(true);
} catch (err: any) {
const message = getAxiosErrorMessage(
err,
i18n.language === "en" ? "en" : "fa"
);
setAuthError(true);
setIsSuccess(false);
console.log("Unhandled error:", err?.message);
setErrorMessage(message);
toast.error(message);
}
},
});
return (
<Container>
<div className="p-4 flex flex-col items-center h-screen justify-center">
<AuthHead title="ورود" />
<form
onSubmit={formik.handleSubmit}
className="w-full max-w-sm flex flex-col items-center"
>
<AuthInput
name="username"
type="text"
placeholder="نام کاربری"
success={isSuccess}
error={authError || Boolean(formik.touched.username && formik.errors.username)}
value={formik.values.username}
onChange={(e) => {
setAuthError(false);
setIsSuccess(false);
formik.handleChange(e);
}}
onBlur={formik.handleBlur}
disabled={loading || isSuccess}
/>
{formik.touched.username && formik.errors.username && !authError && (
<small className="text-red-500 mt-2 block">
{formik.errors.username}
<LocalePageShell>
<Container>
<div className="p-4 flex flex-col items-center h-dvh justify-center">
<AuthHead title={t("auth.login")} />
<form
onSubmit={formik.handleSubmit}
className="w-full max-w-sm flex flex-col items-center"
>
<AuthInput
name="username"
type="text"
placeholder={t("auth.usernamePlaceholder")}
success={isSuccess}
error={
authError ||
Boolean(formik.touched.username && formik.errors.username)
}
value={formik.values.username}
onChange={(e) => {
setAuthError(false);
setErrorMessage("");
setIsSuccess(false);
formik.handleChange(e);
}}
onBlur={formik.handleBlur}
disabled={loading || isSuccess}
/>
{formik.touched.username && formik.errors.username && !authError && (
<small className="text-red-500 mt-2 block">
{formik.errors.username}
</small>
)}
<AuthPasswordInput
name="password"
placeholder={t("auth.passwordPlaceholder")}
wrapperClassName="mt-4"
success={isSuccess}
error={
authError ||
Boolean(formik.touched.password && formik.errors.password)
}
value={formik.values.password}
onChange={(e) => {
setAuthError(false);
setErrorMessage("");
setIsSuccess(false);
formik.handleChange(e);
}}
onBlur={formik.handleBlur}
disabled={loading || isSuccess}
/>
{formik.touched.password && formik.errors.password && !authError && (
<small className="text-red-500 mt-2 block">
{formik.errors.password}
</small>
)}
{authError && errorMessage && (
<small className="text-red-500 mt-2 block text-center">
{errorMessage}
</small>
)}
<AuthButton
type="submit"
className="mt-5"
loading={loading}
disabled={loading || isSuccess}
>
{t("auth.login")}
</AuthButton>
</form>
<Link href={"/forget-password"}>
<small className="text-[#FF5C00] font-bold text-xs mt-8 block">
{t("auth.forgotPassword")}
</small>
)}
<AuthPasswordInput
name="password"
placeholder="کلمه عبور"
wrapperClassName="mt-4"
success={isSuccess}
error={authError || Boolean(formik.touched.password && formik.errors.password)}
value={formik.values.password}
onChange={(e) => {
setAuthError(false);
setIsSuccess(false);
formik.handleChange(e);
}}
onBlur={formik.handleBlur}
disabled={loading || isSuccess}
/>
{formik.touched.password && formik.errors.password && !authError && (
<small className="text-red-500 mt-2 block">
{formik.errors.password}
</Link>
<Link href={"/register"}>
<small className="text-[#292D32] font-bold text-xs mt-8 block">
{t("auth.noAccount")}{" "}
<span className="text-[#0033EA]">{t("auth.signUp")}</span>
</small>
)}
<AuthButton type="submit" className="mt-5" loading={loading} disabled={loading || isSuccess}>
ورود
</AuthButton>
</form>
<Link href={"/forget-password"}>
<small className="text-[#FF5C00] font-bold text-xs mt-8 block">
کلمه عبور خود را فراموش کرده اید؟
</small>
</Link>
<Link href={"/register"}>
<small className="text-[#292D32] font-bold text-xs mt-8 block">
حساب کاربری ندارید؟ <span className="text-[#0033EA]">ثبت نام</span>
</small>
</Link>
<AuthRules isModalOpen={isModalOpen} setModalOpen={setModalOpen} />
</div>
</Container>
</Link>
</div>
</Container>
</LocalePageShell>
);
}

View File

@@ -4,27 +4,42 @@ import AuthButton from "@/components/auth/AuthButton";
import AuthHead from "@/components/auth/AuthHead";
import AuthInput from "@/components/auth/AuthInput";
import Container from "@/components/elements/Container";
import AuthRules from "@/components/auth/AuthRules";
import AuthLanguageToggle from "@/components/auth/AuthLanguageToggle";
import Link from "next/link";
import React, { useState } from "react";
import React, { useMemo } from "react";
import useAxios from "@/hooks/useAxios";
import { useFormik } from "formik";
import * as yup from "yup";
import { useRouter } from "next/navigation";
import { markOtpSent } from "@/lib/auth/otpTimer";
// Validation schema using Yup
const schema = yup.object({
mobile: yup
.string()
.matches(/^(09\d{9})$/, "شماره موبایل معتبر نیست")
.required("شماره موبایل الزامی است"),
});
import { getSafeRedirectPath } from "@/lib/auth/postLogin";
import { GoogleSignInButton } from "@/app/(auth)/AuthProviders";
import { MOBILE_NUMERIC_INPUT_PROPS } from "@/lib/auth/inputProps";
import { useAuthSessionRedirect } from "@/hooks/useAuthSessionRedirect";
import { useTranslation } from "react-i18next";
import LocalePageShell from "@/components/i18n/LocalePageShell";
function Login() {
const { t } = useTranslation("common");
const router = useRouter();
const [isModalOpen, setModalOpen] = useState(false);
const { request, loading } = useAxios();
useAuthSessionRedirect();
const redirectPath = getSafeRedirectPath();
const usernameLoginHref =
redirectPath !== "/"
? `/login-with-username?redirect=${encodeURIComponent(redirectPath)}`
: "/login-with-username";
const schema = useMemo(
() =>
yup.object({
mobile: yup
.string()
.matches(/^(09\d{9})$/, t("auth.mobileInvalid"))
.required(t("auth.mobileRequired")),
}),
[t]
);
const formik = useFormik({
initialValues: {
@@ -44,51 +59,54 @@ function Login() {
});
return (
<Container>
<div className="p-4 flex flex-col items-center h-screen justify-center">
<AuthHead title="ورود" />
<form
onSubmit={formik.handleSubmit}
className="w-full max-w-sm flex flex-col items-center"
>
<AuthInput
name="mobile"
maxLength={11}
type="tel"
pattern="[0-9]*"
dir="ltr"
placeholder="09 _ _ _ _ _ _ _ _ _"
className={`border ${
formik.touched.mobile && formik.errors.mobile
? "border-red-500 dark:border-red-500"
: "border-gray-300"
}`}
value={formik.values.mobile}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
/>
{formik.touched.mobile && formik.errors.mobile && (
<small className="text-red-500 mt-2 block">
{formik.errors.mobile}
<LocalePageShell>
<Container>
<div className="relative p-4 flex flex-col items-center h-dvh justify-center">
<AuthLanguageToggle className="absolute top-6 left-1/2 -translate-x-1/2" />
<AuthHead title={t("auth.login")} />
<form
onSubmit={formik.handleSubmit}
className="w-full max-w-sm flex flex-col items-center"
>
<AuthInput
name="mobile"
maxLength={11}
{...MOBILE_NUMERIC_INPUT_PROPS}
dir="ltr"
placeholder="09 _ _ _ _ _ _ _ _ _"
className={`border ${
formik.touched.mobile && formik.errors.mobile
? "border-red-500 dark:border-red-500"
: "border-gray-300"
}`}
value={formik.values.mobile}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
/>
{formik.touched.mobile && formik.errors.mobile && (
<small className="text-red-500 mt-2 block">
{formik.errors.mobile}
</small>
)}
<AuthButton type="submit" className="mt-5" loading={loading} disabled={loading}>
{t("auth.sendCode")}
</AuthButton>
</form>
<GoogleSignInButton mode="login" />
{/* <InstagramSignInButton mode="login" /> */}
<Link href={usernameLoginHref}>
<small className="text-[#FF5C00] font-bold text-xs mt-8 block">
{t("auth.loginWithUsername")}
</small>
)}
<AuthButton type="submit" className="mt-5" loading={loading} disabled={loading}>
ارسال کد
</AuthButton>
</form>
<Link href={"/login-with-username"}>
<small className="text-[#FF5C00] font-bold text-xs mt-8 block">
با نام کاربری و کلمه عبور خود وارد شوید
</small>
</Link>
<button onClick={() => setModalOpen(true)} className="mb-2">
<small className="text-[#FF5C00] font-bold text-xs mt-8 block">
قوانین و مقررات
</small>
</button>
<AuthRules isModalOpen={isModalOpen} setModalOpen={setModalOpen} />
</div>
</Container>
</Link>
<Link href="/privacy" className="mb-2">
<small className="text-[#FF5C00] font-bold text-xs mt-8 block">
{t("auth.rules")}
</small>
</Link>
</div>
</Container>
</LocalePageShell>
);
}

View File

@@ -4,9 +4,8 @@
import AuthButton from "@/components/auth/AuthButton";
import AuthHead from "@/components/auth/AuthHead";
import Container from "@/components/elements/Container";
import AuthRules from "@/components/auth/AuthRules";
import Link from "next/link";
import React, { useState } from "react";
import React, { useMemo, useState } from "react";
import AuthOtpInput from "@/components/auth/AuthOtpInput";
import AuthOtpStatus from "@/components/auth/AuthOtpStatus";
import useAxios from "@/hooks/useAxios";
@@ -14,20 +13,16 @@ import { useFormik } from "formik";
import * as yup from "yup";
import { useRouter } from "next/navigation";
import { IVerifyOtp } from "@/types/types";
import { setAuthSession } from "@/lib/auth/session";
import { AUTH_SUCCESS_DELAY_MS, pause } from "@/lib/auth/feedback";
// Validation schema using Yup
const schema = yup.object({
otp: yup
.string()
.matches(/^\d{6}$/, "کد وارد شده صحیح نیست")
.required("کد تایید را وارد کنید"),
});
import {
completeLogin,
getSafeRedirectPath,
} from "@/lib/auth/postLogin";
import { useTranslation } from "react-i18next";
import LocalePageShell from "@/components/i18n/LocalePageShell";
function VerifyOtp() {
const { t } = useTranslation("common");
const router = useRouter();
const [isModalOpen, setModalOpen] = useState(false);
const [otpExpired, setOtpExpired] = useState(false);
const [resendLoading, setResendLoading] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
@@ -35,6 +30,18 @@ function VerifyOtp() {
const { request, loading } = useAxios();
const mobile =
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
const schema = useMemo(
() =>
yup.object({
otp: yup
.string()
.matches(/^\d{6}$/, t("auth.otpInvalid"))
.required(t("auth.otpRequired")),
}),
[t]
);
const formik = useFormik({
initialValues: {
otp: "",
@@ -54,48 +61,13 @@ function VerifyOtp() {
otp: values.otp.trim(),
})) as IVerifyOtp;
switch (response?.page) {
case "home":
await setAuthSession(response.token, {
id: response.id,
user_type: response.user_type,
step: response.step,
});
setIsSuccess(true);
await pause(AUTH_SUCCESS_DELAY_MS);
router.refresh();
router.push("/");
break;
case "auth-page":
await setAuthSession(response.token, {
id: response.id,
step: response.step,
});
setIsSuccess(true);
await pause(AUTH_SUCCESS_DELAY_MS);
const loggedIn = await completeLogin(router, response, {
redirectTo: getSafeRedirectPath(),
});
switch (response.step) {
case "user_name":
router.push("/register/username");
break;
case "password":
router.push("/register/password");
break;
case "first_name":
router.push("/register/fullname");
break;
case "user_type":
router.push("/register/usertype");
break;
default:
router.push("/verify/avatar");
break;
}
break;
default:
break;
if (loggedIn) {
setIsSuccess(true);
}
// router.push("/dashboard");
} catch (err: any) {
setOtpError(true);
setIsSuccess(false);
@@ -105,68 +77,75 @@ function VerifyOtp() {
});
return (
<Container>
<div className="p-4 flex flex-col items-center h-screen justify-center">
<AuthHead title="ورود" />
<AuthOtpStatus
mobile={mobile}
resendLoading={resendLoading}
onExpiredChange={setOtpExpired}
onResend={async () => {
if (!mobile) return;
setResendLoading(true);
try {
await request("POST", "/login", { mobile });
formik.setFieldValue("otp", "");
setOtpError(false);
setIsSuccess(false);
} finally {
setResendLoading(false);
}
}}
/>
<form
onSubmit={formik.handleSubmit}
className="w-full max-w-sm flex flex-col items-center"
>
<AuthOtpInput
className="mt-3"
value={formik.values.otp}
onChange={(otp) => {
formik.setFieldValue("otp", otp);
setOtpError(false);
setIsSuccess(false);
<LocalePageShell>
<Container>
<div className="p-4 flex flex-col items-center h-dvh justify-center">
<AuthHead title={t("auth.login")} />
<AuthOtpStatus
mobile={mobile}
resendLoading={resendLoading}
onExpiredChange={setOtpExpired}
onResend={async () => {
if (!mobile) return;
setResendLoading(true);
try {
await request("POST", "/login", { mobile });
formik.setFieldValue("otp", "");
setOtpError(false);
setIsSuccess(false);
} finally {
setResendLoading(false);
}
}}
onComplete={() => setTimeout(() => formik.submitForm(), 400)}
error={otpError || Boolean(formik.touched.otp && formik.errors.otp)}
success={isSuccess}
disabled={loading || otpExpired || isSuccess}
/>
{formik.touched.otp && formik.errors.otp && (
<small className="text-red-500 mt-2">{formik.errors.otp}</small>
)}
<AuthButton
type="submit"
className="mt-5"
loading={loading} disabled={loading || otpExpired || isSuccess || formik.values.otp.length !== 6}
<form
onSubmit={formik.handleSubmit}
className="w-full max-w-sm flex flex-col items-center"
>
تایید کد
</AuthButton>
</form>
<Link href={"/login-with-username"}>
<small className="text-[#FF5C00] font-bold text-xs mt-8 block">
با نام کاربری و کلمه عبور خود وارد شوید
</small>
</Link>
<Link href={"/login"}>
<small className="text-[#667AC1] font-bold text-xs mt-8 block">
اصلاح شماره موبایل
</small>
</Link>
<AuthRules isModalOpen={isModalOpen} setModalOpen={setModalOpen} />
</div>
</Container>
<AuthOtpInput
className="mt-3"
value={formik.values.otp}
onChange={(otp) => {
formik.setFieldValue("otp", otp);
setOtpError(false);
setIsSuccess(false);
}}
onComplete={() => setTimeout(() => formik.submitForm(), 400)}
error={otpError || Boolean(formik.touched.otp && formik.errors.otp)}
success={isSuccess}
disabled={loading || otpExpired || isSuccess}
/>
{formik.touched.otp && formik.errors.otp && (
<small className="text-red-500 mt-2">{formik.errors.otp}</small>
)}
<AuthButton
type="submit"
className="mt-5"
loading={loading}
disabled={
loading ||
otpExpired ||
isSuccess ||
formik.values.otp.length !== 6
}
>
{t("auth.verifyCode")}
</AuthButton>
</form>
<Link href={"/login-with-username"}>
<small className="text-[#FF5C00] font-bold text-xs mt-8 block">
{t("auth.loginWithUsername")}
</small>
</Link>
<Link href={"/login"}>
<small className="text-[#667AC1] font-bold text-xs mt-8 block">
{t("auth.editMobile")}
</small>
</Link>
</div>
</Container>
</LocalePageShell>
);
}

View File

@@ -5,9 +5,8 @@
import AuthButton from "@/components/auth/AuthButton";
import AuthHead from "@/components/auth/AuthHead";
import Container from "@/components/elements/Container";
import AuthRules from "@/components/auth/AuthRules";
import Link from "next/link";
import React, { useState } from "react";
import React, { useMemo, useState } from "react";
import AuthOtpInput from "@/components/auth/AuthOtpInput";
import AuthOtpStatus from "@/components/auth/AuthOtpStatus";
import useAxios from "@/hooks/useAxios";
@@ -15,20 +14,13 @@ import { useFormik } from "formik";
import * as yup from "yup";
import { IVerifyOtp } from "@/types/types";
import { useRouter } from "next/navigation";
import { setAuthSession } from "@/lib/auth/session";
import { AUTH_SUCCESS_DELAY_MS, pause } from "@/lib/auth/feedback";
// Validation schema using Yup
const schema = yup.object({
otp: yup
.string()
.matches(/^\d{6}$/, "کد وارد شده صحیح نیست")
.required("کد تایید را وارد کنید"),
});
import { completeLogin } from "@/lib/auth/postLogin";
import { useTranslation } from "react-i18next";
import LocalePageShell from "@/components/i18n/LocalePageShell";
function RegisterOtp() {
const { t } = useTranslation("common");
const router = useRouter();
const [isModalOpen, setModalOpen] = useState(false);
const [otpExpired, setOtpExpired] = useState(false);
const [resendLoading, setResendLoading] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
@@ -36,6 +28,18 @@ function RegisterOtp() {
const { request, loading } = useAxios();
const mobile =
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
const schema = useMemo(
() =>
yup.object({
otp: yup
.string()
.matches(/^\d{6}$/, t("auth.otpInvalid"))
.required(t("auth.otpRequired")),
}),
[t]
);
const formik = useFormik({
initialValues: {
otp: "",
@@ -55,18 +59,8 @@ function RegisterOtp() {
otp: values.otp.trim(),
})) as IVerifyOtp;
await localStorage.setItem("otp", values.otp);
await setAuthSession(response.token, { step: response.step });
setIsSuccess(true);
await pause(AUTH_SUCCESS_DELAY_MS);
response.step === "user_name"
? router.push("/register/username")
: response.step === "password"
? router.push("/register/password")
: response.step === "first_name"
? router.push("/register/fullname")
: response.step === "user_type"
? router.push("/register/usertype")
: router.push("/verify/avatar");
await completeLogin(router, response);
} catch (err: any) {
setOtpError(true);
setIsSuccess(false);
@@ -76,62 +70,69 @@ function RegisterOtp() {
});
return (
<Container>
<div className="p-4 flex flex-col items-center h-screen justify-center">
<AuthHead title="ثبت نام" />
<AuthOtpStatus
mobile={mobile}
resendLoading={resendLoading}
onExpiredChange={setOtpExpired}
onResend={async () => {
if (!mobile) return;
setResendLoading(true);
try {
await request("POST", "/register", { mobile });
formik.setFieldValue("otp", "");
setOtpError(false);
setIsSuccess(false);
} finally {
setResendLoading(false);
}
}}
/>
<form
onSubmit={formik.handleSubmit}
className="w-full max-w-sm flex flex-col items-center"
>
<AuthOtpInput
className="mt-3"
value={formik.values.otp}
onChange={(otp) => {
formik.setFieldValue("otp", otp);
setOtpError(false);
setIsSuccess(false);
<LocalePageShell>
<Container>
<div className="p-4 flex flex-col items-center h-dvh justify-center">
<AuthHead title={t("auth.register")} />
<AuthOtpStatus
mobile={mobile}
resendLoading={resendLoading}
onExpiredChange={setOtpExpired}
onResend={async () => {
if (!mobile) return;
setResendLoading(true);
try {
await request("POST", "/register", { mobile });
formik.setFieldValue("otp", "");
setOtpError(false);
setIsSuccess(false);
} finally {
setResendLoading(false);
}
}}
onComplete={() => setTimeout(() => formik.submitForm(), 400)}
error={otpError || Boolean(formik.touched.otp && formik.errors.otp)}
success={isSuccess}
disabled={loading || otpExpired || isSuccess}
/>
{formik.touched.otp && formik.errors.otp && (
<small className="text-red-500 mt-2">{formik.errors.otp}</small>
)}
<AuthButton
type="submit"
className="mt-5"
loading={loading} disabled={loading || otpExpired || isSuccess || formik.values.otp.length !== 6}
<form
onSubmit={formik.handleSubmit}
className="w-full max-w-sm flex flex-col items-center"
>
تایید کد
</AuthButton>
</form>
<Link href={"/forget-password"}>
<small className="text-[#667AC1] font-bold text-xs mt-8 block">
اصلاح شماره موبایل
</small>
</Link>
<AuthRules isModalOpen={isModalOpen} setModalOpen={setModalOpen} />
</div>
</Container>
<AuthOtpInput
className="mt-3"
value={formik.values.otp}
onChange={(otp) => {
formik.setFieldValue("otp", otp);
setOtpError(false);
setIsSuccess(false);
}}
onComplete={() => setTimeout(() => formik.submitForm(), 400)}
error={otpError || Boolean(formik.touched.otp && formik.errors.otp)}
success={isSuccess}
disabled={loading || otpExpired || isSuccess}
/>
{formik.touched.otp && formik.errors.otp && (
<small className="text-red-500 mt-2">{formik.errors.otp}</small>
)}
<AuthButton
type="submit"
className="mt-5"
loading={loading}
disabled={
loading ||
otpExpired ||
isSuccess ||
formik.values.otp.length !== 6
}
>
{t("auth.verifyCode")}
</AuthButton>
</form>
<Link href={"/forget-password"}>
<small className="text-[#667AC1] font-bold text-xs mt-8 block">
{t("auth.editMobile")}
</small>
</Link>
</div>
</Container>
</LocalePageShell>
);
}

View File

@@ -0,0 +1,85 @@
/* 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 useAxios from "@/hooks/useAxios";
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";
function RegisterCompletePage() {
const { t } = useTranslation("common");
const router = useRouter();
const { request, loading } = useAxios();
const [action, setAction] = useState<"app" | "continue" | null>(null);
const finishRegistration = async (next: "app" | "continue") => {
setAction(next);
try {
await request("POST", "/register/user_type", {
user_type: "user",
});
localStorage.setItem("usertype", "user");
if (next === "continue") {
router.push("/verify/avatar");
} else {
router.push(getSafeRedirectPath());
}
} catch (err: any) {
toast.error(
err?.response?.data?.message || t("auth.registrationFinishFailed")
);
} finally {
setAction(null);
}
};
return (
<LocalePageShell>
<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">
{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>
<AuthNextButton
type="button"
onClick={() => finishRegistration("app")}
className="!border-[#FF0000] !text-[#FF0000] w-full sm:w-auto"
disabled={loading}
loading={loading && action === "app"}
>
{t("auth.enterApp")}
</AuthNextButton>
</div>
</AuthFormFooter>
</div>
</AuthPageLayout>
</LocalePageShell>
);
}
export default RegisterCompletePage;

View File

@@ -7,104 +7,241 @@ import AuthPageLayout, {
AuthFormFooter,
AuthPageContent,
} from "@/components/auth/AuthPageLayout";
import React from "react";
import React, { useEffect, useMemo, useState } from "react";
import useAxios from "@/hooks/useAxios";
import { useFormik } from "formik";
import * as yup from "yup";
import { useRouter } from "next/navigation";
import { IVerifyOtp } from "@/types/types";
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";
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
// Validation schema using Yup
const schema = yup.object().shape({
family: yup.string().required("نام خانوادگی الزامی است"),
name: yup.string().required("نام الزامی است"),
});
function splitInstagramFullName(fullName: string): { name: string; family: string } {
const trimmed = fullName.trim();
if (!trimmed) return { name: "", family: "" };
const [first, ...rest] = trimmed.split(/\s+/);
return { name: first, family: rest.join(" ") };
}
function FullNamePage() {
const { t } = useTranslation("common");
const router = useRouter();
const { request, loading } = useAxios();
const mobile =
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
const [nameSaved, setNameSaved] = useState(false);
const [choiceLoading, setChoiceLoading] = useState<"app" | "continue" | null>(
null
);
const [avatar, setAvatar] = useState<string | null>(null);
const isInstagramProvider =
typeof window !== "undefined" &&
localStorage.getItem("auth_provider") === "instagram";
const instagramFullName =
isInstagramProvider ? localStorage.getItem("instagram_full_name") || "" : "";
const instagramSuggested = useMemo(
() => splitInstagramFullName(instagramFullName),
[instagramFullName]
);
useEffect(() => {
if (!isInstagramProvider) return;
request<{ user: { profile_image?: string | null } }>("GET", "/profile")
.then((res) => setAvatar(res?.user?.profile_image || null))
.catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const schema = useMemo(
() =>
yup.object().shape({
name: yup
.string()
.trim()
.required(t("auth.nameRequired"))
.min(2, t("auth.nameTooShort")),
family: yup.string().trim().optional(),
}),
[t]
);
const finishRegistration = async (action: "app" | "continue") => {
setChoiceLoading(action);
try {
await request("POST", "/register/user_type", {
user_type: "user",
});
localStorage.setItem("usertype", "user");
if (action === "continue") {
router.push("/verify/avatar");
} else {
router.push(getSafeRedirectPath());
}
} catch (err: any) {
toast.error(
err?.response?.data?.message || t("auth.registrationFinishFailed")
);
} finally {
setChoiceLoading(null);
}
};
const formik = useFormik({
initialValues: {
family: "",
name: "",
family: instagramSuggested.family,
name: instagramSuggested.name,
},
validationSchema: schema,
onSubmit: async (values) => {
onSubmit: async (values, { setSubmitting }) => {
try {
(await request("POST", "/register/fullname", {
mobile: mobile,
first_name: values.name,
last_name: values.family,
})) as IVerifyOtp;
router.push("/register/usertype");
await request("POST", "/register/fullname", {
first_name: values.name.trim(),
last_name: values.family.trim() || undefined,
});
localStorage.setItem("first_name", values.name.trim());
if (values.family.trim()) {
localStorage.setItem("last_name", values.family.trim());
} else {
localStorage.removeItem("last_name");
}
setNameSaved(true);
toast.success(t("auth.nameSavedSuccess"));
// Instagram signups flow straight into the expertise/onboarding wizard —
// 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");
}
} catch (err: any) {
console.log("Unhandled error:", err?.message);
toast.error(err?.response?.data?.message || t("auth.saveNameFailed"));
} finally {
setSubmitting(false);
}
},
});
return (
<AuthPageLayout>
<form
onSubmit={formik.handleSubmit}
className="flex w-full flex-1 flex-col items-center"
>
<AuthPageContent>
<AuthHead title="نام و نام خانوادگی" />
<div className="w-full max-w-sm flex flex-col items-center">
<AuthInput
name="name"
type="text"
placeholder="نام"
dir="rtl"
className={`border mt-4 ${
formik.touched.name && formik.errors.name
? "border-red-500 dark:border-red-500"
: "border-gray-300"
}`}
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
/>
{formik.touched.name && formik.errors.name && (
<small className="text-red-500 mt-2 block text-center">
{formik.errors.name}
</small>
)}
<LocalePageShell>
<AuthPageLayout>
<form
onSubmit={formik.handleSubmit}
className="flex w-full flex-1 flex-col items-center"
>
<AuthPageContent>
<AuthHead title={t("auth.fullNameTitle")} />
<div className="w-full max-w-sm flex flex-col items-center">
{isInstagramProvider && avatar && (
<Image
src={avatar.startsWith("data:") ? avatar : IMAGE_BASE_URL + avatar}
alt=""
width={72}
height={72}
unoptimized
className="mb-2 h-[72px] w-[72px] rounded-full object-cover"
/>
)}
<AuthInput
name="name"
type="text"
placeholder={t("auth.firstNamePlaceholder")}
dir="rtl"
disabled={nameSaved || loading}
className={`border mt-4 ${
formik.touched.name && formik.errors.name
? "border-red-500 dark:border-red-500"
: "border-gray-300"
}`}
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
/>
{formik.touched.name && formik.errors.name ? (
<small className="text-red-500 mt-2 block text-center">
{formik.errors.name}
</small>
) : (
isInstagramProvider &&
instagramSuggested.name &&
formik.values.name === instagramSuggested.name && (
<small className="text-gray-400 mt-2 block text-center text-xs">
{t("auth.nameSuggestedFromInstagram")}
</small>
)
)}
<AuthInput
name="family"
type="text"
placeholder="نام خانوادگی"
dir="rtl"
className={`border mt-4 ${
formik.touched.family && formik.errors.family
? "border-red-500 dark:border-red-500"
: "border-gray-300"
}`}
value={formik.values.family}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
/>
{formik.touched.family && formik.errors.family && (
<small className="text-red-500 mt-2 block text-center">
{formik.errors.family}
</small>
)}
</div>
</AuthPageContent>
<AuthInput
name="family"
type="text"
placeholder={t("auth.lastNamePlaceholder")}
dir="rtl"
disabled={nameSaved || loading}
className={`border mt-4 ${
formik.touched.family && formik.errors.family
? "border-red-500 dark:border-red-500"
: "border-gray-300"
}`}
value={formik.values.family}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
/>
{formik.touched.family && formik.errors.family && (
<small className="text-red-500 mt-2 block text-center">
{formik.errors.family}
</small>
)}
<AuthFormFooter>
<AuthNextButton type="submit" loading={loading} disabled={loading}>
ثبت و ادامه
</AuthNextButton>
</AuthFormFooter>
</form>
</AuthPageLayout>
{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}
</AuthFormFooter>
</form>
</AuthPageLayout>
</LocalePageShell>
);
}

View File

@@ -0,0 +1,15 @@
"use client";
import IOSSpinner from "@/components/ui/IOSSpinner";
import { useTranslation } from "react-i18next";
export default function RegisterLoading() {
const { t } = useTranslation("common");
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">{t("auth.pageLoading")}</span>
</div>
);
}

View File

@@ -4,84 +4,144 @@ import AuthButton from "@/components/auth/AuthButton";
import AuthHead from "@/components/auth/AuthHead";
import AuthInput from "@/components/auth/AuthInput";
import Container from "@/components/elements/Container";
import AuthRules from "@/components/auth/AuthRules";
import React, { useState } from "react";
import AuthLanguageToggle from "@/components/auth/AuthLanguageToggle";
import React, { useMemo } from "react";
import useAxios from "@/hooks/useAxios";
import { useFormik } from "formik";
import * as yup from "yup";
import { useRouter } from "next/navigation";
import { markOtpSent } from "@/lib/auth/otpTimer";
import { GoogleSignInButton } from "@/app/(auth)/AuthProviders";
import { MOBILE_NUMERIC_INPUT_PROPS } from "@/lib/auth/inputProps";
import { getAxiosErrorMessage } from "@/lib/auth/postLogin";
import { useTranslation } from "react-i18next";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import toast from "react-hot-toast";
import Link from "next/link";
// Validation schema using Yup
const schema = yup.object({
mobile: yup
.string()
.matches(/^(09\d{9})$/, "شماره موبایل معتبر نیست")
.required("شماره موبایل الزامی است"),
});
function normalizeMobileInput(value: string): string {
return value.replace(/\D/g, "").slice(0, 11);
}
function Register() {
const { t, i18n } = useTranslation("common");
const router = useRouter();
const [isModalOpen, setModalOpen] = useState(false);
const { request, loading } = useAxios();
const schema = useMemo(
() =>
yup.object({
mobile: yup
.string()
.matches(/^(09\d{9})$/, t("auth.mobileInvalid"))
.required(t("auth.mobileRequired")),
}),
[t]
);
const formik = useFormik({
initialValues: {
mobile: "",
},
validationSchema: schema,
onSubmit: async (values) => {
const mobile = normalizeMobileInput(values.mobile);
try {
await request("POST", "/register", { mobile: values.mobile });
await localStorage.setItem("mobile", values.mobile);
await request(
"POST",
"/register",
{ mobile },
{ noToast: true }
);
localStorage.setItem("mobile", mobile);
markOtpSent();
router.push("/register-otp");
} catch (err) {
console.log(err);
} catch (err: unknown) {
const data = (
err as {
response?: {
data?: { message?: string; code?: string; errors?: Array<{ msg?: string }> };
};
}
)?.response?.data;
const message =
data?.message ||
data?.errors?.[0]?.msg ||
getAxiosErrorMessage(err, i18n.language === "en" ? "en" : "fa");
toast.error(message);
if (data?.code === "ALREADY_REGISTERED") {
setTimeout(() => {
router.push("/login");
}, 1200);
}
}
},
});
return (
<Container>
<div className="p-4 flex flex-col items-center h-screen justify-center">
<AuthHead title="ثبت نام" />
<form
onSubmit={formik.handleSubmit}
className="w-full max-w-sm flex flex-col items-center"
>
<AuthInput
name="mobile"
maxLength={11}
type="text"
dir="ltr"
placeholder="09 _ _ _ _ _ _ _ _ _"
className={`border ${
formik.touched.mobile && formik.errors.mobile
? "border-red-500 dark:border-red-500"
: "border-gray-300"
}`}
value={formik.values.mobile}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
/>
{formik.touched.mobile && formik.errors.mobile && (
<small className="text-red-500 mt-2 block">
{formik.errors.mobile}
<LocalePageShell>
<Container>
<div className="relative p-4 flex flex-col items-center h-dvh justify-center">
<AuthLanguageToggle className="absolute top-6 left-1/2 -translate-x-1/2" />
<AuthHead title={t("auth.register")} />
<form
onSubmit={formik.handleSubmit}
className="w-full max-w-sm flex flex-col items-center"
>
<AuthInput
name="mobile"
maxLength={11}
{...MOBILE_NUMERIC_INPUT_PROPS}
dir="ltr"
placeholder="09 _ _ _ _ _ _ _ _ _"
className={`border ${
formik.touched.mobile && formik.errors.mobile
? "border-red-500 dark:border-red-500"
: "border-gray-300"
}`}
value={formik.values.mobile}
onChange={(e) =>
formik.setFieldValue(
"mobile",
normalizeMobileInput(e.target.value)
)
}
onBlur={formik.handleBlur}
/>
{formik.touched.mobile && formik.errors.mobile && (
<small className="text-red-500 mt-2 block">
{formik.errors.mobile}
</small>
)}
<AuthButton
type="submit"
className="mt-5"
loading={loading}
disabled={loading || formik.values.mobile.length !== 11}
>
{t("auth.sendCode")}
</AuthButton>
</form>
<GoogleSignInButton mode="register" />
{/* <InstagramSignInButton mode="register" /> */}
<Link href="/login">
<small className="text-[#292D32] dark:text-neutral-300 font-bold text-xs mt-6 block text-center">
{t("auth.haveAccount")}{" "}
<span className="text-[#0033EA]">{t("auth.login")}</span>
</small>
)}
<AuthButton type="submit" className="mt-5" loading={loading} disabled={loading}>
ارسال کد
</AuthButton>
</form>
<button onClick={() => setModalOpen(true)} className="mb-2">
<small className="text-[#FF5C00] font-bold text-xs mt-8 block">
قوانین و مقررات
</small>
</button>
<AuthRules isModalOpen={isModalOpen} setModalOpen={setModalOpen} />
</div>
</Container>
</Link>
<Link href="/privacy" className="mb-2">
<small className="text-[#FF5C00] font-bold text-xs mt-8 block">
{t("auth.rules")}
</small>
</Link>
</div>
</Container>
</LocalePageShell>
);
}

View File

@@ -7,41 +7,40 @@ import AuthPageLayout, {
AuthFormFooter,
AuthPageContent,
} from "@/components/auth/AuthPageLayout";
import React from "react";
import React, { useMemo } from "react";
import useAxios from "@/hooks/useAxios";
import { useFormik } from "formik";
import * as yup from "yup";
import { useRouter } from "next/navigation";
import { IVerifyOtp } from "@/types/types";
import AuthNextButton from "@/components/auth/AuthNextButton";
// Validation schema using Yup
const schema = yup.object().shape({
password: yup
.string()
.required("کلمه عبور نمی\u200Cتواند خالی باشد")
.min(8, "کلمه عبور نباید کوتاه تر از 8 کاراکتر باشد")
.matches(
/^(?=.*[a-zA-Zآ-ی])(?=.*\d).{8,}$/,
"کلمه عبور باید شامل حروف و اعداد باشد"
),
confirmPassword: yup
.string()
.required("تکرار کلمه عبور نمی\u200Cتواند خالی باشد")
.oneOf(
[yup.ref("password")],
"تکرار کلمه عبور باید با کلمه عبور مطابقت داشته باشد"
)
.required("تکرار کلمه عبور الزامی است"),
});
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import LocalePageShell from "@/components/i18n/LocalePageShell";
function PasswordPage() {
const { t } = useTranslation("common");
const router = useRouter();
const { request, loading } = useAxios();
const mobile =
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
const otp =
typeof window !== "undefined" ? localStorage.getItem("otp") : null;
const schema = useMemo(
() =>
yup.object().shape({
password: yup
.string()
.required(t("auth.passwordRequired"))
.min(8, t("auth.passwordMin"))
.matches(
/^(?=.*[a-zA-Zآ-ی])(?=.*\d).{8,}$/,
t("auth.passwordLettersNumbers")
),
confirmPassword: yup
.string()
.required(t("auth.confirmPasswordRequired"))
.oneOf([yup.ref("password")], t("auth.confirmPasswordMismatch")),
}),
[t]
);
const formik = useFormik({
initialValues: {
@@ -51,74 +50,77 @@ function PasswordPage() {
validationSchema: schema,
onSubmit: async (values) => {
try {
(await request("POST", "/login/change_password", {
mobile,
otp,
(await request("POST", "/register/password", {
new_password: values.password,
})) as IVerifyOtp;
router.push("/register/fullname");
} catch (err: any) {
console.log("Unhandled error:", err?.message);
const message =
err?.response?.data?.message || t("auth.savePasswordFailed");
toast.error(message);
}
},
});
return (
<AuthPageLayout>
<form
onSubmit={formik.handleSubmit}
className="flex w-full flex-1 flex-col items-center"
>
<AuthPageContent>
<AuthHead title="کلمه عبور" />
<div className="w-full max-w-sm flex flex-col items-center">
<AuthPasswordInput
name="password"
placeholder="کلمه عبور"
wrapperClassName="mt-4"
className={`border ${
formik.touched.password && formik.errors.password
? "border-red-500 dark:border-red-500"
: "border-gray-300"
}`}
value={formik.values.password}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
/>
{formik.touched.password && formik.errors.password && (
<small className="text-red-500 mt-2 block">
{formik.errors.password}
</small>
)}
<LocalePageShell>
<AuthPageLayout>
<form
onSubmit={formik.handleSubmit}
className="flex w-full flex-1 flex-col items-center"
>
<AuthPageContent>
<AuthHead title={t("auth.passwordTitle")} />
<div className="w-full max-w-sm flex flex-col items-center">
<AuthPasswordInput
name="password"
placeholder={t("auth.passwordPlaceholder")}
wrapperClassName="mt-4"
className={`border ${
formik.touched.password && formik.errors.password
? "border-red-500 dark:border-red-500"
: "border-gray-300"
}`}
value={formik.values.password}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
/>
{formik.touched.password && formik.errors.password && (
<small className="text-red-500 mt-2 block">
{formik.errors.password}
</small>
)}
<AuthPasswordInput
name="confirmPassword"
placeholder="تکرار کلمه عبور"
wrapperClassName="mt-4"
className={`border ${
formik.touched.confirmPassword && formik.errors.confirmPassword
? "border-red-500 dark:border-red-500"
: "border-gray-300"
}`}
value={formik.values.confirmPassword}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
/>
{formik.touched.confirmPassword && formik.errors.confirmPassword && (
<small className="text-red-500 mt-2 block">
{formik.errors.confirmPassword}
</small>
)}
</div>
</AuthPageContent>
<AuthPasswordInput
name="confirmPassword"
placeholder={t("auth.confirmPasswordPlaceholder")}
wrapperClassName="mt-4"
className={`border ${
formik.touched.confirmPassword && formik.errors.confirmPassword
? "border-red-500 dark:border-red-500"
: "border-gray-300"
}`}
value={formik.values.confirmPassword}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
/>
{formik.touched.confirmPassword &&
formik.errors.confirmPassword && (
<small className="text-red-500 mt-2 block">
{formik.errors.confirmPassword}
</small>
)}
</div>
</AuthPageContent>
<AuthFormFooter>
<AuthNextButton type="submit" loading={loading} disabled={loading}>
تایید کلمه عبور
</AuthNextButton>
</AuthFormFooter>
</form>
</AuthPageLayout>
<AuthFormFooter>
<AuthNextButton type="submit" loading={loading} disabled={loading}>
{t("auth.saveAndContinue")}
</AuthNextButton>
</AuthFormFooter>
</form>
</AuthPageLayout>
</LocalePageShell>
);
}

View File

@@ -7,41 +7,82 @@ import AuthPageLayout, {
AuthFormFooter,
AuthPageContent,
} from "@/components/auth/AuthPageLayout";
import React, { useState } from "react";
import React, { useMemo, useState } from "react";
import useAxios from "@/hooks/useAxios";
import { useFormik } from "formik";
import * as yup from "yup";
import { useRouter } from "next/navigation";
import { IVerifyOtp } from "@/types/types";
import AuthNextButton from "@/components/auth/AuthNextButton";
import UsernameSuggestions from "@/components/auth/UsernameSuggestions";
import { usernameFormSchema } from "@/lib/validation/usernameSchema";
import { sanitizeUsernameInput, USERNAME_MIN_LENGTH } from "@/lib/validation/username";
import {
sanitizeUsernameInput,
USERNAME_MIN_LENGTH,
USERNAME_MAX_LENGTH,
USERNAME_REGEX,
} from "@/lib/validation/username";
import { useTranslation } from "react-i18next";
import LocalePageShell from "@/components/i18n/LocalePageShell";
function UsernamePage() {
const { t } = useTranslation("common");
const router = useRouter();
const { request, loading } = useAxios();
const [isDuplicate, setIsDuplicate] = useState(false);
const [suggestions, setSuggestions] = useState<string[]>([]);
const mobile =
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
const instagramSuggestedUsername =
typeof window !== "undefined" &&
localStorage.getItem("auth_provider") === "instagram"
? sanitizeUsernameInput(localStorage.getItem("instagram_username") || "")
: "";
const schema = useMemo(
() =>
yup.object({
username: yup
.string()
.required(t("auth.usernameRequired"))
.min(
USERNAME_MIN_LENGTH,
t("auth.usernameMinLength", { min: USERNAME_MIN_LENGTH })
)
.max(
USERNAME_MAX_LENGTH,
t("auth.usernameMaxLength", { max: USERNAME_MAX_LENGTH })
)
.matches(/^[a-zA-Z0-9]/, t("auth.usernameStartRule"))
.matches(USERNAME_REGEX, t("auth.usernameCharsRule")),
}),
[t]
);
const formik = useFormik({
initialValues: {
username: "",
username: instagramSuggestedUsername,
},
validationSchema: usernameFormSchema,
validationSchema: schema,
onSubmit: async (values) => {
setIsDuplicate(false);
setSuggestions([]);
try {
await request("POST", "/register/username", {
mobile,
...(mobile ? { mobile } : {}),
user_name: values.username.trim().toLowerCase(),
}) as IVerifyOtp;
localStorage.setItem("username", values.username.trim().toLowerCase());
router.push("/register/password");
const authProvider =
typeof window !== "undefined"
? localStorage.getItem("auth_provider")
: null;
router.push(
authProvider === "google" || authProvider === "instagram"
? "/register/fullname"
: "/register/password"
);
} catch (err: any) {
const data = err?.response?.data;
if (data?.message?.includes("تکراری")) {
@@ -68,61 +109,89 @@ function UsernamePage() {
};
return (
<AuthPageLayout>
<form
onSubmit={formik.handleSubmit}
className="flex w-full flex-1 flex-col items-center"
>
<AuthPageContent>
<AuthHead title="نام کاربری" />
<div className="w-full max-w-sm flex flex-col items-center">
<AuthInput
name="username"
type="text"
placeholder="نام کاربری"
dir="ltr"
autoComplete="username"
spellCheck={false}
className={`border ${
(formik.touched.username && formik.errors.username) || isDuplicate
? "border-red-500 dark:border-red-500"
: "border-gray-300"
}`}
value={formik.values.username}
onChange={(e) => handleUsernameChange(e.target.value)}
onBlur={formik.handleBlur}
/>
<LocalePageShell>
<AuthPageLayout>
<form
onSubmit={formik.handleSubmit}
className="flex w-full flex-1 flex-col items-center"
>
<AuthPageContent>
<AuthHead title={t("auth.usernameTitle")} />
<div className="w-full max-w-sm flex flex-col items-center">
<p className="text-xs text-gray-500 text-center mb-3 max-w-[290px]">
{t("auth.usernameHint", {
max: USERNAME_MAX_LENGTH,
min: USERNAME_MIN_LENGTH,
})}
</p>
<AuthInput
name="username"
type="text"
placeholder={t("auth.usernamePlaceholder")}
dir="ltr"
autoComplete="username"
spellCheck={false}
maxLength={USERNAME_MAX_LENGTH}
className={`border ${
(formik.touched.username && formik.errors.username) ||
isDuplicate
? "border-red-500 dark:border-red-500"
: "border-gray-300"
}`}
value={formik.values.username}
onChange={(e) => handleUsernameChange(e.target.value)}
onBlur={formik.handleBlur}
/>
{isDuplicate && (
<small className="text-red-500 mt-2 block text-center font-medium">
نام کاربری تکراری است
</small>
)}
{!isDuplicate &&
instagramSuggestedUsername &&
formik.values.username === instagramSuggestedUsername && (
<small className="text-gray-400 mt-2 block text-center text-xs">
{t("auth.usernameSuggestedFromInstagram")}
</small>
)}
{formik.touched.username && formik.errors.username && !isDuplicate && (
<small className="text-red-500 mt-2 block text-center">
{formik.errors.username}
</small>
)}
{isDuplicate && (
<small className="text-red-500 mt-2 block text-center font-medium">
{t("auth.usernameDuplicate")}
</small>
)}
<UsernameSuggestions
suggestions={suggestions}
onSelect={handleSuggestionSelect}
/>
</div>
</AuthPageContent>
{formik.touched.username &&
formik.errors.username &&
!isDuplicate && (
<small className="text-red-500 mt-2 block text-center">
{formik.errors.username}
</small>
)}
<AuthFormFooter>
<AuthNextButton
type="submit"
loading={loading}
disabled={loading || formik.values.username.length < USERNAME_MIN_LENGTH}
>
ثبت و ادامه
</AuthNextButton>
</AuthFormFooter>
</form>
</AuthPageLayout>
{!formik.errors.username && !isDuplicate && (
<small className="text-gray-400 mt-2 block text-center text-xs">
{t("auth.usernameCharsRule")}
</small>
)}
<UsernameSuggestions
suggestions={suggestions}
onSelect={handleSuggestionSelect}
/>
</div>
</AuthPageContent>
<AuthFormFooter>
<AuthNextButton
type="submit"
loading={loading}
disabled={
loading || formik.values.username.length < USERNAME_MIN_LENGTH
}
>
{t("auth.saveAndContinue")}
</AuthNextButton>
</AuthFormFooter>
</form>
</AuthPageLayout>
</LocalePageShell>
);
}

View File

@@ -1,129 +1,15 @@
/* 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 useAxios from "@/hooks/useAxios";
import { useFormik } from "formik";
import * as yup from "yup";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import AuthNextButton from "@/components/auth/AuthNextButton";
import toast from "react-hot-toast";
// Validation schema using Yup
const schema = yup.object().shape({
selectedButton: yup.string(),
});
function UsertypePage() {
/** مسیر قدیمی — به صفحه انتخاب جدید هدایت می‌شود */
export default function UsertypeRedirectPage() {
const router = useRouter();
const { request, loading } = useAxios();
const mobile =
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
// State for managing submit action
const [submitAction, setSubmitAction] = useState<"register" | "login" | null>(
null
);
useEffect(() => {
router.replace("/register/complete");
}, [router]);
const formik = useFormik({
initialValues: {
selectedButton: "",
},
validationSchema: schema,
onSubmit: async (values, { setSubmitting }) => {
try {
await request("POST", "/register/user_type", {
mobile: mobile,
// user_type: values.selectedButton,
user_type: "user",
});
if (submitAction === "register") {
// localStorage.setItem("usertype", values.selectedButton);
localStorage.setItem("usertype", "user");
router.push("/verify/avatar");
} else if (submitAction === "login") {
// localStorage.setItem("usertype", values.selectedButton);
localStorage.setItem("usertype", "user");
router.push("/");
}
} catch (err: any) {
toast.error(err?.message || "خطای نامشخصی رخ داد.");
} finally {
setSubmitting(false);
}
},
});
return (
<AuthPageLayout>
<form
onSubmit={formik.handleSubmit}
className="flex w-full flex-1 flex-col items-center"
>
<AuthPageContent>
<AuthHead title="" />
<div className="w-full max-w-sm flex flex-col items-center">
{/* User Type Buttons */}
{/* <AuthNextButton
type="button"
onClick={() => formik.setFieldValue("selectedButton", "user")}
className={`mt-5 w-full py-3 max-w-[250px] ${
formik.values.selectedButton === "user"
? "!bg-[#FC8EAC] !border-[#FC8EAC] !text-white"
: ""
}`}
>
کاربر
</AuthNextButton>
<AuthNextButton
type="button"
onClick={() => formik.setFieldValue("selectedButton", "employer")}
className={`mt-5 w-full py-3 max-w-[250px] ${
formik.values.selectedButton === "employer"
? "!bg-[#FC8EAC] !border-[#FC8EAC] !text-white"
: ""
}`}
>
کارفرما
</AuthNextButton> */}
{formik.errors.selectedButton && formik.touched.selectedButton && (
<div className="text-red-500 mt-2 text-sm">
{formik.errors.selectedButton}
</div>
)}
</div>
</AuthPageContent>
<AuthFormFooter>
<div className="flex gap-3">
<AuthNextButton
type="submit"
onClick={() => setSubmitAction("register")}
className="!border-[#0C8002] !text-[#0C8002]"
disabled={loading}
>
ادامه ثبت نام
</AuthNextButton>
<AuthNextButton
type="submit"
onClick={() => setSubmitAction("login")}
className="!border-[#FF0000] !text-[#FF0000]"
disabled={loading}
>
ورود به برنامه
</AuthNextButton>
</div>
</AuthFormFooter>
</form>
</AuthPageLayout>
);
return null;
}
export default UsertypePage;

View File

@@ -0,0 +1,104 @@
"use client";
import GoogleSignInButtonBase, {
GoogleAuthRootProvider,
} from "@/components/auth/GoogleSignInButton";
import InstagramSignInButtonBase from "@/components/auth/InstagramSignInButton";
import RegisterRouteGuard from "@/components/auth/RegisterRouteGuard";
import { GOOGLE_OAUTH_CLIENT_ID } from "@/config/googleAuth";
import { INSTAGRAM_OAUTH_CLIENT_ID } from "@/config/instagramAuth";
import { createContext, useContext, useEffect, useState } from "react";
const GoogleClientIdContext = createContext("");
const InstagramClientIdContext = createContext("");
export function useGoogleClientId(): string {
return useContext(GoogleClientIdContext);
}
export function useInstagramClientId(): string {
return useContext(InstagramClientIdContext);
}
export function GoogleSignInButton(
props: Omit<React.ComponentProps<typeof GoogleSignInButtonBase>, "clientId">
) {
const clientId = useGoogleClientId();
return (
<GoogleSignInButtonBase
{...props}
clientId={clientId || GOOGLE_OAUTH_CLIENT_ID}
/>
);
}
export function InstagramSignInButton(
props: Omit<React.ComponentProps<typeof InstagramSignInButtonBase>, "clientId">
) {
const clientId = useInstagramClientId();
return (
<InstagramSignInButtonBase
{...props}
clientId={clientId || INSTAGRAM_OAUTH_CLIENT_ID}
/>
);
}
export default function AuthProviders({
children,
googleClientId = "",
instagramClientId = "",
}: {
children: React.ReactNode;
googleClientId?: string;
instagramClientId?: string;
}) {
const [resolvedClientId, setResolvedClientId] = useState(
googleClientId || GOOGLE_OAUTH_CLIENT_ID
);
const [resolvedInstagramClientId, setResolvedInstagramClientId] = useState(
instagramClientId || INSTAGRAM_OAUTH_CLIENT_ID
);
useEffect(() => {
let cancelled = false;
async function loadClientId() {
const fallback = googleClientId || GOOGLE_OAUTH_CLIENT_ID;
const instagramFallback = instagramClientId || INSTAGRAM_OAUTH_CLIENT_ID;
setResolvedClientId(fallback);
setResolvedInstagramClientId(instagramFallback);
try {
const res = await fetch("/api/auth/config", { cache: "no-store" });
if (!res.ok) return;
const data = (await res.json()) as {
googleClientId?: string;
instagramClientId?: string;
};
if (!cancelled) {
setResolvedClientId(data?.googleClientId || fallback);
setResolvedInstagramClientId(data?.instagramClientId || instagramFallback);
}
} catch {
// keep fallback
}
}
void loadClientId();
return () => {
cancelled = true;
};
}, [googleClientId, instagramClientId]);
return (
<GoogleClientIdContext.Provider value={resolvedClientId}>
<InstagramClientIdContext.Provider value={resolvedInstagramClientId}>
<GoogleAuthRootProvider clientId={resolvedClientId}>
<RegisterRouteGuard>{children}</RegisterRouteGuard>
</GoogleAuthRootProvider>
</InstagramClientIdContext.Provider>
</GoogleClientIdContext.Provider>
);
}

22
src/app/(auth)/layout.tsx Normal file
View File

@@ -0,0 +1,22 @@
import type { Metadata } from "next";
import AuthProviders from "./AuthProviders";
import { getGoogleClientId } from "@/lib/auth/googleClientId";
import { generateSeoPageMetadata } from "@/lib/i18n/server";
export const dynamic = "force-dynamic";
export async function generateMetadata(): Promise<Metadata> {
return generateSeoPageMetadata("auth", { index: false });
}
export default function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
const googleClientId = getGoogleClientId();
return (
<AuthProviders googleClientId={googleClientId}>{children}</AuthProviders>
);
}

View File

@@ -5,7 +5,7 @@ import AuthPageLayout, {
AuthFormFooter,
AuthPageContent,
} from "@/components/auth/AuthPageLayout";
import React from "react";
import React, { useMemo } from "react";
import useAxios from "@/hooks/useAxios";
import { useFormik } from "formik";
import * as yup from "yup";
@@ -19,30 +19,38 @@ import persian from "react-date-object/calendars/persian";
import persian_fa from "react-date-object/locales/persian_fa";
import Image from "next/image";
import moment from "jalali-moment";
// Validation schema using Yup
const schema = yup.object().shape({
birthDate: yup.string().required(" انتخاب تاریخ تولد الزامی است"),
shaba: yup
.string()
.matches(/^(?=.{24}$)[0-9]*$/, "شماره شبا باید ۲۴ رقم باشد")
.required("شماره شبا الزامی است"),
nationalCode: yup
.string()
.matches(/^\d{10}$/, "کد ملی باید ۱۰ رقم باشد")
.required("کد ملی الزامی است"),
});
import { useTranslation } from "react-i18next";
import LocalePageShell from "@/components/i18n/LocalePageShell";
function AuthPage() {
const { t } = useTranslation("common");
const router = useRouter();
const { request, loading } = useAxios();
const p2e = (es: string): string =>
es.replace(/[۰-۹]/g, (d) => String.fromCharCode("۰۱۲۳۴۵۶۷۸۹".indexOf(d) + 48));
es.replace(/[۰-۹]/g, (d) =>
String.fromCharCode("۰۱۲۳۴۵۶۷۸۹".indexOf(d) + 48)
);
const mobile =
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
const schema = useMemo(
() =>
yup.object().shape({
birthDate: yup.string().required(t("auth.birthDateRequired")),
shaba: yup
.string()
.matches(/^(?=.{24}$)[0-9]*$/, t("auth.shabaInvalid"))
.required(t("auth.shabaRequired")),
nationalCode: yup
.string()
.matches(/^\d{10}$/, t("auth.nationalCodeInvalid"))
.required(t("auth.nationalCodeRequired")),
}),
[t]
);
const formik = useFormik({
initialValues: {
nationalCode: "",
@@ -56,129 +64,128 @@ function AuthPage() {
mobile: mobile,
birthday: moment(p2e(values.birthDate), "jYYYY/jMM/jDD").format(
"YYYY/MM/DD"
), // تبدیل به میلادی
),
national_code: values.nationalCode,
shaba: values.shaba,
});
router.push("/verify/location");
} catch (err: any) {
toast.error(err?.message || "خطای نامشخصی رخ داد.");
toast.error(err?.response?.data?.message || t("auth.unknownError"));
} finally {
setSubmitting(false);
}
},
});
console.log(formik.values);
return (
<AuthPageLayout className="auth-page">
<form
onSubmit={formik.handleSubmit}
className="flex w-full flex-1 flex-col items-center"
>
<AuthPageContent>
<span className="text-xl font-bold">احراز هویت</span>
<div className="mt-5">
<AuthUserDetails />
</div>
<div className="w-full max-w-sm flex flex-col items-center">
<small className="font-bold mt-10 mb-4"> جهت احراز هویت</small>
<div className="flex w-full gap-2 max-w-[300px]">
<div>
{/* کد ملی */}
<AuthInput
name="nationalCode"
type="text"
placeholder="کد ملی"
className={`border mt-2 !p-1 h-[36px] ${
formik.touched.nationalCode && formik.errors.nationalCode
? "border-red-500 dark:border-red-500"
: "border-gray-300"
}`}
value={formik.values.nationalCode}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
/>
<LocalePageShell>
<AuthPageLayout className="auth-page">
<form
onSubmit={formik.handleSubmit}
className="flex w-full flex-1 flex-col items-center"
>
<AuthPageContent>
<span className="text-xl font-bold">
{t("auth.identityVerification")}
</span>
<div className="mt-5">
<AuthUserDetails />
</div>
{/* تاریخ تولد */}
<div className="relative">
<DatePicker
id="ss"
calendar={persian}
locale={persian_fa}
value={formik.values.birthDate}
onChange={(value) => {
const formattedDate = value?.format("YYYY/MM/DD") || ""; // اگر مقدار `null` بود، رشته خالی
formik.setFieldValue("birthDate", formattedDate); // مقدار فرم را تنظیم کنید
}}
calendarPosition="bottom-center"
placeholder="تاریخ تولد"
inputClass={`w-full dir-ltr max-w-[290px] p-3 !pr-6 text-center rounded-2xl border border-border-primary-light dark:border-border-primary-dark bg-secondary-light dark:bg-secondary-dark font-medium border mt-2 !p-1 h-[36px] ${
formik.touched.birthDate && formik.errors.birthDate
? "border-red-500 dark:border-red-500"
: "border-gray-300"
}`}
/>
<label htmlFor="ss" className="absolute right-4 top-3.5">
<Image
width={24}
height={24}
alt="calendar icon"
src={"/images/icons/calendar.svg"}
/>
</label>
</div>
</div>
{formik.touched.nationalCode && formik.errors.nationalCode && (
<small className="text-red-500 mt-2 block text-center">
{formik.errors.nationalCode}
</small>
)}
{formik.touched.birthDate && formik.errors.birthDate && (
<small className="text-red-500 mt-2 block text-center">
{formik.errors.birthDate}
</small>
)}
<small className="font-bold mt-10"> جهت واریز حق الزحمه</small>
{/* شماره شبا */}
<div className="flex flex-col w-full relative mt-4 ">
<span className="absolute left-4 top-1.5">IR</span>
<AuthInput
name="shaba"
type="text"
placeholder="شماره شبا"
className={`border w-full mx-auto h-[36px] p-2 max-w-full pl-8 text-sm ${
formik.touched.shaba && formik.errors.shaba
? "border-red-500 dark:border-red-500"
: "border-gray-300"
}`}
value={formik.values.shaba}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
/>
{formik.touched.shaba && formik.errors.shaba && (
<small className="text-red-500 mt-2 block text-center">
{formik.errors.shaba}
<div className="w-full max-w-sm flex flex-col items-center">
<small className="font-bold mt-10 mb-4">
{t("auth.forIdentityVerification")}
</small>
)}
</div>
<div className="flex w-full gap-2 max-w-[300px]">
<div>
<AuthInput
name="nationalCode"
type="text"
placeholder={t("auth.nationalCodePlaceholder")}
className={`border mt-2 !p-1 h-[36px] ${
formik.touched.nationalCode && formik.errors.nationalCode
? "border-red-500 dark:border-red-500"
: "border-gray-300"
}`}
value={formik.values.nationalCode}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
/>
</div>
<div className="relative">
<DatePicker
id="ss"
calendar={persian}
locale={persian_fa}
value={formik.values.birthDate}
onChange={(value) => {
const formattedDate = value?.format("YYYY/MM/DD") || "";
formik.setFieldValue("birthDate", formattedDate);
}}
calendarPosition="bottom-center"
placeholder={t("auth.birthDatePlaceholder")}
inputClass={`w-full dir-ltr max-w-[290px] p-3 !pr-6 text-center rounded-2xl border border-border-primary-light dark:border-border-primary-dark bg-secondary-light dark:bg-secondary-dark font-medium border mt-2 !p-1 h-[36px] ${
formik.touched.birthDate && formik.errors.birthDate
? "border-red-500 dark:border-red-500"
: "border-gray-300"
}`}
/>
<label htmlFor="ss" className="absolute right-4 top-3.5">
<Image
width={24}
height={24}
alt="calendar icon"
src={"/images/icons/calendar.svg"}
/>
</label>
</div>
</div>
<small className="font-bold mt-10">
شماره شبا باید به نام خود شخص باشد
</small>
</div>
</AuthPageContent>
{formik.touched.nationalCode && formik.errors.nationalCode && (
<small className="text-red-500 mt-2 block text-center">
{formik.errors.nationalCode}
</small>
)}
{formik.touched.birthDate && formik.errors.birthDate && (
<small className="text-red-500 mt-2 block text-center">
{formik.errors.birthDate}
</small>
)}
<small className="font-bold mt-10">{t("auth.forPayment")}</small>
<div className="flex flex-col w-full relative mt-4 ">
<span className="absolute left-4 top-1.5">IR</span>
<AuthInput
name="shaba"
type="text"
placeholder={t("auth.shabaPlaceholder")}
className={`border w-full mx-auto h-[36px] p-2 max-w-full pl-8 text-sm ${
formik.touched.shaba && formik.errors.shaba
? "border-red-500 dark:border-red-500"
: "border-gray-300"
}`}
value={formik.values.shaba}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
/>
{formik.touched.shaba && formik.errors.shaba && (
<small className="text-red-500 mt-2 block text-center">
{formik.errors.shaba}
</small>
)}
</div>
<AuthFormFooter onSkip={() => router.push("/verify/location")}>
<AuthNextButton type="submit" loading={loading} disabled={loading}>
ثبت و ادامه
</AuthNextButton>
</AuthFormFooter>
</form>
</AuthPageLayout>
<small className="font-bold mt-10">{t("auth.shabaOwnerHint")}</small>
</div>
</AuthPageContent>
<AuthFormFooter onSkip={() => router.push("/verify/location")}>
<AuthNextButton type="submit" loading={loading} disabled={loading}>
{t("auth.saveAndContinue")}
</AuthNextButton>
</AuthFormFooter>
</form>
</AuthPageLayout>
</LocalePageShell>
);
}

View File

@@ -6,19 +6,20 @@ import AuthPageLayout, {
AuthPageContent,
} from "@/components/auth/AuthPageLayout";
import AuthNextButton from "@/components/auth/AuthNextButton";
import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl";
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";
// کتابخانه برای crop
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";
function AvatarPage() {
const { t } = useTranslation("common");
const router = useRouter();
const { request, loading } = useAxios();
const [userDetail, setUserDetail] = useState<IProfileData | null>(null);
@@ -45,7 +46,6 @@ function AvatarPage() {
fetchData();
}, []);
// انتخاب تصویر
const selectImage = async (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.files && event.target.files[0]) {
const file = event.target.files[0];
@@ -58,37 +58,28 @@ function AvatarPage() {
}
};
// حذف تصویر
// const removeImage = () => {
// setAvatar(null);
// setOriginalImage(null);
// };
// دریافت مختصات crop شده
const onCropComplete = useCallback(
(croppedArea: Area, croppedPixels: Area) => {
(_croppedArea: Area, croppedPixels: Area) => {
setCroppedAreaPixels(croppedPixels);
},
[]
);
// تبدیل crop شده به blob
const createCroppedImage = async () => {
if (!avatar || !croppedAreaPixels) return null;
const croppedImage = await getCroppedImg(avatar, croppedAreaPixels);
return croppedImage;
};
// آپلود تصویر
const uploadImage = async () => {
if (!avatar) {
toast.error("لطفا تصویر پروفایل را انتخاب کنید");
toast.error(t("auth.selectProfileImage"));
return;
}
setLoadingUpload(true);
const croppedBlob = await createCroppedImage();
if (!croppedBlob) {
toast.error("خطا در برش تصویر");
toast.error(t("auth.cropError"));
setLoadingUpload(false);
return;
}
@@ -101,87 +92,99 @@ function AvatarPage() {
await request("POST", "/verify/profile_image", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
toast.success("تصویر با موفقیت ثبت شد!");
navigateHandler();
toast.success(t("auth.imageSavedSuccess"));
await navigateHandler();
} catch (err) {
console.log("Upload error:", err);
toast.error("خطا در ارسال تصویر!");
toast.error(t("auth.imageUploadError"));
setLoadingUpload(false);
}
};
// ناوبری بعد از آپلود
const navigateHandler = () => {
if (userDetail?.user_type === "user") {
router.push("/verify/expertise");
} else {
router.push("/verify/gender");
// userDetail از یه fetchData جدا (useEffect موقع mount) پر می‌شه، بدون هیچ
// تضمینی که قبل از اینکه کاربر آپلود آواتار رو تموم کنه resolve بشه — اگه کاربر
// سریع‌تر از اون GET /profile عکس رو آپلود کنه، userDetail هنوز null بود، چک
// user_type==="user" شکست می‌خورد و مستقیم می‌رفت verify/gender، از verify/expertise
// کامل رد می‌شد. اینجا مستقل از اون state، مستقیم و تازه دوباره می‌گیریم
const navigateHandler = 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");
};
return (
<AuthPageLayout>
<AuthPageContent>
<span className="text-xl font-bold mb-4">تصویر پروفایل</span>
<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} // مربع 1:1
onCropChange={setCrop}
onZoomChange={setZoom}
onCropComplete={onCropComplete}
cropShape="rect"
showGrid={false}
objectFit="contain" // نمایش کامل تصویر
<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
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>
</label>
)}
</div>
<input
id="fileInput"
type="file"
accept="image/*"
className="hidden"
onChange={selectImage}
/>
<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">
انتخاب/ویرایش تصویر
</label>
</AuthNextButton>
</AuthPageContent>
<AuthNextButton className="mt-4">
<label className="cursor-pointer w-full h-full" htmlFor="fileInput">
{t("auth.selectEditImage")}
</label>
</AuthNextButton>
</AuthPageContent>
<AuthFormFooter
onSkip={navigateHandler}
skipDisabled={loadingUpload}
>
<AuthNextButton
onClick={uploadImage}
loading={loadingUpload}
disabled={loadingUpload}
>
ثبت و ادامه
</AuthNextButton>
</AuthFormFooter>
</AuthPageLayout>
<AuthFormFooter onSkip={navigateHandler} skipDisabled={loadingUpload}>
<AuthNextButton
onClick={uploadImage}
loading={loadingUpload}
disabled={loadingUpload}
>
{t("auth.saveAndContinue")}
</AuthNextButton>
</AuthFormFooter>
</AuthPageLayout>
</LocalePageShell>
);
}
export default AvatarPage;
export default AvatarPage;

View File

@@ -5,7 +5,7 @@ import AuthPageLayout, {
AuthFormFooter,
AuthPageContent,
} from "@/components/auth/AuthPageLayout";
import React, { useState } from "react";
import React, { useEffect, useMemo, useState } from "react";
import useAxios from "@/hooks/useAxios";
import * as yup from "yup";
import { useRouter } from "next/navigation";
@@ -13,29 +13,37 @@ import AuthNextButton from "@/components/auth/AuthNextButton";
import toast from "react-hot-toast";
import AuthUserDetails from "@/components/auth/AuthUserDetails";
import Image from "next/image";
// Validation schema using Yup
const schema = yup.object().shape({
selectedHairImage: yup.string().required("رنگ مو الزامی است"),
selectedEyeImage: yup.string().required(" رنگ چشم الزامی است"),
});
import { useTranslation } from "react-i18next";
import LocalePageShell from "@/components/i18n/LocalePageShell";
function Colors() {
const [selectedButton, setSelectedButton] = useState<number>(1); // Default: 1 for "قد"
const { t } = useTranslation("common");
const [selectedButton, setSelectedButton] = useState<number>(1);
const [selectedHairImage, setSelectedHairImage] = useState<string>("");
const [selectedEyeImage, setSelectedEyeImage] = useState<string>("");
const router = useRouter();
const { request, loading } = useAxios();
const mobile =
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
const [expertise] = useState<string>(
typeof window !== "undefined" ? localStorage.getItem("expertise") || "" : ""
);
if (expertise !== "مدل") {
router.push("/verify/public-relations");
const [expertise] = useState<string>(
typeof window !== "undefined" ? localStorage.getItem("expertise") || "" : ""
);
const schema = useMemo(
() =>
yup.object().shape({
selectedHairImage: yup.string().required(t("auth.hairColorRequired")),
selectedEyeImage: yup.string().required(t("auth.eyeColorRequired")),
}),
[t]
);
useEffect(() => {
if (expertise && expertise !== "مدل") {
router.replace("/verify/public-relations");
}
}, [expertise, router]);
const handleCheck = async () => {
try {
await schema.validate({ selectedHairImage, selectedEyeImage });
@@ -47,8 +55,6 @@ function Colors() {
}
};
console.log(selectedHairImage, selectedEyeImage);
const sendSizesHandler = async () => {
try {
const data = {
@@ -67,7 +73,6 @@ function Colors() {
setSelectedButton(buttonIndex);
};
// تصاویر رنگ چشم و رنگ مو
const eyeImages = Array.from(
{ length: 26 },
(_, i) => `/images/eyes/${String(i + 1).padStart(2, "0")}.png`
@@ -77,9 +82,8 @@ function Colors() {
(_, i) => `/images/hairs/${String(i + 1).padStart(2, "0")}.png`
);
// تابع برای ذخیره نام فایل انتخاب شده
const handleImageSelect = (image: string, type: "eye" | "hair") => {
const fileName = image.split("/").pop(); // استخراج فقط نام فایل
const fileName = image.split("/").pop();
if (type === "eye") {
setSelectedEyeImage(fileName || "");
} else if (type === "hair") {
@@ -87,98 +91,97 @@ function Colors() {
}
};
return (
<AuthPageLayout>
<AuthPageContent>
<span className="text-xl font-bold">سایز</span>
<div className="mt-5">
<AuthUserDetails />
</div>
<LocalePageShell>
<AuthPageLayout>
<AuthPageContent>
<span className="text-xl font-bold">{t("auth.sizesTitle")}</span>
<div className="mt-5">
<AuthUserDetails />
</div>
<p className="mt-8 text-center text-sm font-bold">مشخصات ظاهری</p>
<p className="mt-8 text-center text-sm font-bold">
{t("auth.appearanceDetails")}
</p>
{/* Buttons for categories */}
<div className="flex justify-center gap-2 mt-4 w-full max-w-[320px]">
<button
className={`p-1 rounded-full w-full border text-sm flex items-center justify-center gap-1 ${
selectedButton === 1
? "bg-[#FC8EAC] border-[#FC8EAC]"
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
}`}
onClick={() => handleButtonPress(1)}
<div className="flex justify-center gap-2 mt-4 w-full max-w-[320px]">
<button
className={`p-1 rounded-full w-full border text-sm flex items-center justify-center gap-1 ${
selectedButton === 1
? "btn-modern--selected text-white"
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
}`}
onClick={() => handleButtonPress(1)}
>
{t("auth.eyeColor")}
</button>
<button
className={`p-1 rounded-full w-full border text-sm flex items-center justify-center gap-1 ${
selectedButton === 2
? "btn-modern--selected text-white"
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
}`}
onClick={() => handleButtonPress(2)}
>
{t("auth.hairColor")}
</button>
</div>
<div className="mt-6 w-full overflow-y-auto flex flex-col items-center">
{selectedButton === 1 && (
<div className="grid grid-cols-4 gap-2 dir-ltr">
{eyeImages.map((image) => (
<Image
width={75}
height={75}
key={image}
src={image}
alt={image}
className={`cursor-pointer w-full h-auto rounded-lg ${
selectedEyeImage === image?.split("/").pop()
? "border-4 border-[#FC8EAC]"
: ""
}`}
onClick={() => handleImageSelect(image, "eye")}
/>
))}
</div>
)}
{selectedButton === 2 && (
<div className="grid grid-cols-4 gap-2 dir-ltr">
{hairImages.map((image) => (
<Image
width={75}
height={75}
key={image}
src={image}
alt={image}
className={`cursor-pointer w-full h-auto rounded-lg ${
selectedHairImage === image?.split("/").pop()
? "border-4 border-[#FC8EAC]"
: ""
}`}
onClick={() => handleImageSelect(image, "hair")}
/>
))}
</div>
)}
</div>
</AuthPageContent>
<AuthFormFooter onSkip={() => router.push("/verify/public-relations")}>
<AuthNextButton
type="button"
onClick={handleCheck}
loading={loading}
disabled={loading}
>
رنگ چشم
</button>
<button
className={`p-1 rounded-full w-full border text-sm flex items-center justify-center gap-1 ${
selectedButton === 2
? "bg-[#FC8EAC] border-[#FC8EAC]"
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
}`}
onClick={() => handleButtonPress(2)}
>
رنگ مو
</button>
</div>
{/* Display images based on selected button */}
<div className="mt-6 w-full overflow-y-auto flex flex-col items-center">
{selectedButton === 1 && (
<div className="grid grid-cols-4 gap-2 dir-ltr">
{eyeImages.map((image) => (
<Image
width={75}
height={75}
key={image}
src={image}
alt={image}
className={`cursor-pointer w-full h-auto rounded-lg ${
selectedEyeImage === image?.split("/").pop()
? "border-4 border-[#FC8EAC]"
: ""
}`}
onClick={() => handleImageSelect(image, "eye")}
/>
))}
</div>
)}
{selectedButton === 2 && (
<div className="grid grid-cols-4 gap-2 dir-ltr">
{hairImages.map((image) => (
<Image
width={75}
height={75}
key={image}
src={image}
alt={image}
className={`cursor-pointer w-full h-auto rounded-lg ${
selectedHairImage === image?.split("/").pop()
? "border-4 border-[#FC8EAC]"
: ""
}`}
onClick={() => handleImageSelect(image, "hair")}
/>
))}
</div>
)}
</div>
</AuthPageContent>
<AuthFormFooter onSkip={() => router.push("/verify/public-relations")}>
<AuthNextButton
type="button"
onClick={handleCheck}
loading={loading}
disabled={loading}
>
ثبت و ادامه
</AuthNextButton>
</AuthFormFooter>
</AuthPageLayout>
{t("auth.saveAndContinue")}
</AuthNextButton>
</AuthFormFooter>
</AuthPageLayout>
</LocalePageShell>
);
}

View File

@@ -1,164 +1,167 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
"use client";
import Container from "@/components/elements/Container";
import React, { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import AuthNextButton from "@/components/auth/AuthNextButton";
import Modal from "@/components/elements/Modal";
import Link from "next/link";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import VerificationBadge from "@/components/main/VerificationBadge";
import { useUser } from "@/hooks/useUser";
import useAxios from "@/hooks/useAxios";
function Confirm() {
const user = useUser();
const { request } = useAxios();
const router = useRouter();
const [showModal, setShowModal] = useState<boolean>(false);
const [navigating, setNavigating] = useState(false);
const userType: string | null =
typeof window !== "undefined" ? localStorage.getItem("usertype") : null;
const [verifiedStatus, setVerifiedStatus] = useState<string | null>(null);
useEffect(() => {
const markComplete = async () => {
try {
const response = await request<{ is_verified?: string }>(
"POST",
"/verify/complete",
{}
);
setVerifiedStatus(response?.is_verified ?? "pending");
} catch {
setVerifiedStatus(user?.is_verified ?? "pending");
}
};
markComplete();
}, []);
const confirmHandler = () => {
setNavigating(true);
if (userType !== "employer") {
setShowModal(true);
setNavigating(false);
} else {
router.push("/");
}
};
return (
<Container>
<div className="p-4 flex flex-col items-center justify-center address-page ">
<span className="text-xl font-bold text-[#238800]">
ثبت نام شما تکمیل شد
</span>
<small className="font-bold mt-4 mb-4">
ورود شما به خانواده مدستاگرام را تبریک می گوییم{" "}
</small>
<div className="flex flex-col justify-center items-center">
<ProfileAvatar
src={user?.profile_image}
alt={user?.user_name || "user profile"}
size="xl"
rounded="2xl"
/>
<div className="flex flex-col justify-center items-center gap-1 mt-1">
<span>
{user?.first_name && user?.first_name + " " + user?.last_name}
</span>
<div className="flex items-center gap-1 mt-1">
<span className="inline-flex items-center gap-1">
{user?.user_name}
<VerificationBadge
isVerified={verifiedStatus ?? user?.is_verified ?? "pending"}
/>
</span>
</div>
</div>
</div>
<small className="font-bold mt-10 mb-4 text-center">
پس از تایید مدارک و احراز هویت، تیک آبی یا طلایی کنار نام کاربری شما
قرار می گیرد
</small>
<div className=" flex">
<div className="">
<svg
width="80"
height="80"
viewBox="0 0 80 80"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
opacity="0.4"
d="M35.8331 8.16689C38.1331 6.20023 41.8998 6.20023 44.2331 8.16689L49.4998 12.7002C50.4998 13.5669 52.3664 14.2669 53.6998 14.2669H59.3664C62.8998 14.2669 65.7998 17.1669 65.7998 20.7002V26.3669C65.7998 27.6669 66.4998 29.5669 67.3664 30.5669L71.8998 35.8336C73.8664 38.1336 73.8664 41.9002 71.8998 44.2336L67.3664 49.5002C66.4998 50.5002 65.7998 52.3669 65.7998 53.7002V59.3669C65.7998 62.9002 62.8998 65.8002 59.3664 65.8002H53.6998C52.3998 65.8002 50.4998 66.5002 49.4998 67.3669L44.2331 71.9002C41.9331 73.8669 38.1664 73.8669 35.8331 71.9002L30.5664 67.3669C29.5664 66.5002 27.6998 65.8002 26.3664 65.8002H20.5998C17.0664 65.8002 14.1664 62.9002 14.1664 59.3669V53.6669C14.1664 52.3669 13.4664 50.5002 12.6331 49.5002L8.13311 44.2002C6.19977 41.9002 6.19977 38.1669 8.13311 35.8669L12.6331 30.5669C13.4664 29.5669 14.1664 27.7002 14.1664 26.4002V20.6669C14.1664 17.1336 17.0664 14.2336 20.5998 14.2336H26.3664C27.6664 14.2336 29.5664 13.5336 30.5664 12.6669L35.8331 8.16689Z"
fill="#0066FF"
/>
<path
d="M35.9665 50.5668C35.2999 50.5668 34.6665 50.3001 34.1999 49.8334L26.1332 41.7668C25.1665 40.8001 25.1665 39.2001 26.1332 38.2334C27.0999 37.2668 28.6999 37.2668 29.6665 38.2334L35.9665 44.5334L50.2999 30.2001C51.2665 29.2334 52.8665 29.2334 53.8332 30.2001C54.7999 31.1668 54.7999 32.7668 53.8332 33.7334L37.7332 49.8334C37.2665 50.3001 36.6332 50.5668 35.9665 50.5668Z"
fill="#0066FF"
/>
</svg>
</div>
<div className="">
<svg
width="80"
height="80"
viewBox="0 0 80 80"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
opacity="0.4"
d="M35.8331 8.16689C38.1331 6.20023 41.8998 6.20023 44.2331 8.16689L49.4998 12.7002C50.4998 13.5669 52.3664 14.2669 53.6998 14.2669H59.3664C62.8998 14.2669 65.7998 17.1669 65.7998 20.7002V26.3669C65.7998 27.6669 66.4998 29.5669 67.3664 30.5669L71.8998 35.8336C73.8664 38.1336 73.8664 41.9002 71.8998 44.2336L67.3664 49.5002C66.4998 50.5002 65.7998 52.3669 65.7998 53.7002V59.3669C65.7998 62.9002 62.8998 65.8002 59.3664 65.8002H53.6998C52.3998 65.8002 50.4998 66.5002 49.4998 67.3669L44.2331 71.9002C41.9331 73.8669 38.1664 73.8669 35.8331 71.9002L30.5664 67.3669C29.5664 66.5002 27.6998 65.8002 26.3664 65.8002H20.5998C17.0664 65.8002 14.1664 62.9002 14.1664 59.3669V53.6669C14.1664 52.3669 13.4664 50.5002 12.6331 49.5002L8.13311 44.2002C6.19977 41.9002 6.19977 38.1669 8.13311 35.8669L12.6331 30.5669C13.4664 29.5669 14.1664 27.7002 14.1664 26.4002V20.6669C14.1664 17.1336 17.0664 14.2336 20.5998 14.2336H26.3664C27.6664 14.2336 29.5664 13.5336 30.5664 12.6669L35.8331 8.16689Z"
fill="#dbd40b"
/>
<path
d="M35.9665 50.5668C35.2999 50.5668 34.6665 50.3001 34.1999 49.8334L26.1332 41.7668C25.1665 40.8001 25.1665 39.2001 26.1332 38.2334C27.0999 37.2668 28.6999 37.2668 29.6665 38.2334L35.9665 44.5334L50.2999 30.2001C51.2665 29.2334 52.8665 29.2334 53.8332 30.2001C54.7999 31.1668 54.7999 32.7668 53.8332 33.7334L37.7332 49.8334C37.2665 50.3001 36.6332 50.5668 35.9665 50.5668Z"
fill="#f5f507"
/>
</svg>
</div>
</div>
{/* */}
<AuthNextButton onClick={confirmHandler} className="mt-20" loading={navigating} disabled={navigating}>
ثبت و ادامه
</AuthNextButton>
<small className="font-bold mt-10 mb-1 text-center">
زمان تایید مدارک 5 دقیقه تا 1 ساعت در ساعات اداری و
</small>
<small className="font-bold text-center">
3 تا 8 ساعت در ساعات غیر اداری
</small>
<Modal
isOpen={showModal}
onClose={() => setShowModal(false)}
height="200px"
>
<p className="font-bold text-center mt-4">
.برای بهتر دیده شدن نمونه کارهای خود را به اشتراگ بگذراید
</p>
<div className="flex w-full justify-center items-center mt-10 gap-5">
<Link
href={"/new-post"}
className="bg-sky-200 py-2 px-5 rounded-xl min-w-[120px] text-sky-600 text-center"
>
ثبت پست
</Link>
<Link
href={"/"}
className="bg-sky-200 py-2 px-5 rounded-xl min-w-[120px] text-sky-600 text-center"
>
بعدا
</Link>
</div>
</Modal>
</div>
</Container>
);
}
export default Confirm;
/* eslint-disable @typescript-eslint/no-explicit-any */
"use client";
import Container from "@/components/elements/Container";
import React, { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import AuthNextButton from "@/components/auth/AuthNextButton";
import Modal from "@/components/elements/Modal";
import Link from "next/link";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import VerificationBadge from "@/components/main/VerificationBadge";
import { useUser } from "@/hooks/useUser";
import { formatFullName } from "@/lib/formatFullName";
import useAxios from "@/hooks/useAxios";
import { useTranslation } from "react-i18next";
import LocalePageShell from "@/components/i18n/LocalePageShell";
function Confirm() {
const { t } = useTranslation("common");
const user = useUser();
const { request } = useAxios();
const router = useRouter();
const [showModal, setShowModal] = useState<boolean>(false);
const [navigating, setNavigating] = useState(false);
const [verifiedStatus, setVerifiedStatus] = useState<string | null>(null);
useEffect(() => {
const markComplete = async () => {
try {
const response = await request<{ verify_badge?: string }>(
"POST",
"/verify/complete",
{}
);
setVerifiedStatus(response?.verify_badge ?? "none");
} catch {
setVerifiedStatus(user?.verify_badge ?? "none");
}
};
markComplete();
}, []);
const confirmHandler = () => {
setNavigating(true);
setShowModal(true);
setNavigating(false);
};
return (
<LocalePageShell>
<Container>
<div className="p-4 flex flex-col items-center justify-center address-page ">
<span className="text-xl font-bold text-[#238800]">
{t("auth.registrationComplete")}
</span>
<small className="font-bold mt-4 mb-4">
{t("auth.welcomeMessage")}
</small>
<div className="flex flex-col justify-center items-center">
<ProfileAvatar
src={user?.profile_image}
alt={user?.user_name || "user profile"}
size="xl"
rounded="2xl"
userId={user?._id}
verifyBadge={verifiedStatus ?? user?.verify_badge}
/>
<div className="flex flex-col justify-center items-center gap-1 mt-1">
<span>{formatFullName(user?.first_name, user?.last_name)}</span>
<div className="flex items-center gap-1 mt-1">
<span className="inline-flex items-center gap-1">
{user?.user_name}
<VerificationBadge
verifyBadge={verifiedStatus ?? user?.verify_badge ?? "none"}
/>
</span>
</div>
</div>
</div>
<small className="font-bold mt-10 mb-4 text-center">
{t("auth.verificationBadgeHint")}
</small>
<div className=" flex">
<div className="">
<svg
width="80"
height="80"
viewBox="0 0 80 80"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
opacity="0.4"
d="M35.8331 8.16689C38.1331 6.20023 41.8998 6.20023 44.2331 8.16689L49.4998 12.7002C50.4998 13.5669 52.3664 14.2669 53.6998 14.2669H59.3664C62.8998 14.2669 65.7998 17.1669 65.7998 20.7002V26.3669C65.7998 27.6669 66.4998 29.5669 67.3664 30.5669L71.8998 35.8336C73.8664 38.1336 73.8664 41.9002 71.8998 44.2336L67.3664 49.5002C66.4998 50.5002 65.7998 52.3669 65.7998 53.7002V59.3669C65.7998 62.9002 62.8998 65.8002 59.3664 65.8002H53.6998C52.3998 65.8002 50.4998 66.5002 49.4998 67.3669L44.2331 71.9002C41.9331 73.8669 38.1664 73.8669 35.8331 71.9002L30.5664 67.3669C29.5664 66.5002 27.6998 65.8002 26.3664 65.8002H20.5998C17.0664 65.8002 14.1664 62.9002 14.1664 59.3669V53.6669C14.1664 52.3669 13.4664 50.5002 12.6331 49.5002L8.13311 44.2002C6.19977 41.9002 6.19977 38.1669 8.13311 35.8669L12.6331 30.5669C13.4664 29.5669 14.1664 27.7002 14.1664 26.4002V20.6669C14.1664 17.1336 17.0664 14.2336 20.5998 14.2336H26.3664C27.6664 14.2336 29.5664 13.5336 30.5664 12.6669L35.8331 8.16689Z"
fill="#0066FF"
/>
<path
d="M35.9665 50.5668C35.2999 50.5668 34.6665 50.3001 34.1999 49.8334L26.1332 41.7668C25.1665 40.8001 25.1665 39.2001 26.1332 38.2334C27.0999 37.2668 28.6999 37.2668 29.6665 38.2334L35.9665 44.5334L50.2999 30.2001C51.2665 29.2334 52.8665 29.2334 53.8332 30.2001C54.7999 31.1668 54.7999 32.7668 53.8332 33.7334L37.7332 49.8334C37.2665 50.3001 36.6332 50.5668 35.9665 50.5668Z"
fill="#0066FF"
/>
</svg>
</div>
<div className="">
<svg
width="80"
height="80"
viewBox="0 0 80 80"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
opacity="0.4"
d="M35.8331 8.16689C38.1331 6.20023 41.8998 6.20023 44.2331 8.16689L49.4998 12.7002C50.4998 13.5669 52.3664 14.2669 53.6998 14.2669H59.3664C62.8998 14.2669 65.7998 17.1669 65.7998 20.7002V26.3669C65.7998 27.6669 66.4998 29.5669 67.3664 30.5669L71.8998 35.8336C73.8664 38.1336 73.8664 41.9002 71.8998 44.2336L67.3664 49.5002C66.4998 50.5002 65.7998 52.3669 65.7998 53.7002V59.3669C65.7998 62.9002 62.8998 65.8002 59.3664 65.8002H53.6998C52.3998 65.8002 50.4998 66.5002 49.4998 67.3669L44.2331 71.9002C41.9331 73.8669 38.1664 73.8669 35.8331 71.9002L30.5664 67.3669C29.5664 66.5002 27.6998 65.8002 26.3664 65.8002H20.5998C17.0664 65.8002 14.1664 62.9002 14.1664 59.3669V53.6669C14.1664 52.3669 13.4664 50.5002 12.6331 49.5002L8.13311 44.2002C6.19977 41.9002 6.19977 38.1669 8.13311 35.8669L12.6331 30.5669C13.4664 29.5669 14.1664 27.7002 14.1664 26.4002V20.6669C14.1664 17.1336 17.0664 14.2336 20.5998 14.2336H26.3664C27.6664 14.2336 29.5664 13.5336 30.5664 12.6669L35.8331 8.16689Z"
fill="#dbd40b"
/>
<path
d="M35.9665 50.5668C35.2999 50.5668 34.6665 50.3001 34.1999 49.8334L26.1332 41.7668C25.1665 40.8001 25.1665 39.2001 26.1332 38.2334C27.0999 37.2668 28.6999 37.2668 29.6665 38.2334L35.9665 44.5334L50.2999 30.2001C51.2665 29.2334 52.8665 29.2334 53.8332 30.2001C54.7999 31.1668 54.7999 32.7668 53.8332 33.7334L37.7332 49.8334C37.2665 50.3001 36.6332 50.5668 35.9665 50.5668Z"
fill="#f5f507"
/>
</svg>
</div>
</div>
<AuthNextButton
onClick={confirmHandler}
className="mt-20"
loading={navigating}
disabled={navigating}
>
{t("auth.saveAndContinue")}
</AuthNextButton>
<small className="font-bold mt-10 mb-1 text-center">
{t("auth.verificationTimeOffice")}
</small>
<small className="font-bold text-center">
{t("auth.verificationTimeOffHours")}
</small>
<Modal
isOpen={showModal}
onClose={() => setShowModal(false)}
height="200px"
>
<p className="font-bold text-center mt-4">
{t("auth.shareWorkHint")}
</p>
<div className="flex w-full justify-center items-center mt-10 gap-5">
<Link
href={"/new-post"}
className="bg-sky-200 py-2 px-5 rounded-2xl min-w-[120px] text-sky-600 text-center"
>
{t("auth.createPost")}
</Link>
<Link
href={"/"}
className="bg-sky-200 py-2 px-5 rounded-2xl min-w-[120px] text-sky-600 text-center"
>
{t("auth.later")}
</Link>
</div>
</Modal>
</div>
</Container>
</LocalePageShell>
);
}
export default Confirm;

View File

@@ -5,7 +5,7 @@ import AuthPageLayout, {
AuthFormFooter,
AuthPageContent,
} from "@/components/auth/AuthPageLayout";
import React from "react";
import React, { useMemo } from "react";
import useAxios from "@/hooks/useAxios";
import { useFormik } from "formik";
import * as yup from "yup";
@@ -13,18 +13,24 @@ import { useRouter } from "next/navigation";
import AuthNextButton from "@/components/auth/AuthNextButton";
import toast from "react-hot-toast";
import AuthUserDetails from "@/components/auth/AuthUserDetails";
// Validation schema using Yup
const schema = yup.object().shape({
selectedButton: yup.boolean().required("انتخاب نوع همکاری الزامی است"),
});
import { useTranslation } from "react-i18next";
import LocalePageShell from "@/components/i18n/LocalePageShell";
function CooperationType() {
const { t } = useTranslation("common");
const router = useRouter();
const { request, loading } = useAxios();
const mobile =
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
const schema = useMemo(
() =>
yup.object().shape({
selectedButton: yup.boolean().required(t("auth.cooperationRequired")),
}),
[t]
);
const formik = useFormik({
initialValues: {
selectedButton: null,
@@ -39,7 +45,7 @@ function CooperationType() {
});
router.push("/verify/public-relations");
} catch (err: any) {
toast.error(err?.message || "خطای نامشخصی رخ داد.");
toast.error(err?.response?.data?.message || t("auth.unknownError"));
} finally {
setSubmitting(false);
}
@@ -47,62 +53,66 @@ function CooperationType() {
});
return (
<AuthPageLayout>
<form
onSubmit={formik.handleSubmit}
className="flex w-full flex-1 flex-col items-center"
>
<AuthPageContent>
<span className="text-xl font-bold">نوع همکاری</span>
<div className="mt-5">
<AuthUserDetails />
</div>
<p className="my-10 text-sm font-bold">
آیا مایل به همکاری خارج از محل سکونت خود هستید؟
</p>
<div className="w-full max-w-sm flex flex-col items-center">
<div className="flex w-full gap-2">
<AuthNextButton
type="button"
onClick={() => formik.setFieldValue("selectedButton", true)}
className={`mt-5 text-xs w-full max-w-[250px] flex items-center justify-center flex-row-reverse gap-2 px-1 ${
formik.values.selectedButton === true
? "!bg-[#FC8EAC] !border-[#FC8EAC] !text-white"
: ""
}`}
>
بله خوشحال هم می شم
</AuthNextButton>
<AuthNextButton
type="button"
onClick={() => formik.setFieldValue("selectedButton", false)}
className={`mt-5 text-xs w-full max-w-[250px] flex items-center justify-center flex-row-reverse gap-2 px-1 ${
formik.values.selectedButton === false
? "!bg-[#FC8EAC] !border-[#FC8EAC] !text-white"
: ""
}`}
>
نه شهر خودم رو ترجیح میدم
</AuthNextButton>
</div>
{formik.errors.selectedButton && formik.touched.selectedButton && (
<div className="text-red-500 mt-2 text-sm">
{formik.errors.selectedButton}
<LocalePageShell>
<AuthPageLayout>
<form
onSubmit={formik.handleSubmit}
className="flex w-full flex-1 flex-col items-center"
>
<AuthPageContent>
<span className="text-xl font-bold">
{t("auth.cooperationTypeTitle")}
</span>
<div className="mt-5">
<AuthUserDetails />
</div>
)}
</div>
</AuthPageContent>
<AuthFormFooter onSkip={() => router.push("/verify/public-relations")}>
<AuthNextButton type="submit" loading={loading} disabled={loading}>
ثبت و ادامه
</AuthNextButton>
</AuthFormFooter>
</form>
</AuthPageLayout>
<p className="my-10 text-sm font-bold">
{t("auth.cooperationQuestion")}
</p>
<div className="w-full max-w-sm flex flex-col items-center">
<div className="flex w-full gap-2">
<AuthNextButton
type="button"
onClick={() => formik.setFieldValue("selectedButton", true)}
className={`mt-5 text-xs w-full max-w-[250px] flex items-center justify-center flex-row-reverse gap-2 px-1 ${
formik.values.selectedButton === true
? "btn-modern--selected text-white"
: ""
}`}
>
{t("auth.cooperationYes")}
</AuthNextButton>
<AuthNextButton
type="button"
onClick={() => formik.setFieldValue("selectedButton", false)}
className={`mt-5 text-xs w-full max-w-[250px] flex items-center justify-center flex-row-reverse gap-2 px-1 ${
formik.values.selectedButton === false
? "btn-modern--selected text-white"
: ""
}`}
>
{t("auth.cooperationNo")}
</AuthNextButton>
</div>
{formik.errors.selectedButton && formik.touched.selectedButton && (
<div className="text-red-500 mt-2 text-sm">
{formik.errors.selectedButton}
</div>
)}
</div>
</AuthPageContent>
<AuthFormFooter onSkip={() => router.push("/verify/public-relations")}>
<AuthNextButton type="submit" loading={loading} disabled={loading}>
{t("auth.saveAndContinue")}
</AuthNextButton>
</AuthFormFooter>
</form>
</AuthPageLayout>
</LocalePageShell>
);
}

View File

@@ -5,41 +5,58 @@ import AuthPageLayout, {
AuthFormFooter,
AuthPageContent,
} from "@/components/auth/AuthPageLayout";
import React, { useEffect, useState } from "react";
import React, { useEffect, useMemo, useState } from "react";
import useAxios from "@/hooks/useAxios";
import * as yup from "yup";
import { useRouter } from "next/navigation";
import AuthNextButton from "@/components/auth/AuthNextButton";
import toast from "react-hot-toast";
import AuthUserDetails from "@/components/auth/AuthUserDetails";
import ExpertisePicker from "@/components/auth/ExpertisePicker";
import { IExpertise } from "@/types/types";
// Validation schema using Yup
const schema = yup.object().shape({
subExpertise: yup
.array()
.of(yup.string().required("حداقل یک زیرمهارت را انتخاب کنید"))
.min(1, "حداقل یک زیرمهارت را انتخاب کنید"),
expertise: yup.string().required("انتخاب نوع تخصص الزامی است"),
});
import {
resolveDisplaySubExpertise,
subExpertiseListIncludes,
} from "@/lib/subExpertiseDisplay";
import { useTranslation } from "react-i18next";
import LocalePageShell from "@/components/i18n/LocalePageShell";
function Expertise() {
const { t } = useTranslation("common");
const [expertise, setExpertise] = useState<string>("");
const [expertiseList, setExpertiseList] = useState<IExpertise[] | null>(null);
const [subExpertise, setSubExpertise] = useState<string[]>([]);
const [displaySubExpertise, setDisplaySubExpertise] = useState<string | null>(
null
);
const router = useRouter();
const { request, loading } = useAxios();
const mobile =
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
// Fetch expertise list
const schema = useMemo(
() =>
yup.object().shape({
subExpertise: yup
.array()
.of(yup.string().required(t("auth.subExpertiseMin")))
.min(1, t("auth.subExpertiseMin")),
displaySubExpertise: yup
.string()
.nullable()
.required(t("auth.displaySubExpertiseRequired")),
expertise: yup.string().required(t("auth.expertiseRequired")),
}),
[t]
);
const fetchData = async () => {
try {
const response = await request<{ expertises: IExpertise[] }>(
"GET",
"/expertise"
);
setExpertiseList(response?.expertises);
setExpertiseList(response?.expertises ?? []);
} catch (err) {
console.log(err);
}
@@ -49,10 +66,44 @@ function Expertise() {
fetchData();
}, []);
const handleSubExpertiseToggle = (value: string) => {
setSubExpertise((prev) => {
const next = prev.includes(value)
? prev.filter((item) => item !== value)
: [...prev, value];
setDisplaySubExpertise((current) => {
if (current === value && !next.includes(value)) {
return next[0] ?? null;
}
if (current && !next.includes(current)) {
return next[0] ?? null;
}
if (!current && next.length === 1) {
return next[0];
}
return current;
});
return next;
});
};
const handleCheck = async () => {
const resolvedDisplay = subExpertiseListIncludes(
subExpertise,
displaySubExpertise
)
? displaySubExpertise
: resolveDisplaySubExpertise(subExpertise, displaySubExpertise);
try {
await schema.validate({ expertise, subExpertise });
await sendExpertiseTypeHandler();
await schema.validate({
expertise,
subExpertise,
displaySubExpertise: resolvedDisplay,
});
await sendExpertiseTypeHandler(resolvedDisplay);
} catch (validationError: any) {
toast.error(validationError.message, {
duration: 4000,
@@ -60,88 +111,62 @@ function Expertise() {
}
};
const sendExpertiseTypeHandler = async () => {
const sendExpertiseTypeHandler = async (resolvedDisplay: string | null) => {
try {
const data = {
mobile,
expertise,
sub_expertise: subExpertise,
display_sub_expertise: resolvedDisplay,
};
await request("POST", "/verify/expertise", data);
localStorage.setItem("expertise", expertise);
if (resolvedDisplay) {
localStorage.setItem("display_sub_expertise", resolvedDisplay);
}
router.push("/verify/services");
} catch (error: any) {
toast.error(error?.message || "خطای نامشخصی رخ داد.");
toast.error(error?.response?.data?.message || t("auth.unknownError"));
}
};
const handleButtonPress = (expertise: string) => {
setExpertise(expertise);
setSubExpertise([]);
};
const handleSubButtonPress = (sub_expertise: string) => {
const updatedSubExpertise = subExpertise.includes(sub_expertise)
? subExpertise.filter((item) => item !== sub_expertise)
: [...subExpertise, sub_expertise];
setSubExpertise(updatedSubExpertise);
};
const selectedExpertise = expertiseList?.find(
(item) => item.expertise === expertise
);
return (
<AuthPageLayout>
<AuthPageContent>
<span className="text-xl font-bold">تخصص</span>
<LocalePageShell>
<AuthPageLayout>
<AuthPageContent>
<span className="text-xl font-bold">{t("auth.expertiseTitle")}</span>
<AuthUserDetails />
<AuthUserDetails />
<p className="mt-8 text-center">در چه زمینه ای تخصص دارید؟</p>
<p className="mt-8 text-center">{t("auth.expertiseQuestion")}</p>
<div className="grid grid-cols-3 gap-2 mt-4 max-w-[330px]">
{expertiseList?.map((item) => (
<button
key={item._id}
className={`p-1 min-w-24 rounded-3xl border-2 overflow-hidden text-xs ${
expertise === item.expertise
? "bg-yellow-500 text-white border-yellow-500"
: "bg-white text-black border-gray-400"
}`}
onClick={() => handleButtonPress(item.expertise)}
>
{item.expertise}
</button>
))}
<ExpertisePicker
expertiseList={expertiseList}
expertise={expertise}
subExpertise={subExpertise}
displaySubExpertise={displaySubExpertise}
onExpertiseSelect={(value) => {
setExpertise(value);
setSubExpertise([]);
setDisplaySubExpertise(null);
}}
onSubExpertiseToggle={handleSubExpertiseToggle}
onDisplaySubExpertiseChange={setDisplaySubExpertise}
/>
</AuthPageContent>
{selectedExpertise?.sub_expertise.map((item) => (
<button
key={item._id}
className={`p-1 min-w-24 rounded-3xl border-2 overflow-hidden text-xs ${
subExpertise.includes(item.name)
? "bg-pink-500 text-white border-pink-500"
: "bg-white text-black border-gray-400"
}`}
onClick={() => handleSubButtonPress(item.name)}
>
{item.name}
</button>
))}
</div>
</AuthPageContent>
<AuthFormFooter onSkip={() => router.push("/verify/services")}>
<AuthNextButton
type="button"
onClick={handleCheck}
loading={loading}
disabled={loading}
>
ثبت و ادامه
</AuthNextButton>
</AuthFormFooter>
</AuthPageLayout>
<AuthFormFooter onSkip={() => router.push("/verify/services")}>
<AuthNextButton
type="button"
onClick={handleCheck}
loading={loading}
disabled={loading}
>
{t("auth.saveAndContinue")}
</AuthNextButton>
</AuthFormFooter>
</AuthPageLayout>
</LocalePageShell>
);
}

View File

@@ -5,7 +5,7 @@ import AuthPageLayout, {
AuthFormFooter,
AuthPageContent,
} from "@/components/auth/AuthPageLayout";
import React from "react";
import React, { useMemo } from "react";
import useAxios from "@/hooks/useAxios";
import { useFormik } from "formik";
import * as yup from "yup";
@@ -14,13 +14,11 @@ import AuthNextButton from "@/components/auth/AuthNextButton";
import toast from "react-hot-toast";
import AuthUserDetails from "@/components/auth/AuthUserDetails";
import Image from "next/image";
// Validation schema using Yup
const schema = yup.object().shape({
selectedButton: yup.string().required("جنسیت الزامی است"),
});
import { useTranslation } from "react-i18next";
import LocalePageShell from "@/components/i18n/LocalePageShell";
function Gender() {
const { t } = useTranslation("common");
const router = useRouter();
const { request, loading } = useAxios();
const mobile =
@@ -28,6 +26,14 @@ function Gender() {
const expertise =
typeof window !== "undefined" ? localStorage.getItem("expertise") : null;
const schema = useMemo(
() =>
yup.object().shape({
selectedButton: yup.string().required(t("auth.genderRequired")),
}),
[t]
);
const formik = useFormik({
initialValues: {
selectedButton: "",
@@ -45,7 +51,7 @@ function Gender() {
router.push("/verify/services");
}
} catch (err: any) {
toast.error(err?.message || "خطای نامشخصی رخ داد.");
toast.error(err?.response?.data?.message || t("auth.unknownError"));
} finally {
setSubmitting(false);
}
@@ -53,70 +59,72 @@ function Gender() {
});
return (
<AuthPageLayout>
<form
onSubmit={formik.handleSubmit}
className="flex w-full flex-1 flex-col items-center"
>
<AuthPageContent>
<span className="text-xl font-bold">جنسیت</span>
<div className="mt-5">
<AuthUserDetails />
</div>
<div className="w-full max-w-sm flex flex-col items-center">
<div className="flex w-full gap-4">
<AuthNextButton
type="button"
onClick={() => formik.setFieldValue("selectedButton", "female")}
className={`mt-5 w-full max-w-[250px] !border-[#FC8EAC] flex items-center justify-center flex-row-reverse gap-2 ${
formik.values.selectedButton === "female"
? "!bg-[#FC8EAC] !text-white"
: ""
}`}
>
<Image
src={"/images/icons/woman.svg"}
width={25}
height={25}
alt="gender icon"
/>
خانم
</AuthNextButton>
<AuthNextButton
type="button"
onClick={() => formik.setFieldValue("selectedButton", "male")}
className={`mt-5 w-full max-w-[250px] !border-[#3E8BFF] flex items-center justify-center flex-row-reverse gap-2 ${
formik.values.selectedButton === "male"
? "!bg-[#3E8BFF] !text-white"
: ""
}`}
>
<Image
src={"/images/icons/man.svg"}
width={25}
height={25}
alt="gender icon"
/>
آقا
</AuthNextButton>
</div>
{formik.errors.selectedButton && formik.touched.selectedButton && (
<div className="text-red-500 mt-2 text-sm">
{formik.errors.selectedButton}
<LocalePageShell>
<AuthPageLayout>
<form
onSubmit={formik.handleSubmit}
className="flex w-full flex-1 flex-col items-center"
>
<AuthPageContent>
<span className="text-xl font-bold">{t("auth.genderTitle")}</span>
<div className="mt-5">
<AuthUserDetails />
</div>
)}
</div>
</AuthPageContent>
<AuthFormFooter onSkip={() => router.push("/verify/sizes")}>
<AuthNextButton type="submit" loading={loading} disabled={loading}>
ثبت و ادامه
</AuthNextButton>
</AuthFormFooter>
</form>
</AuthPageLayout>
<div className="w-full max-w-sm flex flex-col items-center">
<div className="flex w-full gap-4">
<AuthNextButton
type="button"
onClick={() => formik.setFieldValue("selectedButton", "female")}
className={`mt-5 w-full max-w-[250px] !border-[#FC8EAC] flex items-center justify-center flex-row-reverse gap-2 ${
formik.values.selectedButton === "female"
? "btn-modern--selected text-white"
: ""
}`}
>
<Image
src={"/images/icons/woman.svg"}
width={25}
height={25}
alt="gender icon"
/>
{t("auth.female")}
</AuthNextButton>
<AuthNextButton
type="button"
onClick={() => formik.setFieldValue("selectedButton", "male")}
className={`mt-5 w-full max-w-[250px] !border-[#3E8BFF] flex items-center justify-center flex-row-reverse gap-2 ${
formik.values.selectedButton === "male"
? "!bg-[#3E8BFF] !text-white"
: ""
}`}
>
<Image
src={"/images/icons/man.svg"}
width={25}
height={25}
alt="gender icon"
/>
{t("auth.male")}
</AuthNextButton>
</div>
{formik.errors.selectedButton && formik.touched.selectedButton && (
<div className="text-red-500 mt-2 text-sm">
{formik.errors.selectedButton}
</div>
)}
</div>
</AuthPageContent>
<AuthFormFooter onSkip={() => router.push("/verify/sizes")}>
<AuthNextButton type="submit" loading={loading} disabled={loading}>
{t("auth.saveAndContinue")}
</AuthNextButton>
</AuthFormFooter>
</form>
</AuthPageLayout>
</LocalePageShell>
);
}

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