shop full
This commit is contained in:
@@ -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;
|
||||
@@ -1,151 +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, 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;
|
||||
"use client";
|
||||
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import { useFormik } from "formik";
|
||||
import * as Yup from "yup";
|
||||
import LocationSelector from "./step1/LocationSelector";
|
||||
import { useBillboardForm } from "@/contexts/BillboardFormContext";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import SelectBox from "@/components/elements/SelectBox";
|
||||
import { IAdvertisingCategory } from "@/types/types";
|
||||
import RoundedInput from "@/components/elements/RoundedInput";
|
||||
import ImageUploader from "./step1/ImageUploader";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const Step1 = ({ nextStep }: { nextStep: () => void }) => {
|
||||
const { t } = useTranslation("common");
|
||||
const { formData, updateForm } = useBillboardForm();
|
||||
const [categories, setCategories] = useState<IAdvertisingCategory[]>([]);
|
||||
const { request } = useAxios();
|
||||
|
||||
const validationSchema = useMemo(
|
||||
() =>
|
||||
Yup.object({
|
||||
stateId: Yup.string().required(t("billboards.form.validation.provinceRequired")),
|
||||
cityId: Yup.string().required(t("billboards.form.validation.cityRequired")),
|
||||
categoryId: Yup.string().required(t("billboards.form.validation.categoryRequired")),
|
||||
adsTitle: Yup.string().required(t("billboards.form.validation.titleRequired")),
|
||||
description: Yup.string().required(t("billboards.form.validation.descriptionRequired")),
|
||||
neighbourhood: Yup.string().required(t("billboards.form.validation.neighbourhoodRequired")),
|
||||
address: Yup.string().required(t("billboards.form.validation.addressRequired")),
|
||||
images: Yup.array()
|
||||
.min(1, t("billboards.form.validation.minImages"))
|
||||
.max(5, t("billboards.form.validation.maxImages"))
|
||||
.required(t("billboards.form.validation.imagesRequired")),
|
||||
markerCoordinate: Yup.array()
|
||||
.of(Yup.number().required())
|
||||
.length(2, t("billboards.form.validation.mapLocationRequired"))
|
||||
.required(t("billboards.form.validation.mapLocationRequired")),
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const response = await request<{ categories: IAdvertisingCategory[] }>(
|
||||
"GET",
|
||||
"/advertising/categories"
|
||||
);
|
||||
setCategories(response?.categories);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, [request]);
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
cityId: formData.cityId || "",
|
||||
stateId: formData.stateId || "",
|
||||
adsTitle: formData.adsTitle || "",
|
||||
categoryId: formData.categoryId || "",
|
||||
images: formData.images || [],
|
||||
address: formData.address || "",
|
||||
neighbourhood: formData.neighbourhood || "",
|
||||
description: formData.description || "",
|
||||
markerCoordinate: formData.markerCoordinate || null,
|
||||
},
|
||||
validationSchema,
|
||||
onSubmit: (values) => {
|
||||
updateForm(values);
|
||||
nextStep();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="flex flex-col gap-2 w-full items-center text-sm"
|
||||
>
|
||||
<h6 className="font-bold text-xl mb-2">{t("billboards.createTitle")}</h6>
|
||||
<SelectBox
|
||||
className={`max-w-full bg-secondary-light dark:bg-secondary-dark ${
|
||||
formik.touched.categoryId && formik.errors.categoryId
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: ""
|
||||
}`}
|
||||
value={formik.values.categoryId}
|
||||
onChange={(e) => formik.setFieldValue("categoryId", e.target.value)}
|
||||
>
|
||||
<option className="bg-secondary-light dark:bg-secondary-dark" disabled value="">
|
||||
{t("billboards.category")}
|
||||
</option>
|
||||
{categories?.map((item: IAdvertisingCategory) => (
|
||||
<option
|
||||
className="bg-secondary-light dark:bg-secondary-dark"
|
||||
key={item._id}
|
||||
value={item.title}
|
||||
>
|
||||
{item?.title}
|
||||
</option>
|
||||
))}
|
||||
</SelectBox>
|
||||
{formik.touched.categoryId && formik.errors.categoryId && (
|
||||
<p className="text-red-500 text-xs">{formik.errors.categoryId}</p>
|
||||
)}
|
||||
<hr />
|
||||
<RoundedInput
|
||||
className={`max-w-full ${
|
||||
formik.touched.stateId && formik.errors.stateId
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: ""
|
||||
}`}
|
||||
type="text"
|
||||
placeholder={t("billboards.title")}
|
||||
{...formik.getFieldProps("adsTitle")}
|
||||
/>
|
||||
{formik.touched.adsTitle && formik.errors.adsTitle && (
|
||||
<p className="text-red-500 text-xs">{formik.errors.adsTitle}</p>
|
||||
)}
|
||||
<textarea
|
||||
className={`w-full p-4 mt-2 h-24 rounded-3xl border
|
||||
${
|
||||
formik.touched.description && formik.errors.description
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: ""
|
||||
}
|
||||
border-neutral-950 text-neutral-900 dark:text-neutral-50 dark:border-neutral-400 border font-medium`}
|
||||
placeholder={t("billboards.description")}
|
||||
{...formik.getFieldProps("description")}
|
||||
/>
|
||||
{formik.touched.description && formik.errors.description && (
|
||||
<p className="text-red-500 text-xs">{formik.errors.description}</p>
|
||||
)}
|
||||
|
||||
<hr className="w-full my-2" />
|
||||
<LocationSelector formik={formik} />
|
||||
<ImageUploader formik={formik} />
|
||||
{formik.touched.images && formik.errors.images && (
|
||||
<p className="text-red-500 text-xs">{formik.errors.images}</p>
|
||||
)}
|
||||
<RoundedButton type="submit" variant="primary" className="mt-4 px-8 py-2">
|
||||
{t("billboards.submitContinue")}
|
||||
</RoundedButton>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default Step1;
|
||||
@@ -1,89 +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 { 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;
|
||||
"use client";
|
||||
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import { useFormik } from "formik";
|
||||
import * as Yup from "yup";
|
||||
import { useBillboardForm } from "@/contexts/BillboardFormContext";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Service } from "@/types/types";
|
||||
import ServiceItem from "@/components/main/Services/ServiceItem";
|
||||
import AddServiceModal from "@/components/main/Services/AddServiceModal";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const Step2 = ({ nextStep }: { nextStep: () => void }) => {
|
||||
const { t } = useTranslation("common");
|
||||
const { formData, updateForm } = useBillboardForm();
|
||||
const [isModalOpen, setModalOpen] = useState(false);
|
||||
|
||||
const validationSchema = useMemo(
|
||||
() =>
|
||||
Yup.object({
|
||||
services: Yup.array().min(1, t("billboards.form.validation.minServices")),
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
services: formData.services || [],
|
||||
},
|
||||
validationSchema,
|
||||
onSubmit: (values) => {
|
||||
updateForm(values);
|
||||
nextStep();
|
||||
},
|
||||
});
|
||||
|
||||
const services = formik.values.services;
|
||||
|
||||
const handleAddService = (newService: Service) => {
|
||||
formik.setFieldValue("services", [...services, newService]);
|
||||
};
|
||||
|
||||
const handleDeleteService = (id: string) => {
|
||||
formik.setFieldValue(
|
||||
"services",
|
||||
services.filter((service) => service.id !== id)
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="flex flex-col gap-2 w-full items-center text-sm pb-28"
|
||||
>
|
||||
<h6 className="font-bold text-xl mb-2">{t("billboards.createTitle")}</h6>
|
||||
<div className="gap-4 min-h-96 w-full">
|
||||
{services.map((service) => (
|
||||
<ServiceItem
|
||||
onDelete={handleDeleteService}
|
||||
key={service.id}
|
||||
service={service}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<hr className="w-full" />
|
||||
<div className="sticky bottom-[calc(5.5rem+env(safe-area-inset-bottom))] z-20 flex w-full justify-center gap-4 bg-background/95 py-3 backdrop-blur-sm">
|
||||
<RoundedButton type="submit" variant="primary" className="h-9 w-32">
|
||||
{t("billboards.submitContinue")}
|
||||
</RoundedButton>
|
||||
<RoundedButton
|
||||
onClick={() => setModalOpen(true)}
|
||||
className="!text-[#0066FF] !border-[#0066FF] w-32 h-9"
|
||||
type="button"
|
||||
>
|
||||
{t("billboards.add")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
{isModalOpen && (
|
||||
<AddServiceModal
|
||||
onClose={() => setModalOpen(false)}
|
||||
onAdd={handleAddService}
|
||||
isModalOpen={isModalOpen}
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default Step2;
|
||||
@@ -1,176 +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, 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;
|
||||
"use client";
|
||||
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import { useFormik } from "formik";
|
||||
import * as Yup from "yup";
|
||||
import { useBillboardForm } from "@/contexts/BillboardFormContext";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface IFeatureFetch {
|
||||
title?: string;
|
||||
value: boolean;
|
||||
_id: string;
|
||||
}
|
||||
|
||||
const Step3 = ({ nextStep }: { nextStep: () => void }) => {
|
||||
const { t } = useTranslation("common");
|
||||
const { formData, updateForm } = useBillboardForm();
|
||||
const { request } = useAxios();
|
||||
const [features, setFeatures] = useState<IFeatureFetch[]>([]);
|
||||
|
||||
const validationSchema = useMemo(
|
||||
() =>
|
||||
Yup.object({
|
||||
selectedFeatures: Yup.array().min(1, t("billboards.form.validation.minFeatures")),
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const response = await request<{ features: IFeatureFetch[] }>(
|
||||
"GET",
|
||||
"/advertising/features"
|
||||
);
|
||||
setFeatures(response.features);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, [request]);
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
selectedFeatures: formData.selectedFeatures || [],
|
||||
},
|
||||
validationSchema,
|
||||
onSubmit: (values) => {
|
||||
updateForm(values);
|
||||
nextStep();
|
||||
},
|
||||
enableReinitialize: true,
|
||||
});
|
||||
|
||||
const handleSelect = (
|
||||
featureId: string,
|
||||
value: boolean,
|
||||
title: string | undefined
|
||||
) => {
|
||||
formik.setFieldValue("selectedFeatures", [
|
||||
...formik.values.selectedFeatures.filter(
|
||||
(feature) => feature._id !== featureId
|
||||
),
|
||||
{ _id: featureId, value, title: title },
|
||||
]);
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="flex flex-col gap-4 w-full items-center text-sm"
|
||||
>
|
||||
<h6 className="font-bold text-xl mb-2">{t("billboards.createTitle")}</h6>
|
||||
|
||||
<div className="flex flex-col w-full gap-3">
|
||||
{features.map((feature: IFeatureFetch) => {
|
||||
const isSelected = formik.values.selectedFeatures.some(
|
||||
(featureItem) =>
|
||||
featureItem._id === feature._id && featureItem.value === true
|
||||
);
|
||||
const isDeselected = formik.values.selectedFeatures.some(
|
||||
(featureItem) =>
|
||||
featureItem._id === feature._id && featureItem.value === false
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={feature._id}
|
||||
className="flex justify-between items-center w-full p-4 border-b border-gray-300"
|
||||
>
|
||||
<span className="text-lg">{feature.title}</span>
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name={`feature-${feature._id}`}
|
||||
value="true"
|
||||
checked={isSelected}
|
||||
onChange={() =>
|
||||
handleSelect(feature._id, true, feature?.title)
|
||||
}
|
||||
className="hidden"
|
||||
/>
|
||||
<span>{t("billboards.form.has")}</span>
|
||||
<div
|
||||
className={`w-6 h-6 border-2 rounded-full flex items-center justify-center border-green-500`}
|
||||
>
|
||||
{isSelected && (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#01A168"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M20 6L9 17l-5-5" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name={`feature-${feature._id}`}
|
||||
value="false"
|
||||
checked={isDeselected}
|
||||
onChange={() =>
|
||||
handleSelect(feature._id, false, feature?.title)
|
||||
}
|
||||
className="hidden"
|
||||
/>
|
||||
<span>{t("billboards.form.hasNot")}</span>
|
||||
<div
|
||||
className={`w-6 h-6 border-2 rounded-full flex items-center justify-center border-red-500`}
|
||||
>
|
||||
{isDeselected && (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#FF0000"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M20 6L9 17l-5-5" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{formik.touched.selectedFeatures &&
|
||||
formik.errors.selectedFeatures &&
|
||||
typeof formik.errors.selectedFeatures === "string" && (
|
||||
<p className="text-red-500">{formik.errors.selectedFeatures}</p>
|
||||
)}
|
||||
<RoundedButton type="submit" variant="primary" className="mt-4 h-9 w-32">
|
||||
{t("billboards.submitContinue")}
|
||||
</RoundedButton>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default Step3;
|
||||
@@ -1,193 +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";
|
||||
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;
|
||||
"use client";
|
||||
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import { useFormik } from "formik";
|
||||
import * as Yup from "yup";
|
||||
import { useBillboardForm } from "@/contexts/BillboardFormContext";
|
||||
import { useEffect, useState } from "react";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import RoundedInput from "@/components/elements/RoundedInput";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
landline: Yup.string(),
|
||||
mobile: Yup.string(),
|
||||
telegram: Yup.string(),
|
||||
whatsapp: Yup.string(),
|
||||
instagram: Yup.string(),
|
||||
saveData: Yup.boolean(),
|
||||
});
|
||||
|
||||
interface ContactInfo {
|
||||
landline?: string;
|
||||
mobile?: string;
|
||||
telegram?: string;
|
||||
whatsapp?: string;
|
||||
instagram?: string;
|
||||
saveData?: boolean;
|
||||
}
|
||||
|
||||
interface ApiResponse {
|
||||
data: {
|
||||
contactInfo: ContactInfo;
|
||||
};
|
||||
}
|
||||
|
||||
const Step4 = ({ nextStep }: { nextStep: () => void }) => {
|
||||
const { t } = useTranslation("common");
|
||||
const { formData, updateForm } = useBillboardForm();
|
||||
const { request } = useAxios();
|
||||
const [isCheckedOne, setIsCheckedOne] = useState<boolean>(false);
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
landline: formData.landline || "",
|
||||
mobile: formData.mobile || "",
|
||||
telegram: formData.telegram || "",
|
||||
whatsapp: formData.whatsapp || "",
|
||||
instagram: formData.instagram || "",
|
||||
saveData: formData.saveData || false,
|
||||
},
|
||||
validationSchema,
|
||||
onSubmit: (values) => {
|
||||
if (
|
||||
!formik?.values?.landline &&
|
||||
!formik?.values?.mobile &&
|
||||
!formik?.values?.telegram &&
|
||||
!formik?.values?.whatsapp &&
|
||||
!formik?.values?.instagram
|
||||
) {
|
||||
toast.error(t("billboards.form.contactRequired"));
|
||||
} else {
|
||||
updateForm(values);
|
||||
nextStep();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const response = await request<ApiResponse>(
|
||||
"GET",
|
||||
"/users/contact-info"
|
||||
);
|
||||
const contactInfo = response?.data?.contactInfo;
|
||||
|
||||
if (contactInfo) {
|
||||
formik.setFieldValue("landline", contactInfo.landline || "");
|
||||
formik.setFieldValue("mobile", contactInfo.mobile || "");
|
||||
formik.setFieldValue("telegram", contactInfo.telegram || "");
|
||||
formik.setFieldValue("whatsapp", contactInfo.whatsapp || "");
|
||||
formik.setFieldValue("instagram", contactInfo.instagram || "");
|
||||
formik.setFieldValue("saveData", contactInfo.saveData || false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error fetching contact info:", err);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [request]);
|
||||
|
||||
const toggleCheckBox = () => {
|
||||
formik.setFieldValue("saveData", !isCheckedOne);
|
||||
setIsCheckedOne(!isCheckedOne);
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="flex flex-col gap-2 w-full items-center text-sm"
|
||||
>
|
||||
<h6 className="font-bold text-xl mb-2">{t("billboards.createTitle")}</h6>
|
||||
|
||||
<RoundedInput
|
||||
className={`max-w-full mt-2 ${
|
||||
formik.touched.landline && formik.errors.landline
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
type="text"
|
||||
maxLength={11}
|
||||
placeholder={t("billboards.form.landline")}
|
||||
{...formik.getFieldProps("landline")}
|
||||
/>
|
||||
{formik.touched.landline && formik.errors.landline && (
|
||||
<p className="text-red-500 text-xs">{formik.errors.landline}</p>
|
||||
)}
|
||||
<RoundedInput
|
||||
className={`max-w-full mt-2 ${
|
||||
formik.touched.mobile && formik.errors.mobile
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
type="text"
|
||||
maxLength={11}
|
||||
placeholder={t("billboards.form.mobile")}
|
||||
{...formik.getFieldProps("mobile")}
|
||||
/>
|
||||
{formik.touched.mobile && formik.errors.mobile && (
|
||||
<p className="text-red-500 text-xs">{formik.errors.mobile}</p>
|
||||
)}
|
||||
<RoundedInput
|
||||
className={`max-w-full mt-2 ${
|
||||
formik.touched.telegram && formik.errors.telegram
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
type="text"
|
||||
placeholder={t("billboards.form.telegram")}
|
||||
{...formik.getFieldProps("telegram")}
|
||||
/>
|
||||
{formik.touched.telegram && formik.errors.telegram && (
|
||||
<p className="text-red-500 text-xs">{formik.errors.telegram}</p>
|
||||
)}
|
||||
<RoundedInput
|
||||
className={`max-w-full mt-2 ${
|
||||
formik.touched.whatsapp && formik.errors.whatsapp
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
type="text"
|
||||
placeholder={t("billboards.form.whatsapp")}
|
||||
maxLength={11}
|
||||
{...formik.getFieldProps("whatsapp")}
|
||||
/>
|
||||
{formik.touched.whatsapp && formik.errors.whatsapp && (
|
||||
<p className="text-red-500 text-xs">{formik.errors.whatsapp}</p>
|
||||
)}
|
||||
<RoundedInput
|
||||
className={`max-w-full mt-2 ${
|
||||
formik.touched.instagram && formik.errors.instagram
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
type="text"
|
||||
placeholder={t("billboards.form.instagram")}
|
||||
{...formik.getFieldProps("instagram")}
|
||||
/>
|
||||
{formik.touched.instagram && formik.errors.instagram && (
|
||||
<p className="text-red-500 text-xs">{formik.errors.instagram}</p>
|
||||
)}
|
||||
|
||||
<label className="flex items-center gap-2 justify-start w-full pr-4 mt-4">
|
||||
<input
|
||||
{...formik.getFieldProps("stateId")}
|
||||
className="scale-125"
|
||||
type="checkbox"
|
||||
onChange={toggleCheckBox}
|
||||
/>
|
||||
<span>{t("billboards.form.saveContactInfo")}</span>
|
||||
</label>
|
||||
|
||||
<hr />
|
||||
<RoundedButton type="submit" variant="primary" className="mt-4 px-8 py-2">
|
||||
{t("billboards.submitContinue")}
|
||||
</RoundedButton>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default Step4;
|
||||
@@ -1,268 +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, 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;
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import { useFormik } from "formik";
|
||||
import * as Yup from "yup";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { IAdvertisingType } from "@/types/types";
|
||||
import { sampleAds, sampleAdsHighlight, sampleAdsSpecial } from "@/constants";
|
||||
import RoundedDiv from "@/components/elements/RoundedDiv";
|
||||
import { selectionCardClass } from "@/lib/ui/buttonStyles";
|
||||
import { useRouter } from "next/navigation";
|
||||
import MainBillboardCard from "@/components/billboards/MainBillboardCard/MainBillboardCard";
|
||||
import { dataURLtoBlob } from "@/helpers/helpers";
|
||||
import { useBillboardForm } from "@/contexts/BillboardFormContext";
|
||||
import { getBillboardDisplayTypeOptionLabel } from "@/lib/billboards/displayTypeLabel";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface CreateAdvertisingResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data: {
|
||||
id: string;
|
||||
};
|
||||
}
|
||||
|
||||
const Step5 = () => {
|
||||
const { t } = useTranslation("common");
|
||||
const router = useRouter();
|
||||
const { request } = useAxios();
|
||||
const { formData, updateForm, isEditing } = useBillboardForm();
|
||||
const [selectedType, setSelectedType] = useState("normal");
|
||||
const [showDiscount, setShowDiscount] = useState<boolean>(false);
|
||||
const [typeList, setTypeList] = useState<IAdvertisingType[] | null>(null);
|
||||
|
||||
const validationSchema = useMemo(
|
||||
() =>
|
||||
Yup.object({
|
||||
selectedType: Yup.string().required(
|
||||
t("billboards.form.validation.displayTypeRequired")
|
||||
),
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
const {
|
||||
adsTitle,
|
||||
categoryId,
|
||||
description,
|
||||
stateId,
|
||||
cityId,
|
||||
neighbourhood,
|
||||
address,
|
||||
markerCoordinate,
|
||||
services,
|
||||
selectedFeatures,
|
||||
landline,
|
||||
mobile,
|
||||
telegram,
|
||||
whatsapp,
|
||||
instagram,
|
||||
saveData,
|
||||
images,
|
||||
} = formData;
|
||||
|
||||
const toggleCheckBox = () => {
|
||||
setShowDiscount(!showDiscount);
|
||||
};
|
||||
|
||||
const fetchStates = async () => {
|
||||
try {
|
||||
const response = await request<{ advertisingTypes: IAdvertisingType[] }>(
|
||||
"GET",
|
||||
"/advertising/types"
|
||||
);
|
||||
setTypeList(response?.advertisingTypes || null);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchStates();
|
||||
}, []);
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
selectedType: formData.selectedType || "",
|
||||
},
|
||||
validationSchema,
|
||||
onSubmit: async (values) => {
|
||||
updateForm(values);
|
||||
|
||||
const data = new FormData();
|
||||
data.append("title", adsTitle);
|
||||
data.append("category", categoryId);
|
||||
if (description) {
|
||||
data.append("description", description);
|
||||
}
|
||||
data.append("province", stateId ? stateId : "");
|
||||
data.append("city", cityId ? cityId : "");
|
||||
if (neighbourhood) {
|
||||
data.append("neighbourhood", neighbourhood);
|
||||
}
|
||||
if (address) {
|
||||
data.append("address", address);
|
||||
}
|
||||
if (markerCoordinate) {
|
||||
data.append("lat", markerCoordinate[0]);
|
||||
data.append("lng", markerCoordinate[1]);
|
||||
}
|
||||
data.append("services", JSON.stringify(services));
|
||||
data.append("features", JSON.stringify(selectedFeatures));
|
||||
if (landline) {
|
||||
data.append("contactInfo[phone]", landline);
|
||||
}
|
||||
if (mobile) {
|
||||
data.append("contactInfo[mobile]", mobile);
|
||||
}
|
||||
if (telegram) {
|
||||
data.append("contactInfo[telegramLink]", telegram);
|
||||
}
|
||||
if (whatsapp) {
|
||||
data.append("contactInfo[whatsappNumber]", whatsapp);
|
||||
}
|
||||
if (instagram) {
|
||||
data.append("contactInfo[instagramLink]", instagram);
|
||||
}
|
||||
if (saveData !== null && typeof saveData === "boolean") {
|
||||
data.append("contactInfo[saveInfoForNextAds]", String(saveData));
|
||||
}
|
||||
|
||||
if (showDiscount !== null && typeof showDiscount === "boolean") {
|
||||
data.append("showDiscount", String(showDiscount));
|
||||
}
|
||||
data.append("type", selectedType);
|
||||
|
||||
const mostDiscountPercentage =
|
||||
services.length > 0
|
||||
? services.reduce<number>((max, service) => {
|
||||
const discount =
|
||||
typeof service.discountPercentage === "number"
|
||||
? service.discountPercentage
|
||||
: 0;
|
||||
|
||||
return discount > max ? discount : max;
|
||||
}, 0)
|
||||
: 0;
|
||||
|
||||
if (mostDiscountPercentage) {
|
||||
data.append("mostDiscountPercentage", String(mostDiscountPercentage));
|
||||
}
|
||||
|
||||
images?.forEach((image) => {
|
||||
if (typeof image === "string" && image.startsWith("/advertising/")) {
|
||||
data.append("existingImages[]", image);
|
||||
} else {
|
||||
const blob = dataURLtoBlob(image);
|
||||
data.append("images", blob);
|
||||
}
|
||||
});
|
||||
|
||||
services.forEach((service) => {
|
||||
if (
|
||||
typeof service.image === "string" &&
|
||||
service.image.startsWith("/services/")
|
||||
) {
|
||||
data.append(`existingServiceImages[${service.id}]`, service.image);
|
||||
} else if (service.image) {
|
||||
const blob = dataURLtoBlob(service.image);
|
||||
data.append(
|
||||
`serviceImages[${service.id}]`,
|
||||
blob,
|
||||
`service-${service.id}.jpg`
|
||||
);
|
||||
}
|
||||
});
|
||||
try {
|
||||
if (isEditing) {
|
||||
await request<{ id: string; message: string }>(
|
||||
"POST",
|
||||
`/advertising/edit/${formData?._id}`,
|
||||
data
|
||||
);
|
||||
localStorage.removeItem("billboardForm");
|
||||
router.push(`/settings/my-billboards`);
|
||||
} else {
|
||||
const response = await request<CreateAdvertisingResponse>(
|
||||
"POST",
|
||||
"/advertising/create",
|
||||
data
|
||||
);
|
||||
localStorage.removeItem("billboardForm");
|
||||
router.push(`/billboards/new/${response.data.id}`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.log("Unhandled error:", err?.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing && formik.values.selectedType) {
|
||||
formik.submitForm();
|
||||
}
|
||||
}, [isEditing, formik.values.selectedType]);
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="flex flex-col gap-2 w-full items-center text-sm"
|
||||
>
|
||||
<h6 className="font-bold text-xl mb-2">{t("billboards.displayTypeTitle")}</h6>
|
||||
<p>{t("billboards.displayTypeHint")}</p>
|
||||
<div className="flex items-center gap-2 mt-10">
|
||||
<input
|
||||
className="scale-125"
|
||||
type="checkbox"
|
||||
onChange={toggleCheckBox}
|
||||
/>
|
||||
<span className="text-xs font-bold">{t("billboards.showDiscount")}</span>
|
||||
</div>
|
||||
{typeList?.map((item: IAdvertisingType) => {
|
||||
return (
|
||||
<div
|
||||
onClick={() => {
|
||||
setSelectedType(item?.name);
|
||||
formik.setFieldValue("selectedType", item?.name);
|
||||
}}
|
||||
className="w-full flex flex-col items-center"
|
||||
key={item?._id}
|
||||
>
|
||||
<MainBillboardCard
|
||||
adStatus=""
|
||||
billboard={
|
||||
item?.name == "special"
|
||||
? sampleAdsSpecial
|
||||
: item?.name == "highlight"
|
||||
? sampleAdsHighlight
|
||||
: sampleAds
|
||||
}
|
||||
/>
|
||||
<RoundedDiv
|
||||
className={selectionCardClass(selectedType == item?.name)}
|
||||
>
|
||||
{getBillboardDisplayTypeOptionLabel(t, item.name)}:
|
||||
{item.price !== 0
|
||||
? !showDiscount
|
||||
? Number(item.price).toLocaleString() + t("settings.toman")
|
||||
: (Number(item.price) + 20000).toLocaleString() +
|
||||
t("settings.toman")
|
||||
: ` ${t("billboards.free")}`}
|
||||
</RoundedDiv>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{formik.touched.selectedType && formik.errors.selectedType && (
|
||||
<p className="text-red-500">{formik.errors.selectedType}</p>
|
||||
)}
|
||||
<RoundedButton type="submit" variant="primary" className="mt-4 px-8 py-2">
|
||||
{t("billboards.submitRequest")}
|
||||
</RoundedButton>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default Step5;
|
||||
@@ -1,90 +1,90 @@
|
||||
/* 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;
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
|
||||
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type ImageUploaderProps = {
|
||||
formik: any;
|
||||
};
|
||||
|
||||
const ImageUploader: React.FC<ImageUploaderProps> = ({ formik }) => {
|
||||
const { t } = useTranslation("common");
|
||||
const [images, setImages] = useState<string[]>(formik.values.images || []);
|
||||
|
||||
const handleImageUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!event.target.files) return;
|
||||
const files = Array.from(event.target.files);
|
||||
|
||||
if (images.length + files.length > 5) {
|
||||
alert(t("billboards.form.maxImagesAlert"));
|
||||
return;
|
||||
}
|
||||
|
||||
const newImages: string[] = [];
|
||||
|
||||
files.forEach((file) => {
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result === "string") {
|
||||
newImages.push(reader.result);
|
||||
if (newImages.length === files.length) {
|
||||
const updatedImages = [...images, ...newImages];
|
||||
setImages(updatedImages);
|
||||
formik.setFieldValue("images", updatedImages);
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveImage = (index: number) => {
|
||||
const newImages = images.filter((_, i) => i !== index);
|
||||
setImages(newImages);
|
||||
formik.setFieldValue("images", newImages);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2 w-full mt-5">
|
||||
{images.map((image, index) => (
|
||||
<div
|
||||
key={crypto.randomUUID()}
|
||||
className="relative w-24 h-24 rounded-lg overflow-hidden border"
|
||||
>
|
||||
<img
|
||||
src={
|
||||
images[index].slice(0, 12) === "/advertising"
|
||||
? IMAGE_BASE_URL + image
|
||||
: image
|
||||
}
|
||||
alt="uploaded"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-1 right-1 bg-black bg-opacity-50 p-1 px-2.5 rounded-full text-white"
|
||||
onClick={() => handleRemoveImage(index)}
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{images.length < 5 && (
|
||||
<label className="w-24 h-24 flex items-center justify-center border-dashed border-2 cursor-pointer rounded-lg">
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleImageUpload}
|
||||
/>
|
||||
+
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageUploader;
|
||||
@@ -1,202 +1,202 @@
|
||||
/* 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;
|
||||
/* 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;
|
||||
3
public/images/icons/activity.svg
Normal file
3
public/images/icons/activity.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="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 |
4
public/images/icons/archive-book.svg
Normal file
4
public/images/icons/archive-book.svg
Normal 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 |
3
public/images/icons/chart-square.svg
Normal file
3
public/images/icons/chart-square.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="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 |
4
public/images/icons/user-remove.svg
Normal file
4
public/images/icons/user-remove.svg
Normal 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 |
@@ -148,7 +148,7 @@ export default function CourseDetail({
|
||||
setVideoFields(getAcademyCourses.data.courses);
|
||||
setSelectedVideo(getAcademyCourses.data.courses[0])
|
||||
} catch (err) {
|
||||
console.log("get error:", err);
|
||||
console.error("get error:", err);
|
||||
toast.error(t("academy.course.loadError"));
|
||||
} finally {
|
||||
}
|
||||
@@ -164,20 +164,16 @@ export default function CourseDetail({
|
||||
`/academy/course/checkCoursePurchase/${id}` // ارسال ID در مسیر
|
||||
);
|
||||
|
||||
console.log(getCoursespayment);
|
||||
|
||||
if (getCoursespayment?.success) {
|
||||
if (getCoursespayment.isPurchased) {
|
||||
console.log("دوره خریداری شده است");
|
||||
setIsPurchased(true)
|
||||
} else {
|
||||
console.log("دوره خریداری نشده است");
|
||||
setIsPurchased(false)
|
||||
}
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.log("get error:", err);
|
||||
console.error("get error:", err);
|
||||
toast.error(t("academy.course.loadError"));
|
||||
}
|
||||
};
|
||||
@@ -363,7 +359,7 @@ export default function CourseDetail({
|
||||
setCourses([]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log("get error:", err);
|
||||
console.error("get error:", err);
|
||||
toast.error(t("academy.course.loadError"));
|
||||
setCourses([]);
|
||||
} finally {
|
||||
@@ -484,13 +480,7 @@ export default function CourseDetail({
|
||||
|
||||
// نمایش لودینگ
|
||||
loadingToastId = toast.loading(t("academy.course.connectingPayment"));
|
||||
|
||||
console.log("ارسال درخواست پرداخت:", {
|
||||
planDuration: plan.duration,
|
||||
planLabel: plan.label,
|
||||
price: plan.price
|
||||
});
|
||||
|
||||
|
||||
const response = await request(
|
||||
"POST",
|
||||
"/academy/academy/course/payment-web",
|
||||
@@ -501,9 +491,7 @@ export default function CourseDetail({
|
||||
|
||||
// بستن لودینگ
|
||||
toast.dismiss(loadingToastId);
|
||||
|
||||
console.log("پاسخ کامل سرور:", response);
|
||||
|
||||
|
||||
// پردازش پاسخ (ساختارهای مختلف احتمالی)
|
||||
let authority = null;
|
||||
let paymentUrl = null;
|
||||
@@ -521,7 +509,6 @@ export default function CourseDetail({
|
||||
}
|
||||
|
||||
if (paymentUrl) {
|
||||
console.log("هدایت به درگاه:", paymentUrl);
|
||||
window.open(paymentUrl, "_blank", "noopener,noreferrer");
|
||||
toast.success(t("academy.course.redirectedToPayment"));
|
||||
|
||||
|
||||
@@ -18,10 +18,12 @@ function SuccessProject() {
|
||||
const { request } = useAxios();
|
||||
const searchParams = useSearchParams();
|
||||
const projectId = searchParams.get("projectId");
|
||||
const paymentType = searchParams.get("type");
|
||||
|
||||
const [project, setProject] = useState<Project>();
|
||||
const [typeList, setTypeList] = useState<IProjectType[] | null>(null);
|
||||
const [price, setPrice] = useState("");
|
||||
const [completionSubmitted, setCompletionSubmitted] = useState(false);
|
||||
|
||||
const getDisplayTypeLabel = (projectType?: string) => {
|
||||
if (projectType === "normal" || projectType === "free") {
|
||||
@@ -54,8 +56,8 @@ function SuccessProject() {
|
||||
}
|
||||
};
|
||||
void fetchAd();
|
||||
void fetchStates();
|
||||
}, [projectId, request]);
|
||||
if (!paymentType) void fetchStates();
|
||||
}, [projectId, request, paymentType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (project?.project_type && typeList) {
|
||||
@@ -66,6 +68,38 @@ function SuccessProject() {
|
||||
}
|
||||
}, [project, typeList]);
|
||||
|
||||
// پرداخت مرحله تکمیل پروژه: نظر و امتیازی که قبل از رفتن به درگاه ذخیره شده بود حالا ثبت میشود
|
||||
useEffect(() => {
|
||||
if (paymentType !== "completion" || !projectId || completionSubmitted) return;
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const key = `project-done-${projectId}`;
|
||||
const stashed = sessionStorage.getItem(key);
|
||||
if (!stashed) {
|
||||
setCompletionSubmitted(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const submitDone = async () => {
|
||||
try {
|
||||
const { comment, rate, user_id } = JSON.parse(stashed);
|
||||
const body: Record<string, unknown> = {
|
||||
project_id: projectId,
|
||||
comment,
|
||||
user_id,
|
||||
};
|
||||
if (rate) body.rate = rate;
|
||||
await request("POST", "/projects/done/web", body);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
} finally {
|
||||
sessionStorage.removeItem(key);
|
||||
setCompletionSubmitted(true);
|
||||
}
|
||||
};
|
||||
void submitDone();
|
||||
}, [paymentType, projectId, completionSubmitted, request]);
|
||||
|
||||
return (
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
@@ -84,10 +118,12 @@ function SuccessProject() {
|
||||
<div className="mt-10 w-full px-4 text-sm font-semibold md:text-base">
|
||||
{project && <MainProjectCard project={project} />}
|
||||
<div className="mt-4 flex flex-col items-center gap-4">
|
||||
<RoundedDiv className="w-full p-2">
|
||||
{getDisplayTypeLabel(project?.project_type)}:{" "}
|
||||
{Number(price).toLocaleString()} {t("settings.toman")}
|
||||
</RoundedDiv>
|
||||
{!paymentType && (
|
||||
<RoundedDiv className="w-full p-2">
|
||||
{getDisplayTypeLabel(project?.project_type)}:{" "}
|
||||
{Number(price).toLocaleString()} {t("settings.toman")}
|
||||
</RoundedDiv>
|
||||
)}
|
||||
<p className="my-5">{t("projects.payment.reviewNotice")}</p>
|
||||
<Link href={"/settings/workroom"}>
|
||||
<RoundedButton variant="primary" className="h-9 w-32">
|
||||
|
||||
179
src/app/gift/[userId]/page.tsx
Normal file
179
src/app/gift/[userId]/page.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import RoundedInput from "@/components/elements/RoundedInput";
|
||||
import UserInfo from "@/components/main/UserInfo";
|
||||
import PageTitle from "@/components/settings/PageTitle";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { filterChipClass } from "@/lib/ui/buttonStyles";
|
||||
import { User } from "@/types/types";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const PRESET_AMOUNTS = [2000, 5000, 10000, 15000, 20000, 50000, 100000];
|
||||
const CUSTOM_MIN = 5000;
|
||||
const CUSTOM_MAX = 100000000;
|
||||
|
||||
interface GiftPageProps {
|
||||
params: Promise<{ userId: string }>;
|
||||
}
|
||||
|
||||
function GiftPage({ params }: GiftPageProps) {
|
||||
const { t } = useTranslation("common");
|
||||
const { userId } = React.use(params);
|
||||
const { request } = useAxios();
|
||||
const router = useRouter();
|
||||
|
||||
const [userDetail, setUserDetail] = useState<User>();
|
||||
const [selectedAmount, setSelectedAmount] = useState<number | null>(
|
||||
PRESET_AMOUNTS[0]
|
||||
);
|
||||
const [customAmount, setCustomAmount] = useState("");
|
||||
const [isCustom, setIsCustom] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUser = async () => {
|
||||
const response = await request<{ user: User }>(
|
||||
"GET",
|
||||
`/users/get?user_id=${userId}`
|
||||
);
|
||||
setUserDetail(response?.user);
|
||||
};
|
||||
void fetchUser();
|
||||
}, [userId, request]);
|
||||
|
||||
const finalAmount = useMemo(() => {
|
||||
if (isCustom) return Number(customAmount) || 0;
|
||||
return selectedAmount || 0;
|
||||
}, [isCustom, customAmount, selectedAmount]);
|
||||
|
||||
const isCustomValid =
|
||||
!isCustom ||
|
||||
(Number(customAmount) >= CUSTOM_MIN && Number(customAmount) <= CUSTOM_MAX);
|
||||
|
||||
const canSubmit = finalAmount > 0 && isCustomValid && !submitting;
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!canSubmit) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const response = await request<{ authority?: string; paymentUrl?: string }>(
|
||||
"POST",
|
||||
"/gifts/initiate",
|
||||
{ receiverId: userId, amount: finalAmount, message }
|
||||
);
|
||||
if (response?.paymentUrl) {
|
||||
router.push(response.paymentUrl);
|
||||
} else if (response?.authority) {
|
||||
router.push(`https://www.zarinpal.com/pg/StartPay/${response.authority}`);
|
||||
} else {
|
||||
toast.error(t("giftPage.paymentInfoError"));
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message2 =
|
||||
(err as { response?: { data?: { message?: string } } })?.response
|
||||
?.data?.message || t("giftPage.paymentStartError");
|
||||
toast.error(message2);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="mx-3 mb-10">
|
||||
<PageTitle>{t("giftPage.title")}</PageTitle>
|
||||
|
||||
{userDetail ? (
|
||||
<div className="mt-4 flex justify-center">
|
||||
<UserInfo
|
||||
first_name={userDetail?.first_name}
|
||||
is_verified={userDetail?.is_verified}
|
||||
profile_image={userDetail?.profile_image}
|
||||
user_level={userDetail?.user_level}
|
||||
last_name={userDetail?.last_name}
|
||||
user_name={userDetail?.user_name}
|
||||
noLink
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mx-auto mt-8 flex w-full max-w-md flex-wrap justify-center gap-2">
|
||||
{PRESET_AMOUNTS.map((amount) => (
|
||||
<button
|
||||
key={amount}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsCustom(false);
|
||||
setSelectedAmount(amount);
|
||||
}}
|
||||
className={filterChipClass(!isCustom && selectedAmount === amount)}
|
||||
>
|
||||
{amount.toLocaleString()} {t("settings.toman")}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCustom(true)}
|
||||
className={filterChipClass(isCustom)}
|
||||
>
|
||||
{t("giftPage.customAmount")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isCustom ? (
|
||||
<div className="mx-auto mt-4 w-full max-w-md">
|
||||
<RoundedInput
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
placeholder={t("giftPage.customAmountPlaceholder", {
|
||||
min: CUSTOM_MIN.toLocaleString(),
|
||||
max: CUSTOM_MAX.toLocaleString(),
|
||||
})}
|
||||
value={customAmount}
|
||||
onChange={(e) => setCustomAmount(e.target.value)}
|
||||
/>
|
||||
{!isCustomValid && customAmount ? (
|
||||
<p className="mt-2 text-center text-xs text-red-500">
|
||||
{t("giftPage.customAmountInvalid", {
|
||||
min: CUSTOM_MIN.toLocaleString(),
|
||||
max: CUSTOM_MAX.toLocaleString(),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mx-auto mt-6 w-full max-w-md">
|
||||
<textarea
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder={t("giftPage.messagePlaceholder")}
|
||||
rows={3}
|
||||
maxLength={500}
|
||||
className="w-full resize-none rounded-3xl border border-border-secondary-light bg-secondary-light p-4 font-medium dark:border-border-secondary-dark dark:bg-secondary-dark"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex justify-center">
|
||||
<RoundedButton
|
||||
type="button"
|
||||
variant="primary"
|
||||
className="px-8 py-2"
|
||||
disabled={!canSubmit}
|
||||
onClick={() => void handleSubmit()}
|
||||
>
|
||||
{t("giftPage.payButton")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default GiftPage;
|
||||
16
src/app/gift/layout.tsx
Normal file
16
src/app/gift/layout.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import Header from "@/components/main/Header";
|
||||
import TabNavigation from "@/components/TabNavigation";
|
||||
|
||||
export default function GiftLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="pb-24">
|
||||
<Header />
|
||||
{children}
|
||||
<TabNavigation currentPage="/" />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
41
src/app/gift/payment/failed/page.tsx
Normal file
41
src/app/gift/payment/failed/page.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
function GiftPaymentFailed() {
|
||||
const { t } = useTranslation("common");
|
||||
const searchParams = useSearchParams();
|
||||
const receiverId = searchParams.get("receiverId");
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<h6 className="mb-10 mt-20 line-clamp-2 text-center text-lg font-bold text-[#FF0000] md:text-xl">
|
||||
{t("giftPage.paymentFailed.title")}
|
||||
</h6>
|
||||
<div className="flex flex-col items-center text-sm">
|
||||
<Image
|
||||
width={50}
|
||||
height={50}
|
||||
alt="failed icon"
|
||||
src="/images/icons/failed.svg"
|
||||
className="mb-8 pb-1"
|
||||
/>
|
||||
<span>{t("giftPage.paymentFailed.message")}</span>
|
||||
</div>
|
||||
{receiverId ? (
|
||||
<Link className="mt-8 flex flex-col items-center" href={`/gift/${receiverId}`}>
|
||||
<RoundedButton variant="primary" className="h-9 w-32">
|
||||
{t("giftPage.paymentFailed.retry")}
|
||||
</RoundedButton>
|
||||
</Link>
|
||||
) : null}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default GiftPaymentFailed;
|
||||
51
src/app/gift/payment/success/page.tsx
Normal file
51
src/app/gift/payment/success/page.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
function GiftPaymentSuccess() {
|
||||
const { t } = useTranslation("common");
|
||||
const searchParams = useSearchParams();
|
||||
const receiverId = searchParams.get("receiverId");
|
||||
const receiverUsername = searchParams.get("receiverUsername");
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<h6 className="mb-4 mt-10 line-clamp-2 text-center text-lg font-bold text-[#17A600] md:text-xl">
|
||||
{t("giftPage.paymentSuccess.title")}
|
||||
</h6>
|
||||
<div className="flex flex-col items-center text-sm">
|
||||
<Image
|
||||
width={50}
|
||||
height={50}
|
||||
alt="success icon"
|
||||
src="/images/icons/success.svg"
|
||||
className="mb-5 pb-1"
|
||||
/>
|
||||
<p className="my-5 px-4 text-center">
|
||||
{t("giftPage.paymentSuccess.message")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-center gap-4">
|
||||
{receiverId && receiverUsername ? (
|
||||
<Link href={`/settings/chats/${receiverUsername}/${receiverId}`}>
|
||||
<RoundedButton variant="primary" className="h-9 w-36">
|
||||
{t("giftPage.paymentSuccess.sendMessage")}
|
||||
</RoundedButton>
|
||||
</Link>
|
||||
) : null}
|
||||
<Link href="/">
|
||||
<RoundedButton variant="primary" className="h-9 w-36">
|
||||
{t("giftPage.paymentSuccess.later")}
|
||||
</RoundedButton>
|
||||
</Link>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default GiftPaymentSuccess;
|
||||
@@ -674,10 +674,10 @@ select {
|
||||
|
||||
@keyframes nearby-radar-sweep {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,6 @@ import {
|
||||
hasSeenPostLocationNotice,
|
||||
markPostLocationNoticeSeen,
|
||||
} from "@/lib/postLocationNotice";
|
||||
import { canEditPostWithinWindow } from "@/lib/postEditWindow";
|
||||
import { buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import { fetchPostById } from "@/api/fetchPostById";
|
||||
import Cookies from "js-cookie";
|
||||
@@ -81,6 +80,10 @@ export default function NewPostPage() {
|
||||
const [selectedBillboards, setSelectedBillboards] = useState<
|
||||
SelectedPostLink[]
|
||||
>([]);
|
||||
const [selectedShops, setSelectedShops] = useState<SelectedPostLink[]>([]);
|
||||
const [selectedShopProducts, setSelectedShopProducts] = useState<
|
||||
SelectedPostLink[]
|
||||
>([]);
|
||||
const [storyOverlays, setStoryOverlays] = useState<StoryTextOverlay[]>([]);
|
||||
const [videoCover, setVideoCover] = useState<File | null>(null);
|
||||
const [existingVideoPath, setExistingVideoPath] = useState<string | null>(null);
|
||||
@@ -111,11 +114,6 @@ export default function NewPostPage() {
|
||||
const token = Cookies.get("token") || "";
|
||||
const post = await fetchPostById(editPostId, token);
|
||||
if (cancelled || !post) return;
|
||||
if (!canEditPostWithinWindow(post.createdAt)) {
|
||||
toast.error(t("posts.editPostExpired"));
|
||||
router.replace("/");
|
||||
return;
|
||||
}
|
||||
setMode(post.type === "video" ? "video" : "image");
|
||||
setDescription(post.caption || "");
|
||||
const imageFiles = (post.files || []).filter((f) => f.type !== "video");
|
||||
@@ -310,7 +308,9 @@ export default function NewPostPage() {
|
||||
selectedPackages,
|
||||
selectedProjects,
|
||||
selectedBillboards,
|
||||
captionLang
|
||||
captionLang,
|
||||
selectedShops,
|
||||
selectedShopProducts
|
||||
);
|
||||
|
||||
if (caption.length > POST_CAPTION_MAX_LENGTH) {
|
||||
@@ -440,6 +440,8 @@ export default function NewPostPage() {
|
||||
linked_course_id: selectedPackages[0]?._id ?? null,
|
||||
linked_project_id: selectedProjects[0]?._id ?? null,
|
||||
linked_billboard_id: selectedBillboards[0]?._id ?? null,
|
||||
linked_shop_id: selectedShops[0]?._id ?? null,
|
||||
linked_shop_product_id: selectedShopProducts[0]?._id ?? null,
|
||||
}, { noToast: true });
|
||||
toast.success(t("posts.postPublished"), { id: "post-upload" });
|
||||
router.push("/");
|
||||
@@ -490,7 +492,9 @@ export default function NewPostPage() {
|
||||
selectedPackages,
|
||||
selectedProjects,
|
||||
selectedBillboards,
|
||||
captionLang
|
||||
captionLang,
|
||||
selectedShops,
|
||||
selectedShopProducts
|
||||
);
|
||||
|
||||
const isStory = mode === "story";
|
||||
@@ -668,9 +672,13 @@ export default function NewPostPage() {
|
||||
selectedPackages={selectedPackages}
|
||||
selectedProjects={selectedProjects}
|
||||
selectedBillboards={selectedBillboards}
|
||||
selectedShops={selectedShops}
|
||||
selectedShopProducts={selectedShopProducts}
|
||||
onPackagesChange={setSelectedPackages}
|
||||
onProjectsChange={setSelectedProjects}
|
||||
onBillboardsChange={setSelectedBillboards}
|
||||
onShopsChange={setSelectedShops}
|
||||
onShopProductsChange={setSelectedShopProducts}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -90,8 +90,6 @@ const WalletDashboard = () => {
|
||||
`/academy/course/getAllAcademyPayments?status=success`
|
||||
);
|
||||
|
||||
console.log("پرداختهای موفق:", response?.data?.payments);
|
||||
|
||||
if (response?.data?.payments && Array.isArray(response.data.payments)) {
|
||||
const payments: Payment[] = response.data.payments;
|
||||
|
||||
@@ -115,7 +113,7 @@ const WalletDashboard = () => {
|
||||
}
|
||||
return [];
|
||||
} catch (err) {
|
||||
console.log("error fetching successful payments:", err);
|
||||
console.error("error fetching successful payments:", err);
|
||||
toast.error(t("settings.academy.wallet.fetchSalesError"));
|
||||
return [];
|
||||
}
|
||||
@@ -129,8 +127,6 @@ const WalletDashboard = () => {
|
||||
`/academy/course/getAllAcademyPayments?status=settled`
|
||||
);
|
||||
|
||||
console.log("پرداختهای تسویه شده:", response?.data?.payments);
|
||||
|
||||
if (response?.data?.payments && Array.isArray(response.data.payments)) {
|
||||
const payments: Payment[] = response.data.payments;
|
||||
|
||||
@@ -156,7 +152,7 @@ const WalletDashboard = () => {
|
||||
}
|
||||
return [];
|
||||
} catch (err) {
|
||||
console.log("error fetching settled payments:", err);
|
||||
console.error("error fetching settled payments:", err);
|
||||
toast.error(t("settings.academy.wallet.fetchSettledError"));
|
||||
return [];
|
||||
}
|
||||
@@ -193,7 +189,7 @@ const WalletDashboard = () => {
|
||||
setTotalBalance(balance);
|
||||
|
||||
} catch (err) {
|
||||
console.log("error fetching all data:", err);
|
||||
console.error("error fetching all data:", err);
|
||||
toast.error(t("settings.academy.wallet.fetchError"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
|
||||
@@ -5,10 +5,12 @@ import Container from "@/components/elements/Container";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { User } from "@/types/types";
|
||||
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import {
|
||||
getChatSocket,
|
||||
} from "@/lib/chat/socketClient";
|
||||
import MessageInput from "@/components/chat/MessageInput";
|
||||
import GiftInChatModal from "@/components/chat/GiftInChatModal";
|
||||
import MultiImageModal from "@/components/chat/MultiImageModal";
|
||||
import MediaPreviewModal from "@/components/chat/MediaPreviewModal";
|
||||
import ChatActionBar from "@/components/chat/ChatActionBar";
|
||||
@@ -61,8 +63,11 @@ function TicketChat({ params }: ITicketChatProps) {
|
||||
const { t } = useTranslation("common");
|
||||
const resolvedParams = React.use(params);
|
||||
const { request } = useAxios();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { username, id: chatPartnerId } = resolvedParams;
|
||||
const [newMessage, setNewMessage] = useState("");
|
||||
const [showGiftModal, setShowGiftModal] = useState(false);
|
||||
const [userTwoDetail, setUserTwoDetail] = useState<User>();
|
||||
const [pendingImages, setPendingImages] = useState<File[]>([]);
|
||||
const [showMultiModal, setShowMultiModal] = useState(false);
|
||||
@@ -228,6 +233,36 @@ function TicketChat({ params }: ITicketChatProps) {
|
||||
);
|
||||
};
|
||||
|
||||
const sendGiftMessage = useCallback(
|
||||
(gift: { _id: string; amount: number; message: string | null }) => {
|
||||
const content = JSON.stringify({
|
||||
sharedGift: {
|
||||
giftId: gift._id,
|
||||
amount: gift.amount,
|
||||
message: gift.message,
|
||||
},
|
||||
});
|
||||
void processSendMessage(null, undefined, content);
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[userTwoDetail, receiverId, chatPartnerId, selfDestructSeconds, viewOnceMedia]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const giftId = searchParams.get("giftId");
|
||||
if (!giftId) return;
|
||||
const giftAmount = Number(searchParams.get("giftAmount") || 0);
|
||||
const giftMessage = searchParams.get("giftMessage") || "";
|
||||
sendGiftMessage({ _id: giftId, amount: giftAmount, message: giftMessage || null });
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.delete("giftId");
|
||||
params.delete("giftAmount");
|
||||
params.delete("giftMessage");
|
||||
const query = params.toString();
|
||||
router.replace(`/settings/chats/${username}/${chatPartnerId}${query ? `?${query}` : ""}`);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const sendMessage = () => {
|
||||
if (pendingImages.length > 0) {
|
||||
void sendAllImages();
|
||||
@@ -692,10 +727,19 @@ function TicketChat({ params }: ITicketChatProps) {
|
||||
onSelfDestructChange={setSelfDestructSeconds}
|
||||
viewOnceMedia={viewOnceMedia}
|
||||
onViewOnceChange={setViewOnceMedia}
|
||||
onGiftClick={() => setShowGiftModal(true)}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<GiftInChatModal
|
||||
open={showGiftModal}
|
||||
onClose={() => setShowGiftModal(false)}
|
||||
receiverId={String(userTwoDetail?._id ?? receiverId ?? chatPartnerId)}
|
||||
returnPath={`/settings/chats/${username}/${chatPartnerId}`}
|
||||
onWalletGiftSent={sendGiftMessage}
|
||||
/>
|
||||
|
||||
<MultiImageModal
|
||||
isOpen={showMultiModal}
|
||||
files={pendingImages}
|
||||
|
||||
@@ -324,8 +324,7 @@ export default function NearbyFriendsPage() {
|
||||
{t("nearbyFriends.permissionDesc")}
|
||||
</p>
|
||||
<RoundedButton
|
||||
variant="primary"
|
||||
className="mt-4 w-full max-w-none"
|
||||
className="!border-transparent mt-4 h-9 w-full max-w-none !bg-sky-100 text-sky-600"
|
||||
onClick={requestBrowserLocation}
|
||||
>
|
||||
{t("nearbyFriends.retryLocation")}
|
||||
@@ -339,8 +338,7 @@ export default function NearbyFriendsPage() {
|
||||
{errorMessage || t("nearbyFriends.loadError")}
|
||||
</p>
|
||||
<RoundedButton
|
||||
variant="primary"
|
||||
className="mt-4 w-full max-w-none"
|
||||
className="!border-transparent mt-4 h-9 w-full max-w-none !bg-sky-100 text-sky-600"
|
||||
onClick={requestBrowserLocation}
|
||||
>
|
||||
{t("nearbyFriends.retryLocation")}
|
||||
@@ -398,7 +396,7 @@ export default function NearbyFriendsPage() {
|
||||
)}
|
||||
|
||||
<RoundedButton
|
||||
className="mx-auto mt-4 max-w-none"
|
||||
className="!border-transparent mx-auto mt-4 h-9 w-full max-w-none !bg-neutral-100 text-neutral-600 dark:!bg-neutral-800 dark:text-neutral-300"
|
||||
onClick={requestBrowserLocation}
|
||||
>
|
||||
{t("nearbyFriends.refresh")}
|
||||
|
||||
@@ -21,8 +21,9 @@ import BoldIcon from "@/components/ui/BoldIcon";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import { isUserOnline, resolveOnlineLabel } from "@/lib/chat/onlineStatus";
|
||||
import { isUserOnline } from "@/lib/chat/onlineStatus";
|
||||
import { staticIconUrl } from "@/components/main/BaseUrl";
|
||||
import { E2EE_LOCKED_PREVIEW } from "@/lib/e2ee";
|
||||
|
||||
export interface IMessage {
|
||||
first_name: string;
|
||||
@@ -143,7 +144,7 @@ function Chats() {
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-[#0095f6]/15">
|
||||
<BoldIcon name="messages-2" size={24} className="text-[#0095f6]" tinted />
|
||||
</div>
|
||||
<div className="min-w-0 flex flex-col gap-0.5">
|
||||
<div className="min-w-0 flex flex-col gap-1">
|
||||
<span className="truncate font-semibold">{t("chats.chatRoom")}</span>
|
||||
<span className="truncate text-[11px] text-neutral-500">
|
||||
{t("chats.chatRoomDesc")}
|
||||
@@ -166,7 +167,7 @@ function Chats() {
|
||||
className="dark:invert"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex flex-col gap-0.5">
|
||||
<div className="min-w-0 flex flex-col gap-1">
|
||||
<span className="truncate font-semibold">
|
||||
{t("nearbyFriends.title")}
|
||||
</span>
|
||||
@@ -203,6 +204,9 @@ function Chats() {
|
||||
<span className="truncate font-semibold">
|
||||
{item.display_name}
|
||||
</span>
|
||||
<span className="truncate text-[11px] text-neutral-400 dark:text-neutral-500">
|
||||
{t("chats.shopChatSubtitle")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col items-end gap-1 pr-2">
|
||||
@@ -255,7 +259,7 @@ function Chats() {
|
||||
aria-hidden
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex flex-col gap-0.5">
|
||||
<div className="min-w-0 flex flex-col gap-1">
|
||||
<span className="truncate font-semibold">
|
||||
{item.display_name ||
|
||||
`${item.first_name} ${item.last_name}`.trim()}
|
||||
@@ -266,23 +270,15 @@ function Chats() {
|
||||
</span>
|
||||
<VerificationBadge isVerified={item.is_verified} />
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-[11px]",
|
||||
isOnline
|
||||
? "text-[#22c55e]"
|
||||
: "text-neutral-400 dark:text-neutral-500"
|
||||
)}
|
||||
>
|
||||
{resolveOnlineLabel(
|
||||
item.last_online,
|
||||
onlineLabel,
|
||||
offlineLabel
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col items-end gap-1 pr-2">
|
||||
<span className="truncate text-[11px] text-neutral-400 dark:text-neutral-500">
|
||||
{isOnline ? onlineLabel : item.last_online || offlineLabel}
|
||||
</span>
|
||||
<span className="truncate text-[11px] text-neutral-400 dark:text-neutral-500">
|
||||
{E2EE_LOCKED_PREVIEW}
|
||||
</span>
|
||||
{item.unread_messages_count ? (
|
||||
<span className="rounded-full bg-[#387E65] px-2 py-0.5 text-xs text-white">
|
||||
{item.unread_messages_count}
|
||||
|
||||
@@ -3,19 +3,29 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import Container from "@/components/elements/Container";
|
||||
import Header from "@/components/main/Header";
|
||||
import ProfileAvatar from "@/components/main/ProfileAvatar";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
import ChatBoldIcon from "@/components/chat/ChatBoldIcon";
|
||||
import { chatHeaderCircleBtn, chatHeaderInfoPill } from "@/components/chat/ChatHeader";
|
||||
import VideoMessageBubble from "@/components/chat/VideoMessageBubble";
|
||||
import IOSSpinner from "@/components/ui/IOSSpinner";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
|
||||
import { formatBubbleTime } from "@/lib/chat/formatMessageTime";
|
||||
import {
|
||||
type BubbleGroupPosition,
|
||||
getBubbleRadius,
|
||||
getMessageRowSpacing,
|
||||
} from "@/lib/chat/messageGrouping";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type ShopChatMessage = {
|
||||
_id: string;
|
||||
senderRole: "shop" | "buyer";
|
||||
content: string;
|
||||
content: string | null;
|
||||
file?: string | null;
|
||||
fileType?: "image" | "video" | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
@@ -24,6 +34,24 @@ type Counterpart = {
|
||||
logo?: string | null;
|
||||
};
|
||||
|
||||
const MAX_VIDEO_SIZE_BYTES = 100 * 1024 * 1024;
|
||||
|
||||
function getGroupPosition(
|
||||
messages: ShopChatMessage[],
|
||||
index: number
|
||||
): BubbleGroupPosition {
|
||||
const current = messages[index];
|
||||
const sameAsPrev =
|
||||
index > 0 && messages[index - 1].senderRole === current.senderRole;
|
||||
const sameAsNext =
|
||||
index < messages.length - 1 &&
|
||||
messages[index + 1].senderRole === current.senderRole;
|
||||
if (!sameAsPrev && !sameAsNext) return "single";
|
||||
if (!sameAsPrev && sameAsNext) return "first";
|
||||
if (sameAsPrev && sameAsNext) return "middle";
|
||||
return "last";
|
||||
}
|
||||
|
||||
export default function ShopChatPage() {
|
||||
const { t } = useTranslation("common");
|
||||
const router = useRouter();
|
||||
@@ -37,6 +65,7 @@ export default function ShopChatPage() {
|
||||
const [text, setText] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const attachmentInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const loadMessages = () => {
|
||||
request<{ messages: ShopChatMessage[]; counterpart: Counterpart; isOwner: boolean }>(
|
||||
@@ -83,89 +112,199 @@ export default function ShopChatPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelected = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (!file) return;
|
||||
const fileType: "image" | "video" = file.type.startsWith("video/") ? "video" : "image";
|
||||
if (fileType === "video" && file.size > MAX_VIDEO_SIZE_BYTES) {
|
||||
alert(t("chats.toast.shopChatVideoTooLarge"));
|
||||
return;
|
||||
}
|
||||
setSending(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("buyerId", buyerId);
|
||||
formData.append("fileType", fileType);
|
||||
formData.append("file", file);
|
||||
const res = await request<{ message: ShopChatMessage }>(
|
||||
"POST",
|
||||
`/shop-chat/${shopId}/messages/file`,
|
||||
formData
|
||||
);
|
||||
if (res?.message) {
|
||||
setMessages((prev) => [...(prev || []), res.message]);
|
||||
}
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const myRole = isOwner ? "shop" : "buyer";
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<LocalePageShell>
|
||||
<Container className="pb-24">
|
||||
<div className="mx-auto flex h-[calc(100dvh-140px)] w-full max-w-md flex-col">
|
||||
<div className="flex shrink-0 items-center gap-3 border-b border-neutral-200 py-3 dark:border-neutral-700">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
aria-label={t("common.back")}
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center"
|
||||
>
|
||||
<BoldIcon name="arrow-right-3" size={20} className="block dark:invert" />
|
||||
</button>
|
||||
<ProfileAvatar
|
||||
src={counterpart?.logo}
|
||||
alt={counterpart?.name}
|
||||
size="xs"
|
||||
rounded="full"
|
||||
/>
|
||||
<span className="truncate text-sm font-semibold">
|
||||
<LocalePageShell>
|
||||
<header className="pointer-events-none fixed left-0 right-0 top-0 z-[100] bg-transparent px-2 pb-2 pt-[max(0.5rem,env(safe-area-inset-top))] sm:px-3">
|
||||
<div
|
||||
dir="rtl"
|
||||
className="pointer-events-auto mx-auto flex max-w-lg items-center gap-2"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
className={chatHeaderCircleBtn}
|
||||
aria-label={t("chats.actions.back")}
|
||||
>
|
||||
<ChatBoldIcon name="back" size={22} className="text-current" />
|
||||
</button>
|
||||
|
||||
<div dir="ltr" className={chatHeaderInfoPill}>
|
||||
<div dir="rtl" className="min-w-0 flex-1 text-right">
|
||||
<span className="block truncate text-[15px] font-semibold leading-tight text-neutral-900 dark:text-neutral-50">
|
||||
{counterpart?.name}
|
||||
</span>
|
||||
<span className="block truncate text-xs leading-tight text-neutral-500 dark:text-neutral-400">
|
||||
{t("chats.shopChatSubtitle")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto py-3">
|
||||
{messages === null ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<IOSSpinner />
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-neutral-500">
|
||||
{t("shops.noChatMessagesYet")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{messages.map((m) => {
|
||||
const mine = m.senderRole === myRole;
|
||||
return (
|
||||
<div
|
||||
key={m._id}
|
||||
className={cn(
|
||||
"max-w-[75%] rounded-2xl px-3 py-2 text-sm break-words",
|
||||
mine
|
||||
? "self-end bg-[#387E65] text-white"
|
||||
: "self-start bg-neutral-100 dark:bg-neutral-800"
|
||||
)}
|
||||
>
|
||||
{m.content}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2 border-t border-neutral-200 pt-3 dark:border-neutral-700">
|
||||
<input
|
||||
type="text"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void handleSend();
|
||||
}}
|
||||
placeholder={t("chats.placeholder.message")}
|
||||
className="flex-1 rounded-full border border-neutral-300 bg-transparent px-4 py-2 text-sm outline-none dark:border-neutral-600"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!text.trim() || sending}
|
||||
onClick={() => void handleSend()}
|
||||
className="shrink-0 rounded-full bg-[#387E65] px-4 py-2 text-xs font-bold text-white disabled:opacity-40"
|
||||
>
|
||||
{t("posts.send")}
|
||||
</button>
|
||||
</div>
|
||||
<ProfileAvatar
|
||||
src={counterpart?.logo}
|
||||
alt={counterpart?.name || ""}
|
||||
size="xs"
|
||||
rounded="full"
|
||||
className="h-[34px] w-[34px] shrink-0"
|
||||
/>
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
</>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Container className="!px-0">
|
||||
<div className="mx-auto flex h-[100dvh] max-h-[100dvh] w-full max-w-lg flex-col overflow-hidden">
|
||||
<div className="flex-1 overflow-y-auto px-3 pb-28 pt-[calc(3.75rem+env(safe-area-inset-top))]">
|
||||
{messages === null ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<IOSSpinner />
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-neutral-500">
|
||||
{t("shops.noChatMessagesYet")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
{messages.map((m, index) => {
|
||||
const mine = m.senderRole === myRole;
|
||||
const position = getGroupPosition(messages, index);
|
||||
const groupedWithPrev =
|
||||
index > 0 && messages[index - 1].senderRole === m.senderRole;
|
||||
const radius = getBubbleRadius(mine, position);
|
||||
return (
|
||||
<div
|
||||
key={m._id}
|
||||
dir="rtl"
|
||||
className={cn(
|
||||
"flex w-full",
|
||||
getMessageRowSpacing(position, groupedWithPrev),
|
||||
mine ? "justify-end" : "justify-start"
|
||||
)}
|
||||
>
|
||||
{m.fileType === "video" && m.file ? (
|
||||
<VideoMessageBubble
|
||||
src={`${IMAGE_BASE_URL}${m.file}`}
|
||||
isOutgoing={mine}
|
||||
borderRadius={radius}
|
||||
/>
|
||||
) : m.fileType === "image" && m.file ? (
|
||||
<div
|
||||
className="max-w-[min(280px,85vw)] overflow-hidden"
|
||||
style={{ borderRadius: radius }}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={`${IMAGE_BASE_URL}${m.file}`}
|
||||
alt=""
|
||||
className="max-h-96 w-full min-w-[200px] bg-black/5 object-cover"
|
||||
style={{ borderRadius: radius }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"chat-text-bubble max-w-[75%] px-3 py-2 text-[15px] leading-snug break-words",
|
||||
mine ? "chat-bubble-out" : "chat-bubble-in"
|
||||
)}
|
||||
style={{ borderRadius: radius }}
|
||||
>
|
||||
<p className="chat-message-text select-text">{m.content}</p>
|
||||
<span
|
||||
className={cn(
|
||||
"message-time-below mt-0.5 block text-[10px] tabular-nums",
|
||||
mine ? "text-left" : "text-right"
|
||||
)}
|
||||
>
|
||||
{formatBubbleTime(m.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
<div className="pointer-events-none fixed bottom-0 left-0 right-0 z-50 flex flex-col items-center bg-transparent px-3 sm:px-4">
|
||||
<div
|
||||
dir="ltr"
|
||||
className="pointer-events-auto relative flex w-full max-w-lg items-end gap-2 pb-5 sm:gap-2.5"
|
||||
>
|
||||
<input
|
||||
ref={attachmentInputRef}
|
||||
type="file"
|
||||
accept="image/*,video/*"
|
||||
className="hidden"
|
||||
onChange={(e) => void handleFileSelected(e)}
|
||||
/>
|
||||
<div className="relative flex shrink-0 items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
disabled={sending}
|
||||
onClick={() => attachmentInputRef.current?.click()}
|
||||
className={cn(chatHeaderCircleBtn, "gentle-transition disabled:opacity-40")}
|
||||
aria-label={t("chats.aria.attach")}
|
||||
>
|
||||
<ChatBoldIcon name="attachment" size={22} className="text-current" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="glass-chat-input gentle-transition flex min-h-11 flex-1 items-center rounded-[22px] px-4 py-2">
|
||||
<textarea
|
||||
rows={1}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
void handleSend();
|
||||
}
|
||||
}}
|
||||
placeholder={t("chats.placeholder.message")}
|
||||
className="min-h-[1.5rem] max-h-40 flex-1 resize-none overflow-y-auto bg-transparent text-[15px] leading-6 outline-none placeholder:text-neutral-400 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!text.trim() || sending}
|
||||
onClick={() => void handleSend()}
|
||||
className={cn(chatHeaderCircleBtn, "gentle-transition active:scale-90 disabled:opacity-40")}
|
||||
aria-label={t("chats.aria.send")}
|
||||
>
|
||||
<ChatBoldIcon name="send" size={20} className="text-current -mr-0.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
82
src/app/settings/edit/activity/academy-comments/page.tsx
Normal file
82
src/app/settings/edit/activity/academy-comments/page.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Container from "@/components/elements/Container";
|
||||
import PageTitle from "@/components/settings/PageTitle";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type CommentedCourse = {
|
||||
_id: string;
|
||||
comment?: string;
|
||||
course_id?: { _id: string; cuorse_name?: string } | null;
|
||||
};
|
||||
|
||||
function AcademyCommentsPage() {
|
||||
const { t } = useTranslation("common");
|
||||
const { request } = useAxios();
|
||||
const [items, setItems] = useState<CommentedCourse[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
request<{ data: { comments: CommentedCourse[] } }>(
|
||||
"GET",
|
||||
"/academy/user/comments?page=1&limit=50",
|
||||
null,
|
||||
{ noToast: true }
|
||||
)
|
||||
.then((res) => setItems(res?.data?.comments || []))
|
||||
.catch(() => setItems([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, [request]);
|
||||
|
||||
return (
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<PageTitle>{t("settings.activity.items.academyComments")}</PageTitle>
|
||||
<div className="my-6">
|
||||
{loading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-14 animate-pulse rounded-xl bg-neutral-200 dark:bg-neutral-800"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<p className="py-10 text-center text-sm text-neutral-500">
|
||||
{t("models.nothingFound")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{items
|
||||
.filter((item) => item.course_id)
|
||||
.map((item) => (
|
||||
<li key={item._id}>
|
||||
<Link
|
||||
href={`/explore/${item.course_id?._id}`}
|
||||
className="block rounded-xl px-2 py-3 hover:bg-neutral-100 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<p className="truncate text-sm font-bold">
|
||||
{item.course_id?.cuorse_name}
|
||||
</p>
|
||||
{item.comment ? (
|
||||
<p className="mt-0.5 truncate text-xs text-neutral-500">
|
||||
{item.comment}
|
||||
</p>
|
||||
) : null}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
export default AcademyCommentsPage;
|
||||
76
src/app/settings/edit/activity/academy-likes/page.tsx
Normal file
76
src/app/settings/edit/activity/academy-likes/page.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Container from "@/components/elements/Container";
|
||||
import PageTitle from "@/components/settings/PageTitle";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type LikedCourse = {
|
||||
_id: string;
|
||||
course_id?: { _id: string; cuorse_name?: string; price?: string } | null;
|
||||
};
|
||||
|
||||
function AcademyLikesPage() {
|
||||
const { t } = useTranslation("common");
|
||||
const { request } = useAxios();
|
||||
const [items, setItems] = useState<LikedCourse[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
request<{ data: LikedCourse[] }>(
|
||||
"GET",
|
||||
"/academy/user/liked-courses?page=1&limit=50",
|
||||
null,
|
||||
{ noToast: true }
|
||||
)
|
||||
.then((res) => setItems(res?.data || []))
|
||||
.catch(() => setItems([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, [request]);
|
||||
|
||||
return (
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<PageTitle>{t("settings.activity.items.academyLikes")}</PageTitle>
|
||||
<div className="my-6">
|
||||
{loading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-12 animate-pulse rounded-xl bg-neutral-200 dark:bg-neutral-800"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<p className="py-10 text-center text-sm text-neutral-500">
|
||||
{t("models.nothingFound")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{items
|
||||
.filter((item) => item.course_id)
|
||||
.map((item) => (
|
||||
<li key={item._id}>
|
||||
<Link
|
||||
href={`/explore/${item.course_id?._id}`}
|
||||
className="flex items-center justify-between rounded-xl px-2 py-3 hover:bg-neutral-100 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<span className="truncate text-sm font-bold">
|
||||
{item.course_id?.cuorse_name}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
export default AcademyLikesPage;
|
||||
82
src/app/settings/edit/activity/billboard-comments/page.tsx
Normal file
82
src/app/settings/edit/activity/billboard-comments/page.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Container from "@/components/elements/Container";
|
||||
import PageTitle from "@/components/settings/PageTitle";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type CommentedBillboard = {
|
||||
_id: string;
|
||||
text?: string;
|
||||
advertisingId?: { _id: string; title?: string } | null;
|
||||
};
|
||||
|
||||
function BillboardCommentsPage() {
|
||||
const { t } = useTranslation("common");
|
||||
const { request } = useAxios();
|
||||
const [items, setItems] = useState<CommentedBillboard[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
request<{ data: CommentedBillboard[] }>(
|
||||
"GET",
|
||||
"/advertising/user-comments?page=1&limit=50",
|
||||
null,
|
||||
{ noToast: true }
|
||||
)
|
||||
.then((res) => setItems(res?.data || []))
|
||||
.catch(() => setItems([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, [request]);
|
||||
|
||||
return (
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<PageTitle>{t("settings.activity.items.billboardComments")}</PageTitle>
|
||||
<div className="my-6">
|
||||
{loading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-14 animate-pulse rounded-xl bg-neutral-200 dark:bg-neutral-800"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<p className="py-10 text-center text-sm text-neutral-500">
|
||||
{t("models.nothingFound")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{items
|
||||
.filter((item) => item.advertisingId)
|
||||
.map((item) => (
|
||||
<li key={item._id}>
|
||||
<Link
|
||||
href={`/billboards/${item.advertisingId?._id}/x`}
|
||||
className="block rounded-xl px-2 py-3 hover:bg-neutral-100 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<p className="truncate text-sm font-bold">
|
||||
{item.advertisingId?.title}
|
||||
</p>
|
||||
{item.text ? (
|
||||
<p className="mt-0.5 truncate text-xs text-neutral-500">
|
||||
{item.text}
|
||||
</p>
|
||||
) : null}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
export default BillboardCommentsPage;
|
||||
76
src/app/settings/edit/activity/billboard-likes/page.tsx
Normal file
76
src/app/settings/edit/activity/billboard-likes/page.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Container from "@/components/elements/Container";
|
||||
import PageTitle from "@/components/settings/PageTitle";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type LikedBillboard = {
|
||||
_id: string;
|
||||
advertisingId?: { _id: string; title?: string } | null;
|
||||
};
|
||||
|
||||
function BillboardLikesPage() {
|
||||
const { t } = useTranslation("common");
|
||||
const { request } = useAxios();
|
||||
const [items, setItems] = useState<LikedBillboard[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
request<{ data: LikedBillboard[] }>(
|
||||
"GET",
|
||||
"/advertising/user-liked?page=1&limit=50",
|
||||
null,
|
||||
{ noToast: true }
|
||||
)
|
||||
.then((res) => setItems(res?.data || []))
|
||||
.catch(() => setItems([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, [request]);
|
||||
|
||||
return (
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<PageTitle>{t("settings.activity.items.billboardLikes")}</PageTitle>
|
||||
<div className="my-6">
|
||||
{loading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-12 animate-pulse rounded-xl bg-neutral-200 dark:bg-neutral-800"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<p className="py-10 text-center text-sm text-neutral-500">
|
||||
{t("models.nothingFound")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{items
|
||||
.filter((item) => item.advertisingId)
|
||||
.map((item) => (
|
||||
<li key={item._id}>
|
||||
<Link
|
||||
href={`/billboards/${item.advertisingId?._id}/x`}
|
||||
className="flex items-center justify-between rounded-xl px-2 py-3 hover:bg-neutral-100 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<span className="truncate text-sm font-bold">
|
||||
{item.advertisingId?.title}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
export default BillboardLikesPage;
|
||||
24
src/app/settings/edit/activity/comments/page.tsx
Normal file
24
src/app/settings/edit/activity/comments/page.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import PageTitle from "@/components/settings/PageTitle";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import ActivityPostGrid from "@/components/settings/ActivityPostGrid";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
function ActivityCommentsPage() {
|
||||
const { t } = useTranslation("common");
|
||||
|
||||
return (
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<PageTitle>{t("settings.activity.items.comments")}</PageTitle>
|
||||
<div className="my-6">
|
||||
<ActivityPostGrid endpoint="/posts/my-comments" />
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
export default ActivityCommentsPage;
|
||||
24
src/app/settings/edit/activity/likes/page.tsx
Normal file
24
src/app/settings/edit/activity/likes/page.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import PageTitle from "@/components/settings/PageTitle";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import ActivityPostGrid from "@/components/settings/ActivityPostGrid";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
function ActivityLikesPage() {
|
||||
const { t } = useTranslation("common");
|
||||
|
||||
return (
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<PageTitle>{t("settings.activity.items.likes")}</PageTitle>
|
||||
<div className="my-6">
|
||||
<ActivityPostGrid endpoint="/posts/my-likes" />
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
export default ActivityLikesPage;
|
||||
48
src/app/settings/edit/activity/page.tsx
Normal file
48
src/app/settings/edit/activity/page.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import PageTitle from "@/components/settings/PageTitle";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
|
||||
const ACTIVITY_ITEMS = [
|
||||
{ key: "likes", href: "/settings/edit/activity/likes" },
|
||||
{ key: "comments", href: "/settings/edit/activity/comments" },
|
||||
{ key: "academyLikes", href: "/settings/edit/activity/academy-likes" },
|
||||
{ key: "academyComments", href: "/settings/edit/activity/academy-comments" },
|
||||
{ key: "billboardLikes", href: "/settings/edit/activity/billboard-likes" },
|
||||
{ key: "billboardComments", href: "/settings/edit/activity/billboard-comments" },
|
||||
];
|
||||
|
||||
function ActivityHubPage() {
|
||||
const { t } = useTranslation("common");
|
||||
|
||||
return (
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<PageTitle>{t("settings.edit.nav.activity")}</PageTitle>
|
||||
<div className="my-6 flex flex-col gap-5 text-sm font-semibold md:gap-8">
|
||||
{ACTIVITY_ITEMS.map((item) => (
|
||||
<Link
|
||||
key={item.key}
|
||||
href={item.href}
|
||||
className="flex items-center justify-between gap-2"
|
||||
>
|
||||
<span>{t(`settings.activity.items.${item.key}`)}</span>
|
||||
<BoldIcon
|
||||
name="arrow-right-2"
|
||||
size={18}
|
||||
tinted
|
||||
className="text-neutral-400 rtl:rotate-180"
|
||||
/>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
export default ActivityHubPage;
|
||||
174
src/app/settings/edit/archive/page.tsx
Normal file
174
src/app/settings/edit/archive/page.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Container from "@/components/elements/Container";
|
||||
import PageTitle from "@/components/settings/PageTitle";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useUser } from "@/hooks/useUser";
|
||||
import { Post } from "@/types/types";
|
||||
import { getPostMedia } from "@/lib/explore/postMedia";
|
||||
import ExploreVideoThumb from "@/components/explore/ExploreVideoThumb";
|
||||
import { buildPostPath } from "@/lib/postSlug";
|
||||
import { cacheReelsSeedPost } from "@/lib/reelsSeedPost";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const ASPECT_RATIOS = [
|
||||
"aspect-[2/3]",
|
||||
"aspect-[3/4]",
|
||||
"aspect-[4/5]",
|
||||
"aspect-[5/6]",
|
||||
"aspect-square",
|
||||
"aspect-[3/5]",
|
||||
] as const;
|
||||
const SKELETON_COUNT = 8;
|
||||
|
||||
function hashAspect(postId: string): (typeof ASPECT_RATIOS)[number] {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < postId.length; i++) {
|
||||
hash = (hash + postId.charCodeAt(i)) % ASPECT_RATIOS.length;
|
||||
}
|
||||
return ASPECT_RATIOS[hash];
|
||||
}
|
||||
|
||||
function ArchiveCardSkeleton({ seed }: { seed: number }) {
|
||||
const aspect = ASPECT_RATIOS[seed % ASPECT_RATIOS.length];
|
||||
return (
|
||||
<div className={`mb-2 w-full break-inside-avoid overflow-hidden rounded-2xl bg-neutral-200 dark:bg-neutral-800 ${aspect} animate-pulse`} />
|
||||
);
|
||||
}
|
||||
|
||||
function ArchivePage() {
|
||||
const { t } = useTranslation("common");
|
||||
const { request } = useAxios();
|
||||
const user = useUser();
|
||||
const router = useRouter();
|
||||
const [posts, setPosts] = useState<Post[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [fetchingMore, setFetchingMore] = useState(false);
|
||||
|
||||
const loadPage = useCallback(
|
||||
async (pageNum: number) => {
|
||||
const res = await request<{ posts: Post[]; totalPages: number }>(
|
||||
"GET",
|
||||
`/account/archived-posts?page=${pageNum}&limit=12`,
|
||||
null,
|
||||
{ noToast: true }
|
||||
);
|
||||
return res;
|
||||
},
|
||||
[request]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
loadPage(1)
|
||||
.then((res) => {
|
||||
setPosts(res?.posts || []);
|
||||
setHasMore((res?.totalPages || 1) > 1);
|
||||
setPage(1);
|
||||
})
|
||||
.catch(() => setPosts([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, [loadPage]);
|
||||
|
||||
const fetchMore = useCallback(() => {
|
||||
if (fetchingMore || !hasMore) return;
|
||||
setFetchingMore(true);
|
||||
const nextPage = page + 1;
|
||||
loadPage(nextPage)
|
||||
.then((res) => {
|
||||
setPosts((prev) => [...prev, ...(res?.posts || [])]);
|
||||
setHasMore(nextPage < (res?.totalPages || 1));
|
||||
setPage(nextPage);
|
||||
})
|
||||
.catch(() => setHasMore(false))
|
||||
.finally(() => setFetchingMore(false));
|
||||
}, [fetchingMore, hasMore, page, loadPage]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
if (
|
||||
window.innerHeight + window.scrollY >=
|
||||
document.body.offsetHeight - 800
|
||||
) {
|
||||
fetchMore();
|
||||
}
|
||||
};
|
||||
window.addEventListener("scroll", handleScroll);
|
||||
return () => window.removeEventListener("scroll", handleScroll);
|
||||
}, [fetchMore]);
|
||||
|
||||
const openPost = (post: Post) => {
|
||||
const withAuthor: Post = {
|
||||
...post,
|
||||
user_name: user?.user_name || post.user_name,
|
||||
first_name: user?.first_name || post.first_name,
|
||||
last_name: user?.last_name || post.last_name,
|
||||
profile_image: user?.profile_image || post.profile_image,
|
||||
};
|
||||
cacheReelsSeedPost(withAuthor);
|
||||
router.push(buildPostPath(post._id, withAuthor));
|
||||
};
|
||||
|
||||
return (
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<PageTitle>{t("settings.edit.nav.archive")}</PageTitle>
|
||||
<div className="my-6">
|
||||
{loading ? (
|
||||
<div className="columns-2 gap-2">
|
||||
{Array.from({ length: SKELETON_COUNT }).map((_, i) => (
|
||||
<ArchiveCardSkeleton key={i} seed={i} />
|
||||
))}
|
||||
</div>
|
||||
) : posts.length === 0 ? (
|
||||
<p className="py-16 text-center text-sm text-neutral-500">
|
||||
{t("models.nothingFound")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="columns-2 gap-2">
|
||||
{posts.map((post) => {
|
||||
const media = getPostMedia(post);
|
||||
if (!media) return null;
|
||||
const aspect = hashAspect(post._id);
|
||||
return (
|
||||
<button
|
||||
key={post._id}
|
||||
type="button"
|
||||
onClick={() => openPost(post)}
|
||||
className={`gentle-transition relative mb-2 block w-full overflow-hidden rounded-2xl bg-neutral-200 text-right dark:bg-neutral-900 active:scale-[0.98] ${aspect}`}
|
||||
>
|
||||
{media.kind === "video" ? (
|
||||
<ExploreVideoThumb src={media.src} poster={media.poster} />
|
||||
) : (
|
||||
<Image
|
||||
src={media.src}
|
||||
alt=""
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="50vw"
|
||||
unoptimized
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{fetchingMore
|
||||
? Array.from({ length: 4 }).map((_, i) => (
|
||||
<ArchiveCardSkeleton key={`more-${i}`} seed={i} />
|
||||
))
|
||||
: null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
export default ArchivePage;
|
||||
103
src/app/settings/edit/blocked-users/page.tsx
Normal file
103
src/app/settings/edit/blocked-users/page.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Container from "@/components/elements/Container";
|
||||
import PageTitle from "@/components/settings/PageTitle";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type BlockedUser = {
|
||||
_id: string;
|
||||
user_name: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
profile_image?: string;
|
||||
};
|
||||
|
||||
function BlockedUsersPage() {
|
||||
const { t } = useTranslation("common");
|
||||
const { request } = useAxios();
|
||||
const [users, setUsers] = useState<BlockedUser[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
request<{ users: BlockedUser[] }>("GET", "/users/blocked/list", null, {
|
||||
noToast: true,
|
||||
})
|
||||
.then((res) => setUsers(res?.users || []))
|
||||
.catch(() => setUsers([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, [request]);
|
||||
|
||||
return (
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<PageTitle>{t("settings.edit.nav.blockedUsers")}</PageTitle>
|
||||
<div className="my-6">
|
||||
{loading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex animate-pulse items-center gap-3 rounded-xl px-2 py-2"
|
||||
>
|
||||
<div className="h-11 w-11 shrink-0 rounded-full bg-neutral-200 dark:bg-neutral-800" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-3 w-1/3 rounded bg-neutral-200 dark:bg-neutral-800" />
|
||||
<div className="h-3 w-1/4 rounded bg-neutral-200 dark:bg-neutral-800" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : users.length === 0 ? (
|
||||
<p className="py-10 text-center text-sm text-neutral-500">
|
||||
{t("models.nothingFound")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{users.map((u) => {
|
||||
const name =
|
||||
[u.first_name, u.last_name].filter(Boolean).join(" ") ||
|
||||
u.user_name;
|
||||
return (
|
||||
<li key={u._id}>
|
||||
<Link
|
||||
href={`/users/${u.user_name}`}
|
||||
className="flex items-center gap-3 rounded-xl px-2 py-2 hover:bg-neutral-100 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<div className="relative h-11 w-11 shrink-0 overflow-hidden rounded-full bg-neutral-200">
|
||||
<Image
|
||||
src={
|
||||
u.profile_image
|
||||
? buildStorageUrl(u.profile_image)
|
||||
: "/images/fake-avatar.png"
|
||||
}
|
||||
alt=""
|
||||
fill
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-bold">{name}</p>
|
||||
<p className="truncate text-xs text-neutral-500">
|
||||
@{u.user_name}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
export default BlockedUsersPage;
|
||||
@@ -9,6 +9,7 @@ import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import UserDetails from "@/components/settings/UserDetails";
|
||||
import AccountAnalyticsWidget from "@/components/settings/AccountAnalyticsWidget";
|
||||
import { staticIconUrl } from "@/components/main/BaseUrl";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -29,6 +30,9 @@ const EDIT_NAV_KEY: Record<string, string> = {
|
||||
"/public-relations": "publicRelations",
|
||||
"/shaba": "shaba",
|
||||
"/location": "location",
|
||||
"/blocked-users": "blockedUsers",
|
||||
"/archive": "archive",
|
||||
"/activity": "activity",
|
||||
};
|
||||
|
||||
function EditPage() {
|
||||
@@ -46,6 +50,7 @@ function EditPage() {
|
||||
<PageTitle>{t("settings.nav.edit")}</PageTitle>
|
||||
<div>
|
||||
<UserDetails />
|
||||
<AccountAnalyticsWidget />
|
||||
<div className="my-10 flex flex-col gap-5 text-sm font-semibold md:gap-8">
|
||||
{editUserNavLinks?.map((item) => (
|
||||
<Link
|
||||
|
||||
92
src/app/settings/gifts/page.tsx
Normal file
92
src/app/settings/gifts/page.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import GiftItem from "@/components/settings/gifts/GiftItem";
|
||||
import GiftReplyModal from "@/components/settings/gifts/GiftReplyModal";
|
||||
import PageTitle from "@/components/settings/PageTitle";
|
||||
import UserDetails from "@/components/settings/UserDetails";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import useInfiniteScroll from "@/hooks/useInfiniteScroll";
|
||||
import { IGift } from "@/types/types";
|
||||
import React, { useState } from "react";
|
||||
import { toggleBtnClass } from "@/lib/ui/buttonStyles";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const FILTER_SENT = "sent";
|
||||
const FILTER_RECEIVED = "received";
|
||||
|
||||
function GiftsPage() {
|
||||
const { t } = useTranslation("common");
|
||||
const [filter, setFilter] = useState<string>(FILTER_SENT);
|
||||
const [showReplyModal, setShowReplyModal] = useState(false);
|
||||
const [itemToReply, setItemToReply] = useState<IGift | null>(null);
|
||||
|
||||
const { data, isFetchingNextPage, refetch } = useInfiniteScroll({
|
||||
endpoint: "/gifts",
|
||||
queryKey: ["gifts", filter],
|
||||
params: { filter },
|
||||
});
|
||||
|
||||
const actionHandler = (item: IGift) => {
|
||||
if (filter === FILTER_RECEIVED) {
|
||||
setItemToReply(item);
|
||||
setShowReplyModal(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<PageTitle>{t("settings.gifts.title")}</PageTitle>
|
||||
<div className="text-xs md:text-sm">
|
||||
<UserDetails />
|
||||
<div className="mx-auto mt-8 grid w-full max-w-md grid-cols-2 gap-4">
|
||||
<RoundedButton
|
||||
className={cn(toggleBtnClass(filter === FILTER_SENT), "h-9")}
|
||||
onClick={() => setFilter(FILTER_SENT)}
|
||||
>
|
||||
{t("settings.gifts.given")}
|
||||
</RoundedButton>
|
||||
<RoundedButton
|
||||
className={cn(toggleBtnClass(filter === FILTER_RECEIVED), "h-9")}
|
||||
onClick={() => setFilter(FILTER_RECEIVED)}
|
||||
>
|
||||
{t("settings.gifts.received")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
<div className="mt-10 w-full">
|
||||
{data?.pages.length === 0 ||
|
||||
(data?.pages[0]?.gifts?.length === 0 && !isFetchingNextPage) ? (
|
||||
<p className="text-center text-gray-500">
|
||||
{t("settings.gifts.empty")}
|
||||
</p>
|
||||
) : (
|
||||
data?.pages?.map((page, pageIndex) => (
|
||||
<React.Fragment key={pageIndex}>
|
||||
{page?.gifts?.map((item: IGift) => (
|
||||
<GiftItem
|
||||
key={item?._id}
|
||||
item={item}
|
||||
mode={filter === FILTER_RECEIVED ? "received" : "sent"}
|
||||
actionHandler={actionHandler}
|
||||
/>
|
||||
))}
|
||||
</React.Fragment>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<GiftReplyModal
|
||||
isOpen={showReplyModal}
|
||||
onClose={() => setShowReplyModal(false)}
|
||||
item={itemToReply}
|
||||
onReplied={() => void refetch()}
|
||||
/>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
export default GiftsPage;
|
||||
@@ -62,13 +62,12 @@ export default function ShopSettingsPage() {
|
||||
|
||||
const { data: sellerOrdersData, isLoading: sellerOrdersLoading } = useInfiniteScroll({
|
||||
endpoint: "/orders",
|
||||
queryKey: ["seller-orders", primaryShopId || "pending", search],
|
||||
params: { role: "seller", shopId: primaryShopId || "", ...(search ? { q: search } : {}) },
|
||||
enabled: Boolean(primaryShopId),
|
||||
queryKey: ["seller-orders", search],
|
||||
params: { role: "seller", ...(search ? { q: search } : {}) },
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const sellerOrders: any[] =
|
||||
filter === FILTER_SELLER && primaryShopId
|
||||
filter === FILTER_SELLER
|
||||
? sellerOrdersData?.pages.flatMap((page) => page.docs || []) || []
|
||||
: [];
|
||||
|
||||
|
||||
@@ -16,11 +16,15 @@ import { useTranslation } from "react-i18next";
|
||||
const BUCKET_SHOP = "shop";
|
||||
const BUCKET_GIFT = "gift";
|
||||
const BUCKET_CHARGED = "charged";
|
||||
const BUCKET_ACADEMY = "academy";
|
||||
const BUCKET_PROJECT = "project";
|
||||
|
||||
type WalletSummary = {
|
||||
shop: { available: number; pending: number };
|
||||
gift: { available: number; minWithdrawal: number };
|
||||
charged: { available: number };
|
||||
academy: { available: number };
|
||||
project: { available: number };
|
||||
};
|
||||
|
||||
export default function WalletSettingsPage() {
|
||||
@@ -51,7 +55,9 @@ export default function WalletSettingsPage() {
|
||||
const totalBalance =
|
||||
(summary?.shop.available || 0) +
|
||||
(summary?.gift.available || 0) +
|
||||
(summary?.charged.available || 0);
|
||||
(summary?.charged.available || 0) +
|
||||
(summary?.academy.available || 0) +
|
||||
(summary?.project.available || 0);
|
||||
|
||||
const handleWithdraw = async () => {
|
||||
const amount = Number(withdrawAmount);
|
||||
@@ -120,6 +126,18 @@ export default function WalletSettingsPage() {
|
||||
>
|
||||
{t("shops.walletChargedTab")}
|
||||
</RoundedButton>
|
||||
<RoundedButton
|
||||
className={cn(toggleBtnClass(bucket === BUCKET_ACADEMY), "h-9 text-xs")}
|
||||
onClick={() => setBucket(BUCKET_ACADEMY)}
|
||||
>
|
||||
{t("shops.walletAcademyTab")}
|
||||
</RoundedButton>
|
||||
<RoundedButton
|
||||
className={cn(toggleBtnClass(bucket === BUCKET_PROJECT), "h-9 text-xs")}
|
||||
onClick={() => setBucket(BUCKET_PROJECT)}
|
||||
>
|
||||
{t("shops.walletProjectTab")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto mt-6 max-w-md text-sm">
|
||||
@@ -188,6 +206,60 @@ export default function WalletSettingsPage() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{bucket === BUCKET_ACADEMY && summary && (
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<span>{t("shops.academyBalance")}</span>
|
||||
<span className="font-bold">
|
||||
{summary.academy.available.toLocaleString()} {t("settings.toman")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<RoundedInput
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
placeholder={t("shops.withdrawAmountPlaceholder")}
|
||||
value={withdrawAmount}
|
||||
onChange={(e) => setWithdrawAmount(e.target.value)}
|
||||
/>
|
||||
<RoundedButton
|
||||
variant="primary"
|
||||
disabled={loading}
|
||||
onClick={handleWithdraw}
|
||||
>
|
||||
{t("shops.requestWithdrawal")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{bucket === BUCKET_PROJECT && summary && (
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<span>{t("shops.projectBalance")}</span>
|
||||
<span className="font-bold">
|
||||
{summary.project.available.toLocaleString()} {t("settings.toman")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<RoundedInput
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
placeholder={t("shops.withdrawAmountPlaceholder")}
|
||||
value={withdrawAmount}
|
||||
onChange={(e) => setWithdrawAmount(e.target.value)}
|
||||
/>
|
||||
<RoundedButton
|
||||
variant="primary"
|
||||
disabled={loading}
|
||||
onClick={handleWithdraw}
|
||||
>
|
||||
{t("shops.requestWithdrawal")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{bucket === BUCKET_CHARGED && summary && (
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
|
||||
@@ -35,6 +35,7 @@ function ProductNamePage() {
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [catalogProductId, setCatalogProductId] = useState<string | null>(null);
|
||||
const [sourceListingId, setSourceListingId] = useState<string | null>(null);
|
||||
const [suggestions, setSuggestions] = useState<CatalogProduct[]>([]);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
@@ -75,6 +76,7 @@ function ProductNamePage() {
|
||||
const handleNameChange = (value: string) => {
|
||||
setName(value);
|
||||
setCatalogProductId(null);
|
||||
setSourceListingId(null);
|
||||
};
|
||||
|
||||
const handlePick = (product: CatalogProduct) => {
|
||||
@@ -82,6 +84,15 @@ function ProductNamePage() {
|
||||
setDescription(product.description || "");
|
||||
setCatalogProductId(product._id);
|
||||
setSuggestions([]);
|
||||
setSourceListingId(null);
|
||||
request<{ listings: { _id: string }[] }>(
|
||||
"GET",
|
||||
`/shop-products/catalog/${product._id}/listings`,
|
||||
null,
|
||||
{ noToast: true }
|
||||
)
|
||||
.then((res) => setSourceListingId(res?.listings?.[0]?._id || null))
|
||||
.catch(() => setSourceListingId(null));
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
@@ -107,6 +118,7 @@ function ProductNamePage() {
|
||||
catalogProductId: catalogProductId || undefined,
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
cloneFromListingId: sourceListingId || undefined,
|
||||
}
|
||||
);
|
||||
if (response?.listing?._id) {
|
||||
@@ -157,6 +169,12 @@ function ProductNamePage() {
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
|
||||
{sourceListingId && (
|
||||
<p className="mt-3 w-full max-w-sm text-center text-xs text-neutral-500">
|
||||
{t("shops.prefillFromSimilarProductHint")}
|
||||
</p>
|
||||
)}
|
||||
</AuthPageContent>
|
||||
<AuthFormFooter>
|
||||
<AuthNextButton
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
type OrderDetail = {
|
||||
_id: string;
|
||||
createdAt: string;
|
||||
quantity: number;
|
||||
unit_price: number;
|
||||
total_amount: number;
|
||||
@@ -25,6 +26,10 @@ type OrderDetail = {
|
||||
images: string[];
|
||||
primaryImageIndex: number;
|
||||
};
|
||||
shop: {
|
||||
name: string;
|
||||
estimated_delivery_text?: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
export default function CheckoutPage() {
|
||||
@@ -72,6 +77,8 @@ export default function CheckoutPage() {
|
||||
.filter(Boolean)
|
||||
.join(" / ");
|
||||
|
||||
const itemsSubtotal = order.total_amount - order.shipping_cost;
|
||||
|
||||
return (
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
@@ -92,38 +99,43 @@ export default function CheckoutPage() {
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold">{order.listing.title}</p>
|
||||
<p className="truncate text-sm font-semibold">
|
||||
{order.listing.title}{" "}
|
||||
<span className="font-normal text-neutral-500">× {order.quantity}</span>
|
||||
</p>
|
||||
{variantLabel && (
|
||||
<p className="text-xs text-neutral-500">{variantLabel}</p>
|
||||
)}
|
||||
<p className="text-xs text-neutral-500">
|
||||
{t("shops.quantityLabel")}: {order.quantity}
|
||||
</p>
|
||||
<p className="text-xs text-neutral-500">{order.shop.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span>{t("shops.itemsTotal")}</span>
|
||||
<div className="rounded-2xl border border-neutral-200 p-4 text-sm dark:border-neutral-700">
|
||||
<div className="flex items-center justify-between border-b border-neutral-100 py-2 dark:border-neutral-800">
|
||||
<span className="text-neutral-500">{t("shops.itemsTotal")}</span>
|
||||
<span>
|
||||
{order.total_amount.toLocaleString()} {t("settings.toman")}
|
||||
{itemsSubtotal.toLocaleString()} {t("settings.toman")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-b border-neutral-100 py-2 dark:border-neutral-800">
|
||||
<span className="text-neutral-500">{t("shops.shippingTimeLabel")}</span>
|
||||
<span>{order.shop.estimated_delivery_text || "—"}</span>
|
||||
</div>
|
||||
{order.shipping_method && (
|
||||
<div className="flex justify-between text-neutral-500">
|
||||
<span>
|
||||
{t("shops.shippingPaidAtDelivery", {
|
||||
method: t(`shops.shippingMethods.${order.shipping_method}`),
|
||||
})}
|
||||
</span>
|
||||
<span>
|
||||
{order.shipping_cost.toLocaleString()} {t("settings.toman")}
|
||||
</span>
|
||||
<div className="flex items-center justify-between border-b border-neutral-100 py-2 dark:border-neutral-800">
|
||||
<span className="text-neutral-500">{t("shops.shippingTypeLabel")}</span>
|
||||
<span>{t(`shops.shippingMethods.${order.shipping_method}`)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2 flex justify-between border-t border-neutral-200 pt-2 font-bold dark:border-neutral-700">
|
||||
<span>{t("shops.payOnlineTotal")}</span>
|
||||
<div className="flex items-center justify-between border-b border-neutral-100 py-2 dark:border-neutral-800">
|
||||
<span className="text-neutral-500">{t("shops.shippingCostLabel")}</span>
|
||||
<span>
|
||||
{order.shipping_cost.toLocaleString()} {t("settings.toman")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<span className="font-semibold">{t("shops.payOnlineTotal")}</span>
|
||||
<span className="font-bold text-green-600">
|
||||
{order.total_amount.toLocaleString()} {t("settings.toman")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -27,7 +27,11 @@ export default function ShopDiscoverPage() {
|
||||
<LocalePageShell>
|
||||
<Container className="pb-28">
|
||||
<PageTitle>{t("shops.discoverTitle")}</PageTitle>
|
||||
<ShopProductGrid listings={listings} isLoading={isLoading} />
|
||||
<ShopProductGrid
|
||||
listings={listings}
|
||||
isLoading={isLoading}
|
||||
getHref={(listing) => `/shops/discover/reel/${listing._id}`}
|
||||
/>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
<TabNavigation currentPage="/shops/discover" />
|
||||
|
||||
36
src/app/shops/discover/reel/[listingId]/page.tsx
Normal file
36
src/app/shops/discover/reel/[listingId]/page.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import ShopReelsView from "@/components/shops/ShopReelsView";
|
||||
import PageLoader from "@/components/ui/PageLoader";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { IShopProductListing } from "@/types/types";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export default function ShopDiscoverReelPage() {
|
||||
const params = useParams<{ listingId: string }>();
|
||||
const { request } = useAxios();
|
||||
const [listings, setListings] = useState<IShopProductListing[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
request<{ docs: IShopProductListing[] }>(
|
||||
"GET",
|
||||
"/shop-products/discover?limit=100"
|
||||
)
|
||||
.then((res) => setListings(res?.docs || []))
|
||||
.catch(() => setListings([]));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
if (listings === null) {
|
||||
return <PageLoader className="min-h-[100dvh] bg-black" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ShopReelsView
|
||||
listings={listings}
|
||||
initialListingId={params.listingId}
|
||||
backHref="/shops/discover"
|
||||
/>
|
||||
);
|
||||
}
|
||||
487
src/app/shops/listing/[listingId]/ListingDetailClient.tsx
Normal file
487
src/app/shops/listing/[listingId]/ListingDetailClient.tsx
Normal file
@@ -0,0 +1,487 @@
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import Header from "@/components/main/Header";
|
||||
import { IMAGE_BASE_URL, staticIconUrl } from "@/components/main/BaseUrl";
|
||||
import ProductImageLightbox from "@/components/shops/ProductImageLightbox";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
import TabNavigation from "@/components/TabNavigation";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { addToCart, isFavorite, toggleFavorite } from "@/lib/shops/localCart";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { IShopProductListing, IShopProductVariant } from "@/types/types";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type ShippingMethod = { method: string; cost: number; enabled: boolean };
|
||||
type ShopHeader = {
|
||||
_id: string;
|
||||
name: string;
|
||||
logo?: string | null;
|
||||
shipping_methods?: ShippingMethod[];
|
||||
};
|
||||
|
||||
function uniqueValues(values: (string | null | undefined)[]): string[] {
|
||||
return Array.from(new Set(values.filter((v): v is string => Boolean(v))));
|
||||
}
|
||||
|
||||
function SpecRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex w-full items-center gap-2 rounded-3xl border border-neutral-200 px-4 py-3 dark:border-neutral-700">
|
||||
<span className="shrink-0 text-sm font-bold">{label}</span>
|
||||
<div className="flex flex-1 flex-wrap items-center justify-end gap-2 text-right">
|
||||
{children}
|
||||
</div>
|
||||
<BoldIcon name="arrow-down" size={16} tinted className="shrink-0 text-neutral-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ListingDetailClient() {
|
||||
const { t } = useTranslation("common");
|
||||
const router = useRouter();
|
||||
const params = useParams<{ listingId: string }>();
|
||||
const { request, loading } = useAxios();
|
||||
|
||||
const [listing, setListing] = useState<IShopProductListing | null>(null);
|
||||
const [selectedColor, setSelectedColor] = useState<string | null>(null);
|
||||
const [selectedSize, setSelectedSize] = useState<string | null>(null);
|
||||
const [selectedWeight, setSelectedWeight] = useState<string | null>(null);
|
||||
const [shippingMethod, setShippingMethod] = useState<string>("");
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const [activeImageIndex, setActiveImageIndex] = useState(0);
|
||||
const [descriptionExpanded, setDescriptionExpanded] = useState(false);
|
||||
const [showAddressModal, setShowAddressModal] = useState(false);
|
||||
const [showLightbox, setShowLightbox] = useState(false);
|
||||
const [favorite, setFavorite] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
request<{ listing: IShopProductListing }>("GET", `/shop-products/${params.listingId}`).then(
|
||||
(res) => {
|
||||
const data = res?.listing || null;
|
||||
setListing(data);
|
||||
const firstVariant = data?.variants?.[0];
|
||||
if (firstVariant) {
|
||||
setSelectedColor(firstVariant.color || null);
|
||||
setSelectedSize(firstVariant.size || null);
|
||||
setSelectedWeight(firstVariant.weight || null);
|
||||
}
|
||||
if (data) setFavorite(isFavorite(data._id));
|
||||
}
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [params.listingId]);
|
||||
|
||||
const shopInfo: ShopHeader | null =
|
||||
listing && typeof listing.shop === "object" ? (listing.shop as ShopHeader) : null;
|
||||
|
||||
const colors = useMemo(
|
||||
() => uniqueValues(listing?.variants.map((v) => v.color) || []),
|
||||
[listing]
|
||||
);
|
||||
const sizes = useMemo(() => {
|
||||
if (!listing) return [];
|
||||
const relevant = listing.variants.filter(
|
||||
(v) => colors.length === 0 || v.color === selectedColor
|
||||
);
|
||||
return uniqueValues(relevant.map((v) => v.size));
|
||||
}, [listing, colors, selectedColor]);
|
||||
const weights = useMemo(() => {
|
||||
if (!listing) return [];
|
||||
const relevant = listing.variants.filter(
|
||||
(v) =>
|
||||
(colors.length === 0 || v.color === selectedColor) &&
|
||||
(sizes.length === 0 || v.size === selectedSize)
|
||||
);
|
||||
return uniqueValues(relevant.map((v) => v.weight));
|
||||
}, [listing, colors, sizes, selectedColor, selectedSize]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sizes.length > 0 && (!selectedSize || !sizes.includes(selectedSize))) {
|
||||
setSelectedSize(sizes[0]);
|
||||
}
|
||||
}, [sizes, selectedSize]);
|
||||
|
||||
useEffect(() => {
|
||||
if (weights.length > 0 && (!selectedWeight || !weights.includes(selectedWeight))) {
|
||||
setSelectedWeight(weights[0]);
|
||||
}
|
||||
}, [weights, selectedWeight]);
|
||||
|
||||
const matchedVariant: IShopProductVariant | null = useMemo(() => {
|
||||
if (!listing) return null;
|
||||
return (
|
||||
listing.variants.find(
|
||||
(v) =>
|
||||
(colors.length === 0 || v.color === selectedColor) &&
|
||||
(sizes.length === 0 || v.size === selectedSize) &&
|
||||
(weights.length === 0 || v.weight === selectedWeight)
|
||||
) ||
|
||||
listing.variants[0] ||
|
||||
null
|
||||
);
|
||||
}, [listing, colors, sizes, weights, selectedColor, selectedSize, selectedWeight]);
|
||||
|
||||
useEffect(() => {
|
||||
setQuantity(1);
|
||||
}, [matchedVariant?._id]);
|
||||
|
||||
const enabledShippingMethods = shopInfo?.shipping_methods?.filter((m) => m.enabled) || [];
|
||||
|
||||
const categoryLine = listing
|
||||
? [listing.category, listing.sub_category, listing.sub_sub_category]
|
||||
.filter(Boolean)
|
||||
.join(" / ")
|
||||
: "";
|
||||
|
||||
const discountPercent =
|
||||
matchedVariant?.discount_price && matchedVariant.discount_price < matchedVariant.price
|
||||
? Math.round(
|
||||
((matchedVariant.price - matchedVariant.discount_price) / matchedVariant.price) * 100
|
||||
)
|
||||
: null;
|
||||
|
||||
const handleImageScroll = (event: React.UIEvent<HTMLDivElement>) => {
|
||||
const el = event.currentTarget;
|
||||
const index = Math.round(el.scrollLeft / el.clientWidth);
|
||||
setActiveImageIndex(index);
|
||||
};
|
||||
|
||||
const handleBuy = async () => {
|
||||
if (!listing || !matchedVariant) return;
|
||||
if (enabledShippingMethods.length > 0 && !shippingMethod) {
|
||||
toast.error(t("shops.shippingMethodRequired"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await request<{ order: { _id: string } }>("POST", "/orders/draft", {
|
||||
listingId: listing._id,
|
||||
variantId: matchedVariant._id,
|
||||
quantity,
|
||||
shippingMethod: shippingMethod || undefined,
|
||||
});
|
||||
if (response?.order?._id) {
|
||||
router.push(`/shops/checkout/${response.order._id}`);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const data = (err as { response?: { data?: { code?: string; message?: string } } })
|
||||
?.response?.data;
|
||||
if (data?.code === "ADDRESS_REQUIRED") {
|
||||
setShowAddressModal(true);
|
||||
return;
|
||||
}
|
||||
toast.error(data?.message || t("shops.unknownError"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddToCart = () => {
|
||||
if (!listing || !matchedVariant) return;
|
||||
addToCart(listing._id, matchedVariant._id, quantity);
|
||||
toast.success(t("shops.addToCartSuccess"));
|
||||
};
|
||||
|
||||
const handleToggleFavorite = () => {
|
||||
if (!listing) return;
|
||||
const next = toggleFavorite(listing._id);
|
||||
setFavorite(next);
|
||||
toast.success(next ? t("shops.addedToFavorites") : t("shops.removedFromFavorites"));
|
||||
};
|
||||
|
||||
if (!listing) return null;
|
||||
|
||||
const images = listing.images || [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<LocalePageShell>
|
||||
<Container className="pb-28">
|
||||
{shopInfo?._id ? (
|
||||
<Link
|
||||
href={`/shops/profile/${shopInfo._id}`}
|
||||
className="flex items-center justify-end gap-2"
|
||||
>
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-full bg-neutral-100 dark:bg-neutral-800">
|
||||
{shopInfo?.logo ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={IMAGE_BASE_URL + shopInfo.logo}
|
||||
alt={shopInfo.name}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
src={staticIconUrl("/images/icons/vuesax/bold/shop.svg")}
|
||||
width={18}
|
||||
height={18}
|
||||
alt=""
|
||||
className="dark:invert"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-sm font-bold">{shopInfo?.name}</span>
|
||||
</Link>
|
||||
) : null}
|
||||
|
||||
<div className="mx-auto mt-4 flex w-full max-w-md flex-col gap-4 rounded-3xl border border-neutral-200 p-4 dark:border-neutral-700">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h1 className="text-right text-xl font-bold">{listing.title}</h1>
|
||||
{discountPercent != null && (
|
||||
<span className="relative flex h-11 w-11 shrink-0 items-center justify-center">
|
||||
<span className="absolute inset-0 rounded-lg bg-red-500" />
|
||||
<span className="absolute inset-0 rotate-[22.5deg] rounded-lg bg-red-500" />
|
||||
<span className="relative z-10 text-sm font-extrabold text-white">
|
||||
{t("shops.discountPercentBadge", { percent: discountPercent })}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{images.length > 0 && (
|
||||
<div className="relative">
|
||||
<div
|
||||
onScroll={handleImageScroll}
|
||||
className="flex w-full snap-x snap-mandatory gap-0 overflow-x-auto rounded-2xl [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
>
|
||||
{images.map((img) => (
|
||||
<button
|
||||
key={img}
|
||||
type="button"
|
||||
onClick={() => setShowLightbox(true)}
|
||||
className="relative aspect-[4/3] w-full shrink-0 snap-center overflow-hidden bg-neutral-200 dark:bg-neutral-800"
|
||||
>
|
||||
<Image
|
||||
src={IMAGE_BASE_URL + img}
|
||||
alt={listing.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="400px"
|
||||
unoptimized
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{images.length > 1 && (
|
||||
<span className="absolute bottom-2 left-1/2 -translate-x-1/2 rounded-full bg-black/50 px-2 py-0.5 text-[10px] font-bold text-white">
|
||||
{activeImageIndex + 1}/{images.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-sm font-bold">{t("shops.specsTitle")}</p>
|
||||
|
||||
{categoryLine && (
|
||||
<SpecRow label={t("shops.categorySpecLabel")}>
|
||||
<span className="text-xs">{categoryLine}</span>
|
||||
</SpecRow>
|
||||
)}
|
||||
|
||||
{colors.length > 0 && (
|
||||
<SpecRow label={t("shops.colorLabel")}>
|
||||
{colors.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
onClick={() => setSelectedColor(color)}
|
||||
className={cn(
|
||||
"rounded-full border px-2.5 py-1 text-xs",
|
||||
selectedColor === color
|
||||
? "border-pink-500 bg-pink-500 text-white"
|
||||
: "border-neutral-300 dark:border-neutral-600"
|
||||
)}
|
||||
>
|
||||
{color}
|
||||
</button>
|
||||
))}
|
||||
</SpecRow>
|
||||
)}
|
||||
|
||||
{sizes.length > 0 && (
|
||||
<SpecRow label={t("shops.sizeLabel")}>
|
||||
{sizes.map((size) => (
|
||||
<button
|
||||
key={size}
|
||||
type="button"
|
||||
onClick={() => setSelectedSize(size)}
|
||||
className={cn(
|
||||
"rounded-full border px-2.5 py-1 text-xs",
|
||||
selectedSize === size
|
||||
? "border-pink-500 bg-pink-500 text-white"
|
||||
: "border-neutral-300 dark:border-neutral-600"
|
||||
)}
|
||||
>
|
||||
{size}
|
||||
</button>
|
||||
))}
|
||||
</SpecRow>
|
||||
)}
|
||||
|
||||
{weights.length > 0 && (
|
||||
<SpecRow label={t("shops.weightLabel")}>
|
||||
{weights.map((weight) => (
|
||||
<button
|
||||
key={weight}
|
||||
type="button"
|
||||
onClick={() => setSelectedWeight(weight)}
|
||||
className={cn(
|
||||
"rounded-full border px-2.5 py-1 text-xs",
|
||||
selectedWeight === weight
|
||||
? "border-pink-500 bg-pink-500 text-white"
|
||||
: "border-neutral-300 dark:border-neutral-600"
|
||||
)}
|
||||
>
|
||||
{weight}
|
||||
</button>
|
||||
))}
|
||||
</SpecRow>
|
||||
)}
|
||||
|
||||
{enabledShippingMethods.length > 0 && (
|
||||
<SpecRow
|
||||
label={`${t("shops.shippingTitle")} *`}
|
||||
>
|
||||
{enabledShippingMethods.map((m) => (
|
||||
<button
|
||||
key={m.method}
|
||||
type="button"
|
||||
onClick={() => setShippingMethod(m.method)}
|
||||
className={cn(
|
||||
"rounded-full border px-2.5 py-1 text-xs",
|
||||
shippingMethod === m.method
|
||||
? "border-pink-500 bg-pink-500 text-white"
|
||||
: "border-neutral-300 dark:border-neutral-600"
|
||||
)}
|
||||
>
|
||||
{t(`shops.shippingMethods.${m.method}`)}
|
||||
</button>
|
||||
))}
|
||||
</SpecRow>
|
||||
)}
|
||||
|
||||
{listing.description && (
|
||||
<SpecRow label={t("shops.descriptionLabel")}>
|
||||
<span className="text-xs leading-relaxed">
|
||||
{descriptionExpanded || listing.description.length <= 50
|
||||
? listing.description
|
||||
: `${listing.description.slice(0, 50)}...`}
|
||||
{listing.description.length > 50 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDescriptionExpanded((v) => !v)}
|
||||
className="mr-1 font-bold text-pink-500"
|
||||
>
|
||||
{descriptionExpanded ? t("shops.showLess") : t("shops.showMore")}
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</SpecRow>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleBuy()}
|
||||
disabled={loading || !matchedVariant}
|
||||
className="flex-1 rounded-3xl bg-green-600 py-2.5 text-center text-sm font-bold text-white disabled:opacity-50"
|
||||
>
|
||||
{t("shops.placeOrderButton")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuantity((q) => Math.max(1, q - 1))}
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-green-600 text-lg font-bold text-white"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span className="w-8 shrink-0 text-center text-sm font-bold">{quantity}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setQuantity((q) =>
|
||||
matchedVariant ? Math.min(matchedVariant.stock, q + 1) : q + 1
|
||||
)
|
||||
}
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-green-600 text-lg font-bold text-white"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{matchedVariant && (
|
||||
<SpecRow label={t("shops.priceSpecLabel")}>
|
||||
{matchedVariant.discount_price ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="text-xs text-neutral-400 line-through">
|
||||
{(matchedVariant.price * quantity).toLocaleString()} {t("settings.toman")}
|
||||
</span>
|
||||
<span className="text-sm font-bold text-red-500">
|
||||
{(matchedVariant.discount_price * quantity).toLocaleString()} {t("settings.toman")}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm font-bold">
|
||||
{(matchedVariant.price * quantity).toLocaleString()} {t("settings.toman")}
|
||||
</span>
|
||||
)}
|
||||
</SpecRow>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mx-auto mb-6 mt-8 flex w-full max-w-md gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddToCart}
|
||||
className="flex-1 rounded-3xl bg-green-600 py-3 text-sm font-bold text-white"
|
||||
>
|
||||
{t("shops.addToCartButton")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggleFavorite}
|
||||
className="flex-1 rounded-3xl bg-sky-500 py-3 text-sm font-bold text-white"
|
||||
>
|
||||
{favorite ? t("shops.removeFromFavoritesButton") : t("shops.addToFavoritesButton")}
|
||||
</button>
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
<TabNavigation currentPage="/settings" />
|
||||
|
||||
{showAddressModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="w-full max-w-sm rounded-2xl bg-white p-6 text-center dark:bg-neutral-900">
|
||||
<p className="mb-4 text-sm">{t("shops.addressRequiredHint")}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push("/settings/edit/location")}
|
||||
className="w-full rounded-3xl bg-pink-500 py-2.5 text-sm font-bold text-white"
|
||||
>
|
||||
{t("shops.goToLocationSettings")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-3 block w-full text-xs text-neutral-500"
|
||||
onClick={() => setShowAddressModal(false)}
|
||||
>
|
||||
{t("auth.skip")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ProductImageLightbox
|
||||
images={images}
|
||||
initialIndex={activeImageIndex}
|
||||
alt={listing.title}
|
||||
open={showLightbox}
|
||||
onClose={() => setShowLightbox(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,475 +1,86 @@
|
||||
"use client";
|
||||
import type { Metadata } from "next";
|
||||
import { fetchApiJson } from "@/lib/api/fetchApiJson";
|
||||
import { generatePageMetadata } from "@/utils/generatePageMetadata";
|
||||
import { getServerLanguage } from "@/lib/i18n/server";
|
||||
import { getSiteSeoMeta } from "@/lib/i18n/seo";
|
||||
import { buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import { IShopProductListing } from "@/types/types";
|
||||
import ListingDetailClient from "./ListingDetailClient";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import Header from "@/components/main/Header";
|
||||
import { IMAGE_BASE_URL, staticIconUrl } from "@/components/main/BaseUrl";
|
||||
import ProductImageLightbox from "@/components/shops/ProductImageLightbox";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
import TabNavigation from "@/components/TabNavigation";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { addToCart, isFavorite, toggleFavorite } from "@/lib/shops/localCart";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { IShopProductListing, IShopProductVariant } from "@/types/types";
|
||||
import Image from "next/image";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
interface IListingPageProps {
|
||||
params: Promise<{ listingId: string }>;
|
||||
}
|
||||
|
||||
type ShippingMethod = { method: string; cost: number; enabled: boolean };
|
||||
type ShopHeader = {
|
||||
_id: string;
|
||||
name: string;
|
||||
logo?: string | null;
|
||||
shipping_methods?: ShippingMethod[];
|
||||
};
|
||||
type ShopInfo = { _id: string; name: string; logo?: string | null };
|
||||
|
||||
type ListingResponse = { listing?: IShopProductListing & { shop?: ShopInfo | string } };
|
||||
|
||||
function uniqueValues(values: (string | null | undefined)[]): string[] {
|
||||
return Array.from(new Set(values.filter((v): v is string => Boolean(v))));
|
||||
}
|
||||
|
||||
function SpecRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex w-full items-center gap-2 rounded-3xl border border-neutral-200 px-4 py-3 dark:border-neutral-700">
|
||||
<span className="shrink-0 text-sm font-bold">{label}</span>
|
||||
<div className="flex flex-1 flex-wrap items-center justify-end gap-2 text-right">
|
||||
{children}
|
||||
</div>
|
||||
<BoldIcon name="arrow-down" size={16} tinted className="shrink-0 text-neutral-400" />
|
||||
</div>
|
||||
async function loadListing(listingId: string) {
|
||||
const { data } = await fetchApiJson<ListingResponse>(`/shop-products/${listingId}`);
|
||||
return data?.listing ?? null;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: IListingPageProps): Promise<Metadata> {
|
||||
const { listingId } = await params;
|
||||
const lang = await getServerLanguage();
|
||||
const site = getSiteSeoMeta(lang);
|
||||
const path = `/shops/listing/${listingId}`;
|
||||
|
||||
const listing = await loadListing(listingId);
|
||||
const shop =
|
||||
listing?.shop && typeof listing.shop === "object" ? listing.shop : null;
|
||||
|
||||
if (!listing) {
|
||||
const meta = generatePageMetadata({
|
||||
title: `کالا یافت نشد | ${site.titleSuffix}`,
|
||||
description: site.defaultDescription,
|
||||
path,
|
||||
index: false,
|
||||
lang,
|
||||
});
|
||||
return { ...meta, title: { absolute: `کالا یافت نشد | ${site.titleSuffix}` } };
|
||||
}
|
||||
|
||||
const title = `${listing.title}${shop?.name ? ` - در فروشگاه (${shop.name})` : ""} | ${site.titleSuffix}`;
|
||||
|
||||
const colors = uniqueValues(listing.variants?.map((v) => v.color));
|
||||
const sizes = uniqueValues(listing.variants?.map((v) => v.size));
|
||||
const weights = uniqueValues(listing.variants?.map((v) => v.weight));
|
||||
const prices = (listing.variants || []).map((v) =>
|
||||
v.discount_price != null ? v.discount_price : v.price
|
||||
);
|
||||
const cheapest = prices.length ? Math.min(...prices) : null;
|
||||
const priceText = cheapest != null ? `${cheapest.toLocaleString("fa-IR")} تومان` : "";
|
||||
|
||||
const description = [
|
||||
listing.description,
|
||||
colors.length ? colors.join("، ") : null,
|
||||
sizes.length ? sizes.join("، ") : null,
|
||||
weights.length ? weights.join("، ") : null,
|
||||
priceText || null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" - ") || site.defaultDescription;
|
||||
|
||||
const primaryImage = listing.images?.[listing.primaryImageIndex] || listing.images?.[0];
|
||||
|
||||
const meta = generatePageMetadata({
|
||||
title,
|
||||
description,
|
||||
path,
|
||||
type: "website",
|
||||
imageUrl: primaryImage ? buildStorageUrl(primaryImage) : undefined,
|
||||
imageAlt: listing.title,
|
||||
lang,
|
||||
});
|
||||
|
||||
return { ...meta, title: { absolute: title } };
|
||||
}
|
||||
|
||||
export default function ListingDetailPage() {
|
||||
const { t } = useTranslation("common");
|
||||
const router = useRouter();
|
||||
const params = useParams<{ listingId: string }>();
|
||||
const { request, loading } = useAxios();
|
||||
|
||||
const [listing, setListing] = useState<IShopProductListing | null>(null);
|
||||
const [selectedColor, setSelectedColor] = useState<string | null>(null);
|
||||
const [selectedSize, setSelectedSize] = useState<string | null>(null);
|
||||
const [selectedWeight, setSelectedWeight] = useState<string | null>(null);
|
||||
const [shippingMethod, setShippingMethod] = useState<string>("");
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const [activeImageIndex, setActiveImageIndex] = useState(0);
|
||||
const [descriptionExpanded, setDescriptionExpanded] = useState(false);
|
||||
const [showAddressModal, setShowAddressModal] = useState(false);
|
||||
const [showLightbox, setShowLightbox] = useState(false);
|
||||
const [favorite, setFavorite] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
request<{ listing: IShopProductListing }>("GET", `/shop-products/${params.listingId}`).then(
|
||||
(res) => {
|
||||
const data = res?.listing || null;
|
||||
setListing(data);
|
||||
const firstVariant = data?.variants?.[0];
|
||||
if (firstVariant) {
|
||||
setSelectedColor(firstVariant.color || null);
|
||||
setSelectedSize(firstVariant.size || null);
|
||||
setSelectedWeight(firstVariant.weight || null);
|
||||
}
|
||||
if (data) setFavorite(isFavorite(data._id));
|
||||
}
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [params.listingId]);
|
||||
|
||||
const shopInfo: ShopHeader | null =
|
||||
listing && typeof listing.shop === "object" ? (listing.shop as ShopHeader) : null;
|
||||
|
||||
const colors = useMemo(
|
||||
() => uniqueValues(listing?.variants.map((v) => v.color) || []),
|
||||
[listing]
|
||||
);
|
||||
const sizes = useMemo(() => {
|
||||
if (!listing) return [];
|
||||
const relevant = listing.variants.filter(
|
||||
(v) => colors.length === 0 || v.color === selectedColor
|
||||
);
|
||||
return uniqueValues(relevant.map((v) => v.size));
|
||||
}, [listing, colors, selectedColor]);
|
||||
const weights = useMemo(() => {
|
||||
if (!listing) return [];
|
||||
const relevant = listing.variants.filter(
|
||||
(v) =>
|
||||
(colors.length === 0 || v.color === selectedColor) &&
|
||||
(sizes.length === 0 || v.size === selectedSize)
|
||||
);
|
||||
return uniqueValues(relevant.map((v) => v.weight));
|
||||
}, [listing, colors, sizes, selectedColor, selectedSize]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sizes.length > 0 && (!selectedSize || !sizes.includes(selectedSize))) {
|
||||
setSelectedSize(sizes[0]);
|
||||
}
|
||||
}, [sizes, selectedSize]);
|
||||
|
||||
useEffect(() => {
|
||||
if (weights.length > 0 && (!selectedWeight || !weights.includes(selectedWeight))) {
|
||||
setSelectedWeight(weights[0]);
|
||||
}
|
||||
}, [weights, selectedWeight]);
|
||||
|
||||
const matchedVariant: IShopProductVariant | null = useMemo(() => {
|
||||
if (!listing) return null;
|
||||
return (
|
||||
listing.variants.find(
|
||||
(v) =>
|
||||
(colors.length === 0 || v.color === selectedColor) &&
|
||||
(sizes.length === 0 || v.size === selectedSize) &&
|
||||
(weights.length === 0 || v.weight === selectedWeight)
|
||||
) ||
|
||||
listing.variants[0] ||
|
||||
null
|
||||
);
|
||||
}, [listing, colors, sizes, weights, selectedColor, selectedSize, selectedWeight]);
|
||||
|
||||
useEffect(() => {
|
||||
setQuantity(1);
|
||||
}, [matchedVariant?._id]);
|
||||
|
||||
const enabledShippingMethods = shopInfo?.shipping_methods?.filter((m) => m.enabled) || [];
|
||||
|
||||
const categoryLine = listing
|
||||
? [listing.category, listing.sub_category, listing.sub_sub_category]
|
||||
.filter(Boolean)
|
||||
.join(" / ")
|
||||
: "";
|
||||
|
||||
const discountPercent =
|
||||
matchedVariant?.discount_price && matchedVariant.discount_price < matchedVariant.price
|
||||
? Math.round(
|
||||
((matchedVariant.price - matchedVariant.discount_price) / matchedVariant.price) * 100
|
||||
)
|
||||
: null;
|
||||
|
||||
const handleImageScroll = (event: React.UIEvent<HTMLDivElement>) => {
|
||||
const el = event.currentTarget;
|
||||
const index = Math.round(el.scrollLeft / el.clientWidth);
|
||||
setActiveImageIndex(index);
|
||||
};
|
||||
|
||||
const handleBuy = async () => {
|
||||
if (!listing || !matchedVariant) return;
|
||||
try {
|
||||
const response = await request<{ order: { _id: string } }>("POST", "/orders/draft", {
|
||||
listingId: listing._id,
|
||||
variantId: matchedVariant._id,
|
||||
quantity,
|
||||
shippingMethod: shippingMethod || undefined,
|
||||
});
|
||||
if (response?.order?._id) {
|
||||
router.push(`/shops/checkout/${response.order._id}`);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const data = (err as { response?: { data?: { code?: string; message?: string } } })
|
||||
?.response?.data;
|
||||
if (data?.code === "ADDRESS_REQUIRED") {
|
||||
setShowAddressModal(true);
|
||||
return;
|
||||
}
|
||||
toast.error(data?.message || t("shops.unknownError"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddToCart = () => {
|
||||
if (!listing || !matchedVariant) return;
|
||||
addToCart(listing._id, matchedVariant._id, quantity);
|
||||
toast.success(t("shops.addToCartSuccess"));
|
||||
};
|
||||
|
||||
const handleToggleFavorite = () => {
|
||||
if (!listing) return;
|
||||
const next = toggleFavorite(listing._id);
|
||||
setFavorite(next);
|
||||
toast.success(next ? t("shops.addedToFavorites") : t("shops.removedFromFavorites"));
|
||||
};
|
||||
|
||||
if (!listing) return null;
|
||||
|
||||
const images = listing.images || [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<LocalePageShell>
|
||||
<Container className="pb-28">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<span className="text-sm font-bold">{shopInfo?.name}</span>
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-full bg-neutral-100 dark:bg-neutral-800">
|
||||
{shopInfo?.logo ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={IMAGE_BASE_URL + shopInfo.logo}
|
||||
alt={shopInfo.name}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
src={staticIconUrl("/images/icons/vuesax/bold/shop.svg")}
|
||||
width={18}
|
||||
height={18}
|
||||
alt=""
|
||||
className="dark:invert"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto mt-4 flex w-full max-w-md flex-col gap-4 rounded-3xl border border-neutral-200 p-4 dark:border-neutral-700">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h1 className="text-right text-xl font-bold">{listing.title}</h1>
|
||||
{discountPercent != null && (
|
||||
<span className="relative flex h-11 w-11 shrink-0 items-center justify-center">
|
||||
<span className="absolute inset-0 rounded-lg bg-red-500" />
|
||||
<span className="absolute inset-0 rotate-[22.5deg] rounded-lg bg-red-500" />
|
||||
<span className="relative z-10 text-sm font-extrabold text-white">
|
||||
{t("shops.discountPercentBadge", { percent: discountPercent })}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{images.length > 0 && (
|
||||
<div className="relative">
|
||||
<div
|
||||
onScroll={handleImageScroll}
|
||||
className="flex w-full snap-x snap-mandatory gap-0 overflow-x-auto rounded-2xl [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
>
|
||||
{images.map((img) => (
|
||||
<button
|
||||
key={img}
|
||||
type="button"
|
||||
onClick={() => setShowLightbox(true)}
|
||||
className="relative aspect-[4/3] w-full shrink-0 snap-center overflow-hidden bg-neutral-200 dark:bg-neutral-800"
|
||||
>
|
||||
<Image
|
||||
src={IMAGE_BASE_URL + img}
|
||||
alt={listing.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="400px"
|
||||
unoptimized
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{images.length > 1 && (
|
||||
<span className="absolute bottom-2 left-1/2 -translate-x-1/2 rounded-full bg-black/50 px-2 py-0.5 text-[10px] font-bold text-white">
|
||||
{activeImageIndex + 1}/{images.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-sm font-bold">{t("shops.specsTitle")}</p>
|
||||
|
||||
{categoryLine && (
|
||||
<SpecRow label={t("shops.categorySpecLabel")}>
|
||||
<span className="text-xs">{categoryLine}</span>
|
||||
</SpecRow>
|
||||
)}
|
||||
|
||||
{colors.length > 0 && (
|
||||
<SpecRow label={t("shops.colorLabel")}>
|
||||
{colors.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
onClick={() => setSelectedColor(color)}
|
||||
className={cn(
|
||||
"rounded-full border px-2.5 py-1 text-xs",
|
||||
selectedColor === color
|
||||
? "border-pink-500 bg-pink-500 text-white"
|
||||
: "border-neutral-300 dark:border-neutral-600"
|
||||
)}
|
||||
>
|
||||
{color}
|
||||
</button>
|
||||
))}
|
||||
</SpecRow>
|
||||
)}
|
||||
|
||||
{sizes.length > 0 && (
|
||||
<SpecRow label={t("shops.sizeLabel")}>
|
||||
{sizes.map((size) => (
|
||||
<button
|
||||
key={size}
|
||||
type="button"
|
||||
onClick={() => setSelectedSize(size)}
|
||||
className={cn(
|
||||
"rounded-full border px-2.5 py-1 text-xs",
|
||||
selectedSize === size
|
||||
? "border-pink-500 bg-pink-500 text-white"
|
||||
: "border-neutral-300 dark:border-neutral-600"
|
||||
)}
|
||||
>
|
||||
{size}
|
||||
</button>
|
||||
))}
|
||||
</SpecRow>
|
||||
)}
|
||||
|
||||
{weights.length > 0 && (
|
||||
<SpecRow label={t("shops.weightLabel")}>
|
||||
{weights.map((weight) => (
|
||||
<button
|
||||
key={weight}
|
||||
type="button"
|
||||
onClick={() => setSelectedWeight(weight)}
|
||||
className={cn(
|
||||
"rounded-full border px-2.5 py-1 text-xs",
|
||||
selectedWeight === weight
|
||||
? "border-pink-500 bg-pink-500 text-white"
|
||||
: "border-neutral-300 dark:border-neutral-600"
|
||||
)}
|
||||
>
|
||||
{weight}
|
||||
</button>
|
||||
))}
|
||||
</SpecRow>
|
||||
)}
|
||||
|
||||
{enabledShippingMethods.length > 0 && (
|
||||
<SpecRow label={t("shops.shippingTitle")}>
|
||||
{enabledShippingMethods.map((m) => (
|
||||
<button
|
||||
key={m.method}
|
||||
type="button"
|
||||
onClick={() => setShippingMethod(m.method)}
|
||||
className={cn(
|
||||
"rounded-full border px-2.5 py-1 text-xs",
|
||||
shippingMethod === m.method
|
||||
? "border-pink-500 bg-pink-500 text-white"
|
||||
: "border-neutral-300 dark:border-neutral-600"
|
||||
)}
|
||||
>
|
||||
{t(`shops.shippingMethods.${m.method}`)}
|
||||
</button>
|
||||
))}
|
||||
</SpecRow>
|
||||
)}
|
||||
|
||||
{listing.description && (
|
||||
<SpecRow label={t("shops.descriptionLabel")}>
|
||||
<span className="text-xs leading-relaxed">
|
||||
{descriptionExpanded || listing.description.length <= 50
|
||||
? listing.description
|
||||
: `${listing.description.slice(0, 50)}...`}
|
||||
{listing.description.length > 50 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDescriptionExpanded((v) => !v)}
|
||||
className="mr-1 font-bold text-pink-500"
|
||||
>
|
||||
{descriptionExpanded ? t("shops.showLess") : t("shops.showMore")}
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</SpecRow>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleBuy()}
|
||||
disabled={loading || !matchedVariant}
|
||||
className="flex-1 rounded-3xl bg-green-600 py-2.5 text-center text-sm font-bold text-white disabled:opacity-50"
|
||||
>
|
||||
{t("shops.placeOrderButton")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuantity((q) => Math.max(1, q - 1))}
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-green-600 text-lg font-bold text-white"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span className="w-8 shrink-0 text-center text-sm font-bold">{quantity}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setQuantity((q) =>
|
||||
matchedVariant ? Math.min(matchedVariant.stock, q + 1) : q + 1
|
||||
)
|
||||
}
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-green-600 text-lg font-bold text-white"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{matchedVariant && (
|
||||
<SpecRow label={t("shops.priceSpecLabel")}>
|
||||
{matchedVariant.discount_price ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="text-xs text-neutral-400 line-through">
|
||||
{matchedVariant.price.toLocaleString()} {t("settings.toman")}
|
||||
</span>
|
||||
<span className="text-sm font-bold text-red-500">
|
||||
{matchedVariant.discount_price.toLocaleString()} {t("settings.toman")}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm font-bold">
|
||||
{matchedVariant.price.toLocaleString()} {t("settings.toman")}
|
||||
</span>
|
||||
)}
|
||||
</SpecRow>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mx-auto mb-6 mt-8 flex w-full max-w-md gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddToCart}
|
||||
className="flex-1 rounded-3xl bg-green-600 py-3 text-sm font-bold text-white"
|
||||
>
|
||||
{t("shops.addToCartButton")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggleFavorite}
|
||||
className="flex-1 rounded-3xl bg-sky-500 py-3 text-sm font-bold text-white"
|
||||
>
|
||||
{favorite ? t("shops.removeFromFavoritesButton") : t("shops.addToFavoritesButton")}
|
||||
</button>
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
<TabNavigation currentPage="/settings" />
|
||||
|
||||
{showAddressModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="w-full max-w-sm rounded-2xl bg-white p-6 text-center dark:bg-neutral-900">
|
||||
<p className="mb-4 text-sm">{t("shops.addressRequiredHint")}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push("/settings/edit/location")}
|
||||
className="w-full rounded-3xl bg-pink-500 py-2.5 text-sm font-bold text-white"
|
||||
>
|
||||
{t("shops.goToLocationSettings")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-3 block w-full text-xs text-neutral-500"
|
||||
onClick={() => setShowAddressModal(false)}
|
||||
>
|
||||
{t("auth.skip")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ProductImageLightbox
|
||||
images={images}
|
||||
initialIndex={activeImageIndex}
|
||||
alt={listing.title}
|
||||
open={showLightbox}
|
||||
onClose={() => setShowLightbox(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
return <ListingDetailClient />;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import ShopInfoModal from "@/components/shops/orders/ShopInfoModal";
|
||||
import { IMAGE_BASE_URL, staticIconUrl } from "@/components/main/BaseUrl";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useUser } from "@/hooks/useUser";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import toast from "react-hot-toast";
|
||||
@@ -45,7 +45,9 @@ type OrderDetail = {
|
||||
tracking_code?: string | null;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
isPaymentExpired?: boolean;
|
||||
buyer: string;
|
||||
variantId: string;
|
||||
returnWindowExpiresAt?: string | null;
|
||||
variantSnapshot: { color?: string | null; size?: string | null; weight?: string | null };
|
||||
buyerAddressSnapshot?: {
|
||||
@@ -58,10 +60,11 @@ type OrderDetail = {
|
||||
lat?: string | null;
|
||||
lng?: string | null;
|
||||
} | null;
|
||||
listing: { title: string; images: string[]; primaryImageIndex: number };
|
||||
listing: { _id: string; title: string; images: string[]; primaryImageIndex: number };
|
||||
shop: {
|
||||
_id: string;
|
||||
name: string;
|
||||
logo?: string | null;
|
||||
owner: string;
|
||||
estimated_delivery_text?: string | null;
|
||||
has_physical_location?: boolean;
|
||||
@@ -76,15 +79,20 @@ type OrderDetail = {
|
||||
export default function OrderDetailPage() {
|
||||
const { t } = useTranslation("common");
|
||||
const params = useParams<{ orderId: string }>();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const { request, loading } = useAxios();
|
||||
const user = useUser();
|
||||
const [repurchasing, setRepurchasing] = useState(false);
|
||||
|
||||
const [order, setOrder] = useState<OrderDetail | null>(null);
|
||||
const [report, setReport] = useState<OrderReport | null>(null);
|
||||
const [returnRequest, setReturnRequest] = useState<ReturnRequest | null>(null);
|
||||
const [shopRating, setShopRating] = useState<ShopRating | null>(null);
|
||||
const [trackingCode, setTrackingCode] = useState("");
|
||||
const [printMode, setPrintMode] = useState<"invoice" | null>(null);
|
||||
const [printMode, setPrintMode] = useState<"invoice" | "label-a5" | "label-a6" | null>(
|
||||
null
|
||||
);
|
||||
const [showRating, setShowRating] = useState(false);
|
||||
const [ratingScore, setRatingScore] = useState(0);
|
||||
const [ratingComment, setRatingComment] = useState("");
|
||||
@@ -128,6 +136,13 @@ export default function OrderDetailPage() {
|
||||
|
||||
const isSeller = Boolean(order && user && order.shop.owner === user._id);
|
||||
const isBuyer = Boolean(order && user && order.buyer === user._id);
|
||||
|
||||
useEffect(() => {
|
||||
if (isBuyer && searchParams.get("autoDownload") === "1") {
|
||||
setPrintMode("invoice");
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isBuyer]);
|
||||
const canConfirmReceipt =
|
||||
isBuyer && order && ["shipped", "ready_for_pickup"].includes(order.status);
|
||||
const canRequestReturn =
|
||||
@@ -135,6 +150,28 @@ export default function OrderDetailPage() {
|
||||
order?.returnWindowExpiresAt &&
|
||||
new Date() < new Date(order.returnWindowExpiresAt);
|
||||
|
||||
const handleRepurchase = async () => {
|
||||
if (!order) return;
|
||||
setRepurchasing(true);
|
||||
try {
|
||||
const response = await request<{ order: { _id: string } }>("POST", "/orders/draft", {
|
||||
listingId: order.listing._id,
|
||||
variantId: order.variantId,
|
||||
quantity: order.quantity,
|
||||
shippingMethod: order.shipping_method || undefined,
|
||||
});
|
||||
if (response?.order?._id) {
|
||||
router.push(`/shops/checkout/${response.order._id}`);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const data = (err as { response?: { data?: { code?: string; message?: string } } })
|
||||
?.response?.data;
|
||||
toast.error(data?.message || t("shops.unknownError"));
|
||||
} finally {
|
||||
setRepurchasing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmReceipt = async () => {
|
||||
if (!order) return;
|
||||
try {
|
||||
@@ -372,6 +409,31 @@ export default function OrderDetailPage() {
|
||||
</AuthNextButton>
|
||||
)}
|
||||
|
||||
{isBuyer && (
|
||||
<AuthNextButton type="button" onClick={() => setPrintMode("invoice")}>
|
||||
{t("shops.downloadInvoice")}
|
||||
</AuthNextButton>
|
||||
)}
|
||||
|
||||
{isSeller && (
|
||||
<div className="flex gap-2">
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
className="flex-1"
|
||||
onClick={() => setPrintMode("label-a5")}
|
||||
>
|
||||
{t("shops.printLabelA5")}
|
||||
</AuthNextButton>
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
className="flex-1"
|
||||
onClick={() => setPrintMode("label-a6")}
|
||||
>
|
||||
{t("shops.printLabelA6")}
|
||||
</AuthNextButton>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSeller && (
|
||||
<>
|
||||
<div>
|
||||
@@ -435,7 +497,24 @@ export default function OrderDetailPage() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{isBuyer && (
|
||||
{isBuyer && order.status === "pending_payment" && (
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
loading={repurchasing}
|
||||
disabled={repurchasing}
|
||||
onClick={() =>
|
||||
order.isPaymentExpired
|
||||
? handleRepurchase()
|
||||
: router.push(`/shops/checkout/${order._id}`)
|
||||
}
|
||||
>
|
||||
{order.isPaymentExpired
|
||||
? t("shops.repurchase")
|
||||
: t("shops.completePurchase")}
|
||||
</AuthNextButton>
|
||||
)}
|
||||
|
||||
{isBuyer && order.status !== "pending_payment" && (
|
||||
<>
|
||||
{canConfirmReceipt && (
|
||||
<AuthNextButton type="button" onClick={handleConfirmReceipt}>
|
||||
@@ -593,12 +672,32 @@ export default function OrderDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(printMode === "label-a5" || printMode === "label-a6") && (
|
||||
<div className="print-only">
|
||||
<div className="p-6 text-sm">
|
||||
<p className="mb-4 text-lg font-bold">{order.shop.name}</p>
|
||||
<p>{t("shops.buyerInfoTitle")}:</p>
|
||||
<p>{buyerName}</p>
|
||||
<p>
|
||||
{order.buyerAddressSnapshot?.province?.name}{" "}
|
||||
{order.buyerAddressSnapshot?.city?.name}
|
||||
</p>
|
||||
<p>{order.buyerAddressSnapshot?.address}</p>
|
||||
{order.tracking_code && (
|
||||
<p className="mt-4">
|
||||
{t("shops.trackingCodeLabel")}: {order.tracking_code}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
.print-only { display: none; }
|
||||
@media print {
|
||||
.no-print { display: none !important; }
|
||||
.print-only { display: block !important; }
|
||||
@page { size: A5; }
|
||||
@page { size: ${printMode === "label-a6" ? "A6" : "A5"}; }
|
||||
}
|
||||
`}</style>
|
||||
</Container>
|
||||
|
||||
@@ -5,6 +5,7 @@ import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -14,6 +15,23 @@ export default function ShopPaymentFailedPage() {
|
||||
const { request, loading } = useAxios();
|
||||
const searchParams = useSearchParams();
|
||||
const orderId = searchParams.get("orderId");
|
||||
const [shopId, setShopId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!orderId) return;
|
||||
request<{ order: { shop: { _id: string } | string } }>(
|
||||
"GET",
|
||||
`/orders/${orderId}`,
|
||||
null,
|
||||
{ noToast: true }
|
||||
)
|
||||
.then((res) => {
|
||||
const shop = res?.order?.shop;
|
||||
setShopId(typeof shop === "object" ? shop._id : shop || null);
|
||||
})
|
||||
.catch(() => setShopId(null));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [orderId]);
|
||||
|
||||
const retry = async () => {
|
||||
if (!orderId) return;
|
||||
@@ -56,6 +74,14 @@ export default function ShopPaymentFailedPage() {
|
||||
>
|
||||
{t("shops.retryPayment")}
|
||||
</RoundedButton>
|
||||
<RoundedButton
|
||||
className="!border-transparent h-9 w-32 !bg-neutral-100 text-neutral-600 dark:!bg-neutral-800 dark:text-neutral-300"
|
||||
onClick={() =>
|
||||
router.push(shopId ? `/shops/profile/${shopId}` : "/shops/discover")
|
||||
}
|
||||
>
|
||||
{t("shops.backToShopList")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
|
||||
@@ -39,6 +39,12 @@ export default function ShopPaymentSuccessPage() {
|
||||
>
|
||||
{t("shops.viewOrder")}
|
||||
</RoundedButton>
|
||||
<RoundedButton
|
||||
className="!border-transparent h-9 w-48 !bg-neutral-100 text-neutral-600 dark:!bg-neutral-800 dark:text-neutral-300"
|
||||
onClick={() => router.push("/")}
|
||||
>
|
||||
{t("shops.backToHome")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import Header from "@/components/main/Header";
|
||||
import TabNavigation from "@/components/TabNavigation";
|
||||
import PageTitle from "@/components/settings/PageTitle";
|
||||
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
@@ -41,9 +43,11 @@ export default function ProductComparisonPage() {
|
||||
: "";
|
||||
|
||||
return (
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<PageTitle>{productName || t("shops.comparisonTitle")}</PageTitle>
|
||||
<>
|
||||
<Header />
|
||||
<LocalePageShell>
|
||||
<Container className="pb-28">
|
||||
<PageTitle>{productName || t("shops.comparisonTitle")}</PageTitle>
|
||||
|
||||
{listings === null ? (
|
||||
<p className="py-16 text-center text-sm text-neutral-500">
|
||||
@@ -68,7 +72,7 @@ export default function ProductComparisonPage() {
|
||||
key={listing._id}
|
||||
type="button"
|
||||
onClick={() => router.push(`/shops/listing/${listing._id}`)}
|
||||
className="flex items-center gap-3 rounded-2xl border border-neutral-200 p-2 text-right active:scale-[0.98] dark:border-neutral-700"
|
||||
className="flex w-full items-center gap-3 rounded-2xl border border-neutral-200 p-2 text-right active:scale-[0.98] dark:border-neutral-700"
|
||||
>
|
||||
{primaryImage && (
|
||||
<div className="relative h-16 w-16 shrink-0 overflow-hidden rounded-xl bg-neutral-200 dark:bg-neutral-800">
|
||||
@@ -82,7 +86,7 @@ export default function ProductComparisonPage() {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="min-w-0 flex-1 text-right">
|
||||
<p className="truncate text-xs font-semibold text-neutral-500">
|
||||
{shop?.name}
|
||||
</p>
|
||||
@@ -94,12 +98,17 @@ export default function ProductComparisonPage() {
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="shrink-0 rounded-full bg-[#387E65] px-4 py-2 text-xs font-bold text-white">
|
||||
{t("shops.viewAndBuyButton")}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
<TabNavigation currentPage="/shops/discover" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
384
src/app/shops/profile/[shopId]/ShopProfileClient.tsx
Normal file
384
src/app/shops/profile/[shopId]/ShopProfileClient.tsx
Normal file
@@ -0,0 +1,384 @@
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import Header from "@/components/main/Header";
|
||||
import TabNavigation from "@/components/TabNavigation";
|
||||
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
|
||||
import ProfileAvatar from "@/components/main/ProfileAvatar";
|
||||
import ExpandableBio from "@/components/profile/ExpandableBio";
|
||||
import ShareShopModal from "@/components/shops/ShareShopModal";
|
||||
import ShopContactInfoModal from "@/components/shops/ShopContactInfoModal";
|
||||
import ShopMyOrdersModal from "@/components/shops/ShopMyOrdersModal";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import useInfiniteScroll from "@/hooks/useInfiniteScroll";
|
||||
import { useUser } from "@/hooks/useUser";
|
||||
import { useRequireAuth } from "@/lib/auth/useRequireAuth";
|
||||
import { profileActionBtnClass } from "@/lib/ui/buttonStyles";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { IShopProductListing } from "@/types/types";
|
||||
import dynamic from "next/dynamic";
|
||||
import Image from "next/image";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const LocationModal = dynamic(
|
||||
() => import("@/components/models/ModelPage/LocationModal"),
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
type PublicShop = {
|
||||
_id: string;
|
||||
name: string;
|
||||
logo?: string | null;
|
||||
description?: string | null;
|
||||
has_physical_location?: boolean;
|
||||
province?: { name: string } | null;
|
||||
city?: { name: string } | null;
|
||||
address?: string | null;
|
||||
lat?: string | null;
|
||||
lng?: string | null;
|
||||
contact?: {
|
||||
mobile?: string | null;
|
||||
landline?: string | null;
|
||||
telegram?: string | null;
|
||||
whatsapp?: string | null;
|
||||
instagram?: string | null;
|
||||
} | null;
|
||||
response_schedule?: { day: string; start_time?: string | null; end_time?: string | null }[];
|
||||
owner?: {
|
||||
_id: string;
|
||||
user_name: string;
|
||||
} | null;
|
||||
productCount: number;
|
||||
rating: { average: number; count: number };
|
||||
isFollowing: boolean;
|
||||
followedAt?: string | null;
|
||||
isOwner: boolean;
|
||||
createdAt?: string;
|
||||
};
|
||||
|
||||
type PerformanceTier = "weak" | "average" | "good" | "veryGood" | "excellent";
|
||||
|
||||
function getPerformanceTier(percent: number): PerformanceTier {
|
||||
if (percent < 60) return "weak";
|
||||
if (percent < 70) return "average";
|
||||
if (percent < 80) return "good";
|
||||
if (percent < 90) return "veryGood";
|
||||
return "excellent";
|
||||
}
|
||||
|
||||
function monthsSince(dateStr: string): number {
|
||||
const diffMs = Date.now() - new Date(dateStr).getTime();
|
||||
return Math.floor(diffMs / (1000 * 60 * 60 * 24 * 30));
|
||||
}
|
||||
|
||||
export default function ShopProfileClient() {
|
||||
const { t, i18n } = useTranslation("common");
|
||||
const isFa = (i18n.language || "fa").toLowerCase().startsWith("fa");
|
||||
const router = useRouter();
|
||||
const params = useParams<{ shopId: string }>();
|
||||
const shopId = params.shopId;
|
||||
const { request } = useAxios();
|
||||
const requireAuth = useRequireAuth();
|
||||
const currentUser = useUser();
|
||||
|
||||
const [shop, setShop] = useState<PublicShop | null>(null);
|
||||
const [isFollowing, setIsFollowing] = useState(false);
|
||||
const [followedAt, setFollowedAt] = useState<string | null>(null);
|
||||
const [followLoading, setFollowLoading] = useState(false);
|
||||
const [showContactModal, setShowContactModal] = useState(false);
|
||||
const [showOrdersModal, setShowOrdersModal] = useState(false);
|
||||
const [showLocationModal, setShowLocationModal] = useState(false);
|
||||
const [showShareModal, setShowShareModal] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
request<{ shop: PublicShop }>("GET", `/shops/public/${shopId}`)
|
||||
.then((res) => {
|
||||
const data = res?.shop || null;
|
||||
setShop(data);
|
||||
setIsFollowing(Boolean(data?.isFollowing));
|
||||
setFollowedAt(data?.followedAt || null);
|
||||
})
|
||||
.catch(() => setShop(null));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [shopId]);
|
||||
|
||||
const { data, isLoading } = useInfiniteScroll({
|
||||
endpoint: "/shop-products",
|
||||
queryKey: ["shop-products", shopId],
|
||||
params: { shopId },
|
||||
});
|
||||
const listings: IShopProductListing[] =
|
||||
data?.pages.flatMap((page) => page.docs || []) || [];
|
||||
|
||||
const toggleFollow = async () => {
|
||||
if (!requireAuth() || !shop || followLoading) return;
|
||||
const nextFollowing = !isFollowing;
|
||||
setIsFollowing(nextFollowing);
|
||||
setFollowLoading(true);
|
||||
try {
|
||||
const res = await request<{ is_following: boolean; followed_at: string | null }>(
|
||||
"POST",
|
||||
`/shops/${shop._id}/follow`,
|
||||
{}
|
||||
);
|
||||
setIsFollowing(res?.is_following ?? nextFollowing);
|
||||
setFollowedAt(res?.followed_at || null);
|
||||
toast.success(
|
||||
(res?.is_following ?? nextFollowing)
|
||||
? t("shops.shopFollowSuccess")
|
||||
: t("shops.shopUnfollowSuccess")
|
||||
);
|
||||
} catch {
|
||||
setIsFollowing(!nextFollowing);
|
||||
} finally {
|
||||
setFollowLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMessage = () => {
|
||||
if (!requireAuth() || !shop || !currentUser?._id) return;
|
||||
router.push(`/settings/chats/shop/${shop._id}/${currentUser._id}`);
|
||||
};
|
||||
|
||||
if (!shop) return null;
|
||||
|
||||
const percent =
|
||||
shop.rating.count > 0 ? Math.round((shop.rating.average / 5) * 100) : null;
|
||||
const performanceTier = percent != null ? getPerformanceTier(percent) : null;
|
||||
const hasDescription = Boolean(shop.description?.trim());
|
||||
|
||||
const shopTenureLabel = shop.createdAt
|
||||
? monthsSince(shop.createdAt) < 1
|
||||
? t("shops.lessThanOneMonth")
|
||||
: t("shops.monthsCount", { count: monthsSince(shop.createdAt) })
|
||||
: t("shops.followButton");
|
||||
|
||||
const followLabel = isFollowing
|
||||
? followedAt
|
||||
? monthsSince(followedAt) < 1
|
||||
? t("shops.lessThanOneMonth")
|
||||
: t("shops.monthsCount", { count: monthsSince(followedAt) })
|
||||
: t("shops.unfollowButton")
|
||||
: shopTenureLabel;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<LocalePageShell>
|
||||
<Container className="pb-28">
|
||||
<div className="mx-auto w-full max-w-md" dir={isFa ? "ltr" : "rtl"}>
|
||||
<div className="flex w-full items-center justify-between py-2">
|
||||
<div className="flex flex-col items-center text-xs md:text-sm">
|
||||
<div className="mt-2 flex gap-5">
|
||||
<div className="flex flex-col items-center font-semibold max-sm:text-[10px]">
|
||||
<span>{shop.productCount}</span>
|
||||
<span>{t("shops.productsLabel")}</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center font-semibold text-[#0C8002] max-sm:text-[10px]">
|
||||
<span>
|
||||
{performanceTier
|
||||
? t(`shops.performance.${performanceTier}`)
|
||||
: t("shops.underReview")}
|
||||
</span>
|
||||
<span>{t("shops.performanceLabel")}</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center font-semibold text-[#3A59A9] max-sm:text-[10px]">
|
||||
<span>{percent != null ? `${percent}%` : t("shops.newShop")}</span>
|
||||
<span>{t("shops.satisfactionLabel")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ProfileAvatar
|
||||
src={shop.logo}
|
||||
alt={shop.name}
|
||||
size="md"
|
||||
rounded="xl"
|
||||
className="md:h-[120px] md:w-[120px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid w-full grid-cols-3 items-end py-2 text-xs font-semibold md:text-sm">
|
||||
<div className="flex items-center">
|
||||
<span>{shop.rating.count > 0 ? shop.rating.average.toFixed(1) : "0"}</span>
|
||||
<Image width={22} height={22} alt="star icon" src="/images/icons/star1.png" />
|
||||
</div>
|
||||
<div className="flex items-center justify-center">
|
||||
<span>{shop.rating.count}</span>
|
||||
<Image width={22} height={22} alt="rate icon" src="/images/icons/medal-star.png" />
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0 max-w-full flex-col",
|
||||
isFa ? "items-end" : "items-start"
|
||||
)}
|
||||
>
|
||||
<h3
|
||||
className={cn(
|
||||
"max-w-full truncate whitespace-nowrap",
|
||||
isFa ? "text-right" : "text-left"
|
||||
)}
|
||||
>
|
||||
{shop.name}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full py-2 text-xs font-semibold md:text-sm">
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full gap-3",
|
||||
hasDescription
|
||||
? "items-baseline justify-between"
|
||||
: "items-center justify-end"
|
||||
)}
|
||||
>
|
||||
{hasDescription ? (
|
||||
<div
|
||||
className={cn(
|
||||
"min-w-0 flex-1 leading-6",
|
||||
isFa ? "text-right" : "text-left"
|
||||
)}
|
||||
>
|
||||
<ExpandableBio
|
||||
bio={shop.description}
|
||||
className={cn("leading-6", isFa ? "text-right" : "text-left")}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 leading-6 text-[#387E65]",
|
||||
isFa ? "text-right" : "text-left"
|
||||
)}
|
||||
>
|
||||
{shop.has_physical_location ? t("shops.inPerson") : t("shops.onlineOnly")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 grid grid-cols-3 gap-1 md:mt-4 md:gap-4">
|
||||
<button type="button" onClick={handleMessage} className={profileActionBtnClass}>
|
||||
{t("shops.sendMessage")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowOrdersModal(true)}
|
||||
className={profileActionBtnClass}
|
||||
>
|
||||
{t("shops.myOrdersFromShop")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowContactModal(true)}
|
||||
className={profileActionBtnClass}
|
||||
>
|
||||
{t("shops.contactInfoButton")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void toggleFollow()}
|
||||
disabled={followLoading}
|
||||
className={profileActionBtnClass}
|
||||
>
|
||||
{followLabel}
|
||||
</button>
|
||||
{shop.has_physical_location && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowLocationModal(true)}
|
||||
className={profileActionBtnClass}
|
||||
>
|
||||
{t("shops.showLocation")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowShareModal(true)}
|
||||
className={profileActionBtnClass}
|
||||
>
|
||||
{t("shops.share.title")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
{isLoading ? (
|
||||
<p className="py-16 text-center text-sm text-neutral-500">
|
||||
{t("common.loading")}
|
||||
</p>
|
||||
) : listings.length === 0 ? (
|
||||
<p className="py-16 text-center text-sm text-neutral-500">
|
||||
{t("shops.noProductsYet")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 gap-0.5 sm:gap-1">
|
||||
{listings.map((listing) => {
|
||||
const primaryImage =
|
||||
listing.images?.[listing.primaryImageIndex] || listing.images?.[0];
|
||||
return (
|
||||
<button
|
||||
key={listing._id}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
router.push(`/shops/profile/${shopId}/reel/${listing._id}`)
|
||||
}
|
||||
className="relative aspect-square overflow-hidden rounded-md bg-neutral-100 sm:rounded-xl dark:bg-neutral-800"
|
||||
>
|
||||
{primaryImage && (
|
||||
<Image
|
||||
src={IMAGE_BASE_URL + primaryImage}
|
||||
alt={listing.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="33vw"
|
||||
unoptimized
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
<TabNavigation currentPage="/settings" />
|
||||
|
||||
<ShareShopModal
|
||||
open={showShareModal}
|
||||
onClose={() => setShowShareModal(false)}
|
||||
shopId={shop._id}
|
||||
shopName={shop.name}
|
||||
shopLogo={shop.logo}
|
||||
responseSchedule={shop.response_schedule}
|
||||
/>
|
||||
<ShopContactInfoModal
|
||||
open={showContactModal}
|
||||
onClose={() => setShowContactModal(false)}
|
||||
contactInfo={shop.contact || {}}
|
||||
/>
|
||||
<ShopMyOrdersModal
|
||||
open={showOrdersModal}
|
||||
onClose={() => setShowOrdersModal(false)}
|
||||
shopId={shop._id}
|
||||
/>
|
||||
{showLocationModal && shop.has_physical_location && (
|
||||
<LocationModal
|
||||
isOpen={showLocationModal}
|
||||
onClose={() => setShowLocationModal(false)}
|
||||
location={{
|
||||
address: shop.address || undefined,
|
||||
lat: shop.lat || undefined,
|
||||
lng: shop.lng || undefined,
|
||||
city: shop.city || undefined,
|
||||
province: shop.province || undefined,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +1,16 @@
|
||||
"use client";
|
||||
import type { Metadata } from "next";
|
||||
import { fetchApiJson } from "@/lib/api/fetchApiJson";
|
||||
import { generatePageMetadata } from "@/utils/generatePageMetadata";
|
||||
import { getServerLanguage } from "@/lib/i18n/server";
|
||||
import { getSiteSeoMeta } from "@/lib/i18n/seo";
|
||||
import { buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import ShopProfileClient from "./ShopProfileClient";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import Header from "@/components/main/Header";
|
||||
import TabNavigation from "@/components/TabNavigation";
|
||||
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
|
||||
import ProfileAvatar from "@/components/main/ProfileAvatar";
|
||||
import ExpandableBio from "@/components/profile/ExpandableBio";
|
||||
import ShareShopModal from "@/components/shops/ShareShopModal";
|
||||
import ShopContactInfoModal from "@/components/shops/ShopContactInfoModal";
|
||||
import ShopMyOrdersModal from "@/components/shops/ShopMyOrdersModal";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import useInfiniteScroll from "@/hooks/useInfiniteScroll";
|
||||
import { useUser } from "@/hooks/useUser";
|
||||
import { useRequireAuth } from "@/lib/auth/useRequireAuth";
|
||||
import { profileActionBtnClass } from "@/lib/ui/buttonStyles";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { IShopProductListing } from "@/types/types";
|
||||
import dynamic from "next/dynamic";
|
||||
import Image from "next/image";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const LocationModal = dynamic(
|
||||
() => import("@/components/models/ModelPage/LocationModal"),
|
||||
{ ssr: false }
|
||||
);
|
||||
interface IShopProfilePageProps {
|
||||
params: Promise<{ shopId: string }>;
|
||||
}
|
||||
|
||||
type PublicShop = {
|
||||
_id: string;
|
||||
name: string;
|
||||
logo?: string | null;
|
||||
description?: string | null;
|
||||
@@ -38,347 +18,59 @@ type PublicShop = {
|
||||
province?: { name: string } | null;
|
||||
city?: { name: string } | null;
|
||||
address?: string | null;
|
||||
lat?: string | null;
|
||||
lng?: string | null;
|
||||
contact?: {
|
||||
mobile?: string | null;
|
||||
landline?: string | null;
|
||||
telegram?: string | null;
|
||||
whatsapp?: string | null;
|
||||
instagram?: string | null;
|
||||
} | null;
|
||||
response_schedule?: { day: string; start_time?: string | null; end_time?: string | null }[];
|
||||
owner?: {
|
||||
_id: string;
|
||||
user_name: string;
|
||||
} | null;
|
||||
productCount: number;
|
||||
rating: { average: number; count: number };
|
||||
isFollowing: boolean;
|
||||
followedAt?: string | null;
|
||||
isOwner: boolean;
|
||||
createdAt?: string;
|
||||
};
|
||||
|
||||
type PerformanceTier = "weak" | "average" | "good" | "veryGood" | "excellent";
|
||||
|
||||
function getPerformanceTier(percent: number): PerformanceTier {
|
||||
if (percent < 60) return "weak";
|
||||
if (percent < 70) return "average";
|
||||
if (percent < 80) return "good";
|
||||
if (percent < 90) return "veryGood";
|
||||
return "excellent";
|
||||
async function loadShop(shopId: string) {
|
||||
const { data } = await fetchApiJson<{ shop?: PublicShop }>(`/shops/public/${shopId}`);
|
||||
return data?.shop ?? null;
|
||||
}
|
||||
|
||||
function monthsSince(dateStr: string): number {
|
||||
const diffMs = Date.now() - new Date(dateStr).getTime();
|
||||
return Math.floor(diffMs / (1000 * 60 * 60 * 24 * 30));
|
||||
}
|
||||
export async function generateMetadata({ params }: IShopProfilePageProps): Promise<Metadata> {
|
||||
const { shopId } = await params;
|
||||
const lang = await getServerLanguage();
|
||||
const site = getSiteSeoMeta(lang);
|
||||
const path = `/shops/profile/${shopId}`;
|
||||
|
||||
export default function ShopPublicProfilePage() {
|
||||
const { t, i18n } = useTranslation("common");
|
||||
const isFa = (i18n.language || "fa").toLowerCase().startsWith("fa");
|
||||
const router = useRouter();
|
||||
const params = useParams<{ shopId: string }>();
|
||||
const shopId = params.shopId;
|
||||
const { request } = useAxios();
|
||||
const requireAuth = useRequireAuth();
|
||||
const currentUser = useUser();
|
||||
const shop = await loadShop(shopId);
|
||||
|
||||
const [shop, setShop] = useState<PublicShop | null>(null);
|
||||
const [isFollowing, setIsFollowing] = useState(false);
|
||||
const [followedAt, setFollowedAt] = useState<string | null>(null);
|
||||
const [followLoading, setFollowLoading] = useState(false);
|
||||
const [showContactModal, setShowContactModal] = useState(false);
|
||||
const [showOrdersModal, setShowOrdersModal] = useState(false);
|
||||
const [showLocationModal, setShowLocationModal] = useState(false);
|
||||
const [showShareModal, setShowShareModal] = useState(false);
|
||||
if (!shop) {
|
||||
const title = `فروشگاه یافت نشد | ${site.titleSuffix}`;
|
||||
const meta = generatePageMetadata({
|
||||
title,
|
||||
description: site.defaultDescription,
|
||||
path,
|
||||
index: false,
|
||||
lang,
|
||||
});
|
||||
return { ...meta, title: { absolute: title } };
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
request<{ shop: PublicShop }>("GET", `/shops/public/${shopId}`)
|
||||
.then((res) => {
|
||||
const data = res?.shop || null;
|
||||
setShop(data);
|
||||
setIsFollowing(Boolean(data?.isFollowing));
|
||||
setFollowedAt(data?.followedAt || null);
|
||||
})
|
||||
.catch(() => setShop(null));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [shopId]);
|
||||
const title = [shop.name, shop.province?.name, shop.city?.name]
|
||||
.filter(Boolean)
|
||||
.join(" - ") + ` | ${site.titleSuffix}`;
|
||||
|
||||
const { data, isLoading } = useInfiniteScroll({
|
||||
endpoint: "/shop-products",
|
||||
queryKey: ["shop-products", shopId],
|
||||
params: { shopId },
|
||||
const description =
|
||||
[
|
||||
shop.description,
|
||||
shop.address,
|
||||
shop.has_physical_location ? "حضوری" : "اینترنتی",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" - ") || site.defaultDescription;
|
||||
|
||||
const meta = generatePageMetadata({
|
||||
title,
|
||||
description,
|
||||
path,
|
||||
type: "website",
|
||||
imageUrl: shop.logo ? buildStorageUrl(shop.logo) : undefined,
|
||||
imageAlt: shop.name,
|
||||
lang,
|
||||
});
|
||||
const listings: IShopProductListing[] =
|
||||
data?.pages.flatMap((page) => page.docs || []) || [];
|
||||
|
||||
const toggleFollow = async () => {
|
||||
if (!requireAuth() || !shop || followLoading) return;
|
||||
const nextFollowing = !isFollowing;
|
||||
setIsFollowing(nextFollowing);
|
||||
setFollowLoading(true);
|
||||
try {
|
||||
const res = await request<{ is_following: boolean; followed_at: string | null }>(
|
||||
"POST",
|
||||
`/shops/${shop._id}/follow`,
|
||||
{}
|
||||
);
|
||||
setIsFollowing(res?.is_following ?? nextFollowing);
|
||||
setFollowedAt(res?.followed_at || null);
|
||||
toast.success(
|
||||
(res?.is_following ?? nextFollowing)
|
||||
? t("shops.shopFollowSuccess")
|
||||
: t("shops.shopUnfollowSuccess")
|
||||
);
|
||||
} catch {
|
||||
setIsFollowing(!nextFollowing);
|
||||
} finally {
|
||||
setFollowLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMessage = () => {
|
||||
if (!requireAuth() || !shop || !currentUser?._id) return;
|
||||
router.push(`/settings/chats/shop/${shop._id}/${currentUser._id}`);
|
||||
};
|
||||
|
||||
if (!shop) return null;
|
||||
|
||||
const percent =
|
||||
shop.rating.count > 0 ? Math.round((shop.rating.average / 5) * 100) : null;
|
||||
const performanceTier = percent != null ? getPerformanceTier(percent) : null;
|
||||
const hasDescription = Boolean(shop.description?.trim());
|
||||
|
||||
const shopTenureLabel = shop.createdAt
|
||||
? monthsSince(shop.createdAt) < 1
|
||||
? t("shops.lessThanOneMonth")
|
||||
: t("shops.monthsCount", { count: monthsSince(shop.createdAt) })
|
||||
: t("shops.followButton");
|
||||
|
||||
const followLabel = isFollowing
|
||||
? followedAt
|
||||
? monthsSince(followedAt) < 1
|
||||
? t("shops.lessThanOneMonth")
|
||||
: t("shops.monthsCount", { count: monthsSince(followedAt) })
|
||||
: t("shops.unfollowButton")
|
||||
: shopTenureLabel;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<LocalePageShell>
|
||||
<Container className="pb-28">
|
||||
<div className="mx-auto w-full max-w-md" dir={isFa ? "ltr" : "rtl"}>
|
||||
<div className="flex w-full items-center justify-between py-2">
|
||||
<div className="flex flex-col items-center text-xs md:text-sm">
|
||||
<div className="mt-2 flex gap-5">
|
||||
<div className="flex flex-col items-center font-semibold max-sm:text-[10px]">
|
||||
<span>{shop.productCount}</span>
|
||||
<span>{t("shops.productsLabel")}</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center font-semibold text-[#0C8002] max-sm:text-[10px]">
|
||||
<span>
|
||||
{performanceTier
|
||||
? t(`shops.performance.${performanceTier}`)
|
||||
: t("shops.underReview")}
|
||||
</span>
|
||||
<span>{t("shops.performanceLabel")}</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center font-semibold text-[#3A59A9] max-sm:text-[10px]">
|
||||
<span>{percent != null ? `${percent}%` : t("shops.newShop")}</span>
|
||||
<span>{t("shops.satisfactionLabel")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ProfileAvatar
|
||||
src={shop.logo}
|
||||
alt={shop.name}
|
||||
size="md"
|
||||
rounded="xl"
|
||||
className="md:h-[120px] md:w-[120px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid w-full grid-cols-3 items-end py-2 text-xs font-semibold md:text-sm">
|
||||
<div className="flex items-center">
|
||||
<span>{shop.rating.count > 0 ? shop.rating.average.toFixed(1) : "0"}</span>
|
||||
<Image width={22} height={22} alt="star icon" src="/images/icons/star1.png" />
|
||||
</div>
|
||||
<div className="flex items-center justify-center">
|
||||
<span>{shop.rating.count}</span>
|
||||
<Image width={22} height={22} alt="rate icon" src="/images/icons/medal-star.png" />
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0 max-w-full flex-col",
|
||||
isFa ? "items-end" : "items-start"
|
||||
)}
|
||||
>
|
||||
<h3
|
||||
className={cn(
|
||||
"max-w-full truncate whitespace-nowrap",
|
||||
isFa ? "text-right" : "text-left"
|
||||
)}
|
||||
>
|
||||
{shop.name}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full py-2 text-xs font-semibold md:text-sm">
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full gap-3",
|
||||
hasDescription
|
||||
? "items-baseline justify-between"
|
||||
: "items-center justify-end"
|
||||
)}
|
||||
>
|
||||
{hasDescription ? (
|
||||
<div
|
||||
className={cn(
|
||||
"min-w-0 flex-1 leading-6",
|
||||
isFa ? "text-right" : "text-left"
|
||||
)}
|
||||
>
|
||||
<ExpandableBio
|
||||
bio={shop.description}
|
||||
className={cn("leading-6", isFa ? "text-right" : "text-left")}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 leading-6 text-[#387E65]",
|
||||
isFa ? "text-right" : "text-left"
|
||||
)}
|
||||
>
|
||||
{shop.has_physical_location ? t("shops.inPerson") : t("shops.onlineOnly")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 grid grid-cols-3 gap-1 md:mt-4 md:gap-4">
|
||||
<button type="button" onClick={handleMessage} className={profileActionBtnClass}>
|
||||
{t("shops.sendMessage")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowOrdersModal(true)}
|
||||
className={profileActionBtnClass}
|
||||
>
|
||||
{t("shops.myOrdersFromShop")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowContactModal(true)}
|
||||
className={profileActionBtnClass}
|
||||
>
|
||||
{t("shops.contactInfoButton")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void toggleFollow()}
|
||||
disabled={followLoading}
|
||||
className={profileActionBtnClass}
|
||||
>
|
||||
{followLabel}
|
||||
</button>
|
||||
{shop.has_physical_location && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowLocationModal(true)}
|
||||
className={profileActionBtnClass}
|
||||
>
|
||||
{t("shops.showLocation")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowShareModal(true)}
|
||||
className={profileActionBtnClass}
|
||||
>
|
||||
{t("shops.share.title")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
{isLoading ? (
|
||||
<p className="py-16 text-center text-sm text-neutral-500">
|
||||
{t("common.loading")}
|
||||
</p>
|
||||
) : listings.length === 0 ? (
|
||||
<p className="py-16 text-center text-sm text-neutral-500">
|
||||
{t("shops.noProductsYet")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 gap-0.5 sm:gap-1">
|
||||
{listings.map((listing) => {
|
||||
const primaryImage =
|
||||
listing.images?.[listing.primaryImageIndex] || listing.images?.[0];
|
||||
return (
|
||||
<button
|
||||
key={listing._id}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
router.push(`/shops/profile/${shopId}/reel/${listing._id}`)
|
||||
}
|
||||
className="relative aspect-square overflow-hidden rounded-md bg-neutral-100 sm:rounded-xl dark:bg-neutral-800"
|
||||
>
|
||||
{primaryImage && (
|
||||
<Image
|
||||
src={IMAGE_BASE_URL + primaryImage}
|
||||
alt={listing.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="33vw"
|
||||
unoptimized
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
<TabNavigation currentPage="/settings" />
|
||||
|
||||
<ShareShopModal
|
||||
open={showShareModal}
|
||||
onClose={() => setShowShareModal(false)}
|
||||
shopId={shop._id}
|
||||
shopName={shop.name}
|
||||
shopLogo={shop.logo}
|
||||
responseSchedule={shop.response_schedule}
|
||||
/>
|
||||
<ShopContactInfoModal
|
||||
open={showContactModal}
|
||||
onClose={() => setShowContactModal(false)}
|
||||
contactInfo={shop.contact || {}}
|
||||
/>
|
||||
<ShopMyOrdersModal
|
||||
open={showOrdersModal}
|
||||
onClose={() => setShowOrdersModal(false)}
|
||||
shopId={shop._id}
|
||||
/>
|
||||
{showLocationModal && shop.has_physical_location && (
|
||||
<LocationModal
|
||||
isOpen={showLocationModal}
|
||||
onClose={() => setShowLocationModal(false)}
|
||||
location={{
|
||||
address: shop.address || undefined,
|
||||
lat: shop.lat || undefined,
|
||||
lng: shop.lng || undefined,
|
||||
city: shop.city || undefined,
|
||||
province: shop.province || undefined,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
return { ...meta, title: { absolute: title } };
|
||||
}
|
||||
|
||||
export default function ShopProfilePage() {
|
||||
return <ShopProfileClient />;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import AuthSessionSync from "@/components/auth/AuthSessionSync";
|
||||
import PwaInstallPrompt from "@/components/pwa/PwaInstallPrompt";
|
||||
import PwaBootSplash from "@/components/pwa/PwaBootSplash";
|
||||
import PwaHead from "@/components/PwaHead";
|
||||
import NotificationBridge from "@/components/notifications/NotificationBridge";
|
||||
|
||||
interface ILayoutProps {
|
||||
children: React.ReactNode;
|
||||
@@ -29,6 +30,7 @@ function Layout({ children }: ILayoutProps) {
|
||||
<AuthSessionSync />
|
||||
<PwaInstallPrompt />
|
||||
<BackgroundPrefetch />
|
||||
<NotificationBridge />
|
||||
{children}
|
||||
</ReactQueryProvider>
|
||||
</LanguageProvider>
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
} from "@/lib/chat/messageGrouping";
|
||||
import SharedPostBubble, { parseSharedPost } from "./SharedPostBubble";
|
||||
import SharedProductBubble, { parseSharedProduct } from "./SharedProductBubble";
|
||||
import SharedGiftBubble, { parseSharedGift } from "./SharedGiftBubble";
|
||||
import SharedStoryBubble, {
|
||||
parseSharedStory,
|
||||
extractStoryReactionText,
|
||||
@@ -320,6 +321,7 @@ const ChatMessageCard = ({
|
||||
message.forwardedFrom || parseForwarded(message.content || "");
|
||||
const sharedPost = parseSharedPost(message.content || "");
|
||||
const sharedProduct = parseSharedProduct(message.content || "");
|
||||
const sharedGift = parseSharedGift(message.content || "");
|
||||
const sharedStory = parseSharedStory(message.content || "");
|
||||
const storyReactionText = sharedStory
|
||||
? extractStoryReactionText(message.content || "")
|
||||
@@ -334,6 +336,7 @@ const ChatMessageCard = ({
|
||||
!hasLocation &&
|
||||
!sharedPost &&
|
||||
!sharedProduct &&
|
||||
!sharedGift &&
|
||||
!sharedStory &&
|
||||
!(message.viewOnce && isViewOnceMediaType(message.fileType))
|
||||
) {
|
||||
@@ -351,7 +354,7 @@ const ChatMessageCard = ({
|
||||
: detectChatFileType(message.file || "", message.fileType);
|
||||
|
||||
const textContent =
|
||||
sharedPost || sharedStory
|
||||
sharedPost || sharedProduct || sharedGift || sharedStory
|
||||
? ""
|
||||
: getBubbleTextContent(message.content);
|
||||
|
||||
@@ -748,6 +751,16 @@ const ChatMessageCard = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sharedGift && (
|
||||
<div
|
||||
className={cn("p-1", isSender ? "chat-bubble-out" : "chat-bubble-in")}
|
||||
style={bubbleStyle}
|
||||
>
|
||||
<SharedGiftBubble data={sharedGift} isSender={isSender} />
|
||||
<TimeBelow />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showTextBubble && !message.file && emojiOnly && (
|
||||
<p className="px-1 py-0.5 text-[2.5rem] leading-none select-text">{textContent}</p>
|
||||
)}
|
||||
|
||||
@@ -14,6 +14,7 @@ import ChatRoomMemberPicker, {
|
||||
ChatRoomMember,
|
||||
} from "@/components/chat/ChatRoomMemberPicker";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { filterChipClass } from "@/lib/ui/buttonStyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getStoredUserId } from "@/lib/auth/session";
|
||||
import { ensureIdentityKeys, ensureRoomAesKey } from "@/lib/e2ee";
|
||||
@@ -36,6 +37,7 @@ export default function CreateChatRoomModal({ open, onClose, onCreated }: Props)
|
||||
const [cityId, setCityId] = useState("");
|
||||
const [expertise, setExpertise] = useState("");
|
||||
const [invitedMembers, setInvitedMembers] = useState<ChatRoomMember[]>([]);
|
||||
const [idleTimeoutDays, setIdleTimeoutDays] = useState(30);
|
||||
const [imageFile, setImageFile] = useState<File | null>(null);
|
||||
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||
const [provinces, setProvinces] = useState<IProvince[]>([]);
|
||||
@@ -51,6 +53,7 @@ export default function CreateChatRoomModal({ open, onClose, onCreated }: Props)
|
||||
setCityId("");
|
||||
setExpertise("");
|
||||
setInvitedMembers([]);
|
||||
setIdleTimeoutDays(30);
|
||||
setImageFile(null);
|
||||
setImagePreview(null);
|
||||
|
||||
@@ -113,6 +116,7 @@ export default function CreateChatRoomModal({ open, onClose, onCreated }: Props)
|
||||
JSON.stringify(invitedMembers.map((member) => member._id))
|
||||
);
|
||||
}
|
||||
form.append("idle_timeout_days", String(idleTimeoutDays));
|
||||
if (imageFile) form.append("room_image", imageFile);
|
||||
|
||||
const created = await request<{ room?: { _id?: string } }>(
|
||||
@@ -193,6 +197,27 @@ export default function CreateChatRoomModal({ open, onClose, onCreated }: Props)
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 w-full">
|
||||
<p className="mb-2 text-center text-xs text-neutral-500">
|
||||
{t("chats.modal.idleTimeout")}
|
||||
</p>
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
{[1, 2, 7, 14, 30].map((days) => (
|
||||
<button
|
||||
key={days}
|
||||
type="button"
|
||||
onClick={() => setIdleTimeoutDays(days)}
|
||||
className={filterChipClass(idleTimeoutDays === days)}
|
||||
>
|
||||
{t("chats.modal.idleTimeoutDays", { count: days })}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-2 px-2 text-center text-[11px] leading-5 text-neutral-500">
|
||||
{t("chats.modal.idleTimeoutHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 w-full">
|
||||
<p className="mb-2 text-center text-xs text-neutral-500">
|
||||
{t("chats.modal.roomType")}
|
||||
|
||||
180
src/components/chat/GiftInChatModal.tsx
Normal file
180
src/components/chat/GiftInChatModal.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Modal from "@/components/elements/Modal";
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import RoundedInput from "@/components/elements/RoundedInput";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { filterChipClass } from "@/lib/ui/buttonStyles";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const PRESET_AMOUNTS = [2000, 5000, 10000, 15000, 20000, 50000, 100000];
|
||||
const CUSTOM_MIN = 5000;
|
||||
const CUSTOM_MAX = 100000000;
|
||||
|
||||
type GiftChatResponse = {
|
||||
paidFromWallet: boolean;
|
||||
authority?: string;
|
||||
paymentUrl?: string;
|
||||
gift?: { _id: string; amount: number; message: string | null };
|
||||
};
|
||||
|
||||
type GiftInChatModalProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
receiverId: string;
|
||||
returnPath: string;
|
||||
onWalletGiftSent: (gift: { _id: string; amount: number; message: string | null }) => void;
|
||||
};
|
||||
|
||||
export default function GiftInChatModal({
|
||||
open,
|
||||
onClose,
|
||||
receiverId,
|
||||
returnPath,
|
||||
onWalletGiftSent,
|
||||
}: GiftInChatModalProps) {
|
||||
const { t } = useTranslation("common");
|
||||
const { request } = useAxios();
|
||||
const router = useRouter();
|
||||
|
||||
const [selectedAmount, setSelectedAmount] = useState<number | null>(PRESET_AMOUNTS[0]);
|
||||
const [customAmount, setCustomAmount] = useState("");
|
||||
const [isCustom, setIsCustom] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const finalAmount = useMemo(() => {
|
||||
if (isCustom) return Number(customAmount) || 0;
|
||||
return selectedAmount || 0;
|
||||
}, [isCustom, customAmount, selectedAmount]);
|
||||
|
||||
const isCustomValid =
|
||||
!isCustom ||
|
||||
(Number(customAmount) >= CUSTOM_MIN && Number(customAmount) <= CUSTOM_MAX);
|
||||
|
||||
const canSubmit = finalAmount > 0 && isCustomValid && !submitting;
|
||||
|
||||
const reset = () => {
|
||||
setSelectedAmount(PRESET_AMOUNTS[0]);
|
||||
setCustomAmount("");
|
||||
setIsCustom(false);
|
||||
setMessage("");
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!canSubmit) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const response = await request<GiftChatResponse>(
|
||||
"POST",
|
||||
"/gifts/chat/initiate",
|
||||
{ receiverId, amount: finalAmount, message, return_path: returnPath }
|
||||
);
|
||||
if (response?.paidFromWallet && response.gift) {
|
||||
toast.success(t("chats.gift.sentSuccess"));
|
||||
onWalletGiftSent(response.gift);
|
||||
reset();
|
||||
onClose();
|
||||
} else if (response?.paymentUrl) {
|
||||
router.push(response.paymentUrl);
|
||||
} else {
|
||||
toast.error(t("giftPage.paymentInfoError"));
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message || t("giftPage.paymentStartError");
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={open}
|
||||
onClose={onClose}
|
||||
height="fit"
|
||||
elevated
|
||||
panelClassName="!p-0 max-h-[85dvh] w-full overflow-y-auto"
|
||||
>
|
||||
<div className="px-4 pb-6 pt-5">
|
||||
<h3 className="mb-4 text-center text-sm font-bold">
|
||||
{t("chats.gift.modalTitle")}
|
||||
</h3>
|
||||
|
||||
<div className="mx-auto flex w-full max-w-sm flex-wrap justify-center gap-2">
|
||||
{PRESET_AMOUNTS.map((amount) => (
|
||||
<button
|
||||
key={amount}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsCustom(false);
|
||||
setSelectedAmount(amount);
|
||||
}}
|
||||
className={filterChipClass(!isCustom && selectedAmount === amount)}
|
||||
>
|
||||
{amount.toLocaleString()} {t("settings.toman")}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCustom(true)}
|
||||
className={filterChipClass(isCustom)}
|
||||
>
|
||||
{t("giftPage.customAmount")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isCustom ? (
|
||||
<div className="mx-auto mt-4 w-full max-w-sm">
|
||||
<RoundedInput
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
placeholder={t("giftPage.customAmountPlaceholder", {
|
||||
min: CUSTOM_MIN.toLocaleString(),
|
||||
max: CUSTOM_MAX.toLocaleString(),
|
||||
})}
|
||||
value={customAmount}
|
||||
onChange={(e) => setCustomAmount(e.target.value)}
|
||||
/>
|
||||
{!isCustomValid && customAmount ? (
|
||||
<p className="mt-2 text-center text-xs text-red-500">
|
||||
{t("giftPage.customAmountInvalid", {
|
||||
min: CUSTOM_MIN.toLocaleString(),
|
||||
max: CUSTOM_MAX.toLocaleString(),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mx-auto mt-4 w-full max-w-sm">
|
||||
<textarea
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder={t("giftPage.messagePlaceholder")}
|
||||
rows={3}
|
||||
maxLength={500}
|
||||
className="w-full resize-none rounded-3xl border border-border-secondary-light bg-secondary-light p-4 text-sm font-medium dark:border-border-secondary-dark dark:bg-secondary-dark"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex justify-center">
|
||||
<RoundedButton
|
||||
type="button"
|
||||
variant="primary"
|
||||
className="h-10 w-40"
|
||||
disabled={!canSubmit}
|
||||
onClick={() => void handleSubmit()}
|
||||
>
|
||||
{submitting ? t("chats.gift.sending") : t("giftPage.payButton")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
124
src/components/chat/GiftRevealModal.tsx
Normal file
124
src/components/chat/GiftRevealModal.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import Modal from "@/components/elements/Modal";
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const PARTICLES = ["🎉", "🎊", "✨", "🎈", "🎁"];
|
||||
|
||||
type Particle = {
|
||||
id: number;
|
||||
emoji: string;
|
||||
x: number;
|
||||
y: number;
|
||||
rotate: number;
|
||||
delay: number;
|
||||
};
|
||||
|
||||
function buildParticles(count: number): Particle[] {
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
const angle = (Math.PI * 2 * i) / count + Math.random() * 0.5;
|
||||
const distance = 90 + Math.random() * 60;
|
||||
return {
|
||||
id: i,
|
||||
emoji: PARTICLES[i % PARTICLES.length],
|
||||
x: Math.cos(angle) * distance,
|
||||
y: Math.sin(angle) * distance,
|
||||
rotate: Math.random() * 360 - 180,
|
||||
delay: Math.random() * 0.15,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
type GiftRevealModalProps = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
amount: number;
|
||||
message?: string | null;
|
||||
};
|
||||
|
||||
export default function GiftRevealModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
amount,
|
||||
message,
|
||||
}: GiftRevealModalProps) {
|
||||
const { t } = useTranslation("common");
|
||||
const particles = useMemo(() => buildParticles(14), [isOpen]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
height="fit"
|
||||
elevated
|
||||
panelClassName="!bg-transparent !shadow-none !border-none max-w-sm w-full"
|
||||
>
|
||||
<div className="relative flex flex-col items-center gap-4 py-10">
|
||||
<div className="pointer-events-none absolute left-1/2 top-16 -translate-x-1/2">
|
||||
<AnimatePresence>
|
||||
{isOpen &&
|
||||
particles.map((p) => (
|
||||
<motion.span
|
||||
key={p.id}
|
||||
className="absolute text-2xl"
|
||||
initial={{ x: 0, y: 0, opacity: 1, scale: 0.4, rotate: 0 }}
|
||||
animate={{
|
||||
x: p.x,
|
||||
y: p.y,
|
||||
opacity: 0,
|
||||
scale: 1,
|
||||
rotate: p.rotate,
|
||||
}}
|
||||
transition={{ duration: 1.4, delay: p.delay, ease: "easeOut" }}
|
||||
>
|
||||
{p.emoji}
|
||||
</motion.span>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ scale: 0.3, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ type: "spring", stiffness: 260, damping: 16 }}
|
||||
className="flex h-20 w-20 items-center justify-center rounded-full bg-gradient-to-b from-pink-400 to-purple-500 text-4xl shadow-lg"
|
||||
>
|
||||
🎁
|
||||
</motion.div>
|
||||
|
||||
<motion.p
|
||||
initial={{ scale: 0.6, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ delay: 0.2, type: "spring", stiffness: 220, damping: 14 }}
|
||||
className="text-2xl font-extrabold text-white drop-shadow"
|
||||
>
|
||||
{amount.toLocaleString()} {t("settings.toman")}
|
||||
</motion.p>
|
||||
|
||||
{message ? (
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.35 }}
|
||||
className="max-w-[260px] text-center text-sm text-white/90"
|
||||
>
|
||||
{message}
|
||||
</motion.p>
|
||||
) : null}
|
||||
|
||||
<p className="text-xs text-white/70">{t("chats.gift.creditedHint")}</p>
|
||||
|
||||
<RoundedButton
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="!border-transparent mt-2 h-9 w-32 !bg-white/15 text-white"
|
||||
>
|
||||
{t("posts.close")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -31,6 +31,7 @@ interface MessageInputProps {
|
||||
onSelfDestructChange?: (seconds: number | null) => void;
|
||||
viewOnceMedia?: boolean;
|
||||
onViewOnceChange?: (value: boolean) => void;
|
||||
onGiftClick?: () => void;
|
||||
}
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
@@ -56,15 +57,20 @@ const MessageInput = ({
|
||||
onSelfDestructChange,
|
||||
viewOnceMedia = false,
|
||||
onViewOnceChange,
|
||||
onGiftClick,
|
||||
}: MessageInputProps) => {
|
||||
const { t } = useTranslation("common");
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [recordingTime, setRecordingTime] = useState(0);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [isLocked, setIsLocked] = useState(false);
|
||||
const [dragProgress, setDragProgress] = useState(0);
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const audioChunksRef = useRef<Blob[]>([]);
|
||||
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const dragStartYRef = useRef<number | null>(null);
|
||||
const LOCK_THRESHOLD_PX = 80;
|
||||
const attachBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||
const videoInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -125,6 +131,9 @@ const MessageInput = ({
|
||||
mediaRecorderRef.current.stop();
|
||||
}
|
||||
setIsRecording(false);
|
||||
setIsLocked(false);
|
||||
setDragProgress(0);
|
||||
dragStartYRef.current = null;
|
||||
if (!shouldSend) {
|
||||
audioChunksRef.current = [];
|
||||
}
|
||||
@@ -164,12 +173,30 @@ const MessageInput = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleMicPointerDown = () => {
|
||||
if (!hasText && !blocked_you) startRecording();
|
||||
const handleMicPointerDown = (e: React.PointerEvent<HTMLButtonElement>) => {
|
||||
if (!hasText && !blocked_you) {
|
||||
dragStartYRef.current = e.clientY;
|
||||
setIsLocked(false);
|
||||
setDragProgress(0);
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
startRecording();
|
||||
}
|
||||
};
|
||||
|
||||
const handleMicPointerMove = (e: React.PointerEvent<HTMLButtonElement>) => {
|
||||
if (!isRecording || isLocked || dragStartYRef.current == null) return;
|
||||
const delta = dragStartYRef.current - e.clientY;
|
||||
setDragProgress(Math.max(0, Math.min(1, delta / LOCK_THRESHOLD_PX)));
|
||||
if (delta >= LOCK_THRESHOLD_PX) {
|
||||
setIsLocked(true);
|
||||
dragStartYRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleMicPointerUp = () => {
|
||||
if (isRecording) stopRecording(true);
|
||||
if (isRecording && !isLocked) stopRecording(true);
|
||||
dragStartYRef.current = null;
|
||||
setDragProgress(0);
|
||||
};
|
||||
|
||||
const handleAttachmentSelect = (type: AttachmentType) => {
|
||||
@@ -296,6 +323,17 @@ const MessageInput = ({
|
||||
onSelect={handleAttachmentSelect}
|
||||
anchorRef={attachBtnRef}
|
||||
/>
|
||||
{onGiftClick && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={blocked_you || isRecording}
|
||||
onClick={onGiftClick}
|
||||
className={`${chatHeaderCircleBtn} gentle-transition disabled:opacity-40`}
|
||||
aria-label={t("chats.aria.gift")}
|
||||
>
|
||||
<BoldIcon name="gift" size={20} tinted className="text-current" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Glass input box */}
|
||||
@@ -335,14 +373,35 @@ const MessageInput = ({
|
||||
<span className="font-mono text-xs font-bold">
|
||||
{formatTime(recordingTime)}
|
||||
</span>
|
||||
{!isLocked && (
|
||||
<span className="text-[10px] font-normal text-neutral-400">
|
||||
{t("chats.voice.slideUpToLock")}
|
||||
</span>
|
||||
)}
|
||||
{isLocked && (
|
||||
<BoldIcon name="lock" size={13} tinted className="text-neutral-400" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => stopRecording(false)}
|
||||
className="rounded-full p-1"
|
||||
aria-label={t("chats.aria.cancelVoice")}
|
||||
>
|
||||
<BoldIcon name="trash" size={18} tinted className="text-current" />
|
||||
</button>
|
||||
{isLocked && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => stopRecording(true)}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-full bg-[#0095f6] text-white"
|
||||
aria-label={t("chats.aria.sendVoice")}
|
||||
>
|
||||
<ChatBoldIcon name="send" size={14} className="text-current -mr-0.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => stopRecording(false)}
|
||||
className="rounded-full p-1"
|
||||
>
|
||||
<BoldIcon name="stop" size={18} tinted className="text-current" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<textarea
|
||||
@@ -389,24 +448,37 @@ const MessageInput = ({
|
||||
<ChatBoldIcon name="send" size={20} className="text-current -mr-0.5" />
|
||||
</motion.button>
|
||||
) : (
|
||||
<motion.button
|
||||
key="mic"
|
||||
initial={{ scale: 0.6, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.6, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
type="button"
|
||||
disabled={blocked_you}
|
||||
onPointerDown={handleMicPointerDown}
|
||||
onPointerUp={handleMicPointerUp}
|
||||
onPointerLeave={() => isRecording && stopRecording(true)}
|
||||
className={`${chatHeaderCircleBtn} gentle-transition ${
|
||||
isRecording ? "scale-110 !bg-red-500 text-white" : ""
|
||||
} active:scale-90`}
|
||||
aria-label={t("chats.aria.recordVoice")}
|
||||
>
|
||||
{isRecording ? <BoldIcon name="stop" size={20} tinted className="text-white" /> : <ChatBoldIcon name="microphone" size={22} className="text-current" />}
|
||||
</motion.button>
|
||||
<motion.div key="mic" className="relative">
|
||||
{isRecording && !isLocked && (
|
||||
<div
|
||||
className="pointer-events-none absolute bottom-full right-1/2 mb-2 flex translate-x-1/2 flex-col items-center gap-1"
|
||||
style={{ opacity: 0.4 + dragProgress * 0.6 }}
|
||||
>
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-black/40 text-white backdrop-blur-sm">
|
||||
<BoldIcon name="lock" size={14} tinted className="text-white" />
|
||||
</span>
|
||||
<BoldIcon name="arrow-up-2" size={12} tinted className="text-neutral-400" />
|
||||
</div>
|
||||
)}
|
||||
<motion.button
|
||||
initial={{ scale: 0.6, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.6, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
type="button"
|
||||
disabled={blocked_you}
|
||||
onPointerDown={handleMicPointerDown}
|
||||
onPointerMove={handleMicPointerMove}
|
||||
onPointerUp={handleMicPointerUp}
|
||||
onPointerLeave={() => isRecording && !isLocked && stopRecording(true)}
|
||||
className={`${chatHeaderCircleBtn} gentle-transition ${
|
||||
isRecording ? "scale-110 !bg-red-500 text-white" : ""
|
||||
} active:scale-90`}
|
||||
aria-label={t("chats.aria.recordVoice")}
|
||||
>
|
||||
{isRecording ? <BoldIcon name="stop" size={20} tinted className="text-white" /> : <ChatBoldIcon name="microphone" size={22} className="text-current" />}
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
67
src/components/chat/SharedGiftBubble.tsx
Normal file
67
src/components/chat/SharedGiftBubble.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
import GiftRevealModal from "./GiftRevealModal";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface SharedGiftPayload {
|
||||
giftId: string;
|
||||
amount: number;
|
||||
message?: string | null;
|
||||
}
|
||||
|
||||
export function parseSharedGift(content: string): SharedGiftPayload | null {
|
||||
if (!content) return null;
|
||||
try {
|
||||
const line = content.split("\n")[0];
|
||||
const j = JSON.parse(line);
|
||||
if (j?.e2ee) return null;
|
||||
return j.sharedGift ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default function SharedGiftBubble({
|
||||
data,
|
||||
isSender,
|
||||
}: {
|
||||
data: SharedGiftPayload;
|
||||
isSender: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation("common");
|
||||
const { request } = useAxios();
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
|
||||
const handleOpen = () => {
|
||||
setRevealed(true);
|
||||
if (!isSender && data.giftId) {
|
||||
void request("POST", `/gifts/${data.giftId}/open`, {}, { noToast: true }).catch(
|
||||
() => {}
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpen}
|
||||
className="flex w-[210px] flex-col items-center gap-2 rounded-2xl border border-white/20 bg-gradient-to-b from-pink-500/25 to-purple-500/25 px-4 py-5 text-center active:scale-[0.98]"
|
||||
>
|
||||
<BoldIcon name="gift" size={36} tinted className="text-pink-300" />
|
||||
<span className="text-xs font-semibold opacity-90">
|
||||
{t("chats.gift.tapToOpen")}
|
||||
</span>
|
||||
</button>
|
||||
<GiftRevealModal
|
||||
isOpen={revealed}
|
||||
onClose={() => setRevealed(false)}
|
||||
amount={data.amount}
|
||||
message={data.message}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -107,8 +107,6 @@ export function CourseManager() {
|
||||
`/academy/academy/get/course?page=${page}&limit=${itemsPerPage}`
|
||||
);
|
||||
|
||||
console.log("پاسخ سرور:", response);
|
||||
|
||||
// توجه: با توجه به ساختار دیتای شما
|
||||
if (response?.data?.courses) {
|
||||
setCourses(response.data.courses);
|
||||
@@ -126,7 +124,7 @@ export function CourseManager() {
|
||||
toast.error(t("academyDashboard.fetchStructureError"));
|
||||
}
|
||||
} catch (err) {
|
||||
console.log("get error:", err);
|
||||
console.error("get error:", err);
|
||||
toast.error(t("academyDashboard.fetchError"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -212,7 +210,7 @@ export function CourseManager() {
|
||||
setSelectedCourse(undefined);
|
||||
await fetchCourses(currentPage);
|
||||
} catch (err) {
|
||||
console.log("Upload error:", err);
|
||||
console.error("Upload error:", err);
|
||||
toast.error(t("academyCourse.saveError"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -240,13 +238,6 @@ export function CourseManager() {
|
||||
}
|
||||
// اگر عکس جدیدی انتخاب نشده، فیلد course_image رو ارسال نکن
|
||||
|
||||
console.log("دادههای ارسالی برای ویرایش:", {
|
||||
courseId: data._id,
|
||||
name: data.cuorse_name,
|
||||
price: data.price,
|
||||
hasNewImage: !!fileInput?.files?.[0],
|
||||
});
|
||||
|
||||
const response = await request(
|
||||
"PATCH",
|
||||
"/academy/academy/update/course",
|
||||
@@ -258,14 +249,12 @@ export function CourseManager() {
|
||||
}
|
||||
);
|
||||
|
||||
console.log("پاسخ سرور:", response);
|
||||
|
||||
toast.success(t("academyCourse.updateSuccess"));
|
||||
setIsDialogOpen(false);
|
||||
setSelectedCourse(undefined);
|
||||
await fetchCourses(currentPage);
|
||||
} catch (err) {
|
||||
console.log("Upload error:", err);
|
||||
console.error("Upload error:", err);
|
||||
toast.error(t("academyCourse.updateError"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -285,7 +274,7 @@ export function CourseManager() {
|
||||
// بعد از حذف، صفحه جاری را مجدداً fetch کن
|
||||
fetchCourses(currentPage);
|
||||
} catch (err) {
|
||||
console.log("Upload error:", err);
|
||||
console.error("Upload error:", err);
|
||||
toast.error(t("academyCourse.deleteError"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -322,13 +311,6 @@ export function CourseManager() {
|
||||
// نمایش لودینگ
|
||||
loadingToastId = toast.loading(t("academyCourse.connectingPayment"));
|
||||
|
||||
console.log("ارسال درخواست پرداخت:", {
|
||||
courseId: selectedCourseId,
|
||||
planDuration: plan.duration,
|
||||
planLabel: plan.label,
|
||||
price: plan.price,
|
||||
});
|
||||
|
||||
const response = await request(
|
||||
"POST",
|
||||
"/academy/academy/course/payment",
|
||||
@@ -341,8 +323,6 @@ export function CourseManager() {
|
||||
// بستن لودینگ
|
||||
toast.dismiss(loadingToastId);
|
||||
|
||||
console.log("پاسخ کامل سرور:", response);
|
||||
|
||||
// پردازش پاسخ (ساختارهای مختلف احتمالی)
|
||||
let authority = null;
|
||||
let paymentUrl = null;
|
||||
@@ -360,7 +340,6 @@ export function CourseManager() {
|
||||
}
|
||||
|
||||
if (paymentUrl) {
|
||||
console.log("هدایت به درگاه:", paymentUrl);
|
||||
window.open(paymentUrl, "_blank", "noopener,noreferrer");
|
||||
toast.success(t("academyCourse.redirectedToPayment"));
|
||||
|
||||
@@ -422,8 +401,6 @@ export function CourseManager() {
|
||||
setSelectedCourseId(courseId);
|
||||
};
|
||||
const handleAddVideo = async (data: any) => {
|
||||
console.log("دادههای دریافتی:", data);
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
@@ -563,7 +540,7 @@ export function CourseManager() {
|
||||
<SubscriptionCard
|
||||
benefits={[t("academyCourse.highlightBenefit")]}
|
||||
plans={subscriptionPlans}
|
||||
currency={t("constants.toman")}
|
||||
currency={t("settings.toman")}
|
||||
onPurchase={handlePurchase}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -108,6 +108,15 @@ function ModelContent({ user, searchParams }: ModelContentProps) {
|
||||
|
||||
const router = useRouter();
|
||||
const requireAuth = useRequireAuth();
|
||||
const goToGift = () => {
|
||||
if (isOwnProfile) {
|
||||
router.push("/settings/gifts");
|
||||
return;
|
||||
}
|
||||
if (!requireAuth()) return;
|
||||
if (!user?._id) return;
|
||||
router.push(`/gift/${user._id}`);
|
||||
};
|
||||
const sendMsgHandler = async () => {
|
||||
if (!requireAuth()) return;
|
||||
if (!user?.user_name) return;
|
||||
@@ -276,7 +285,7 @@ function ModelContent({ user, searchParams }: ModelContentProps) {
|
||||
profileImage={user?.profile_image}
|
||||
/>
|
||||
{user?.user_type == "user" ? (
|
||||
<div className="grid grid-cols-3 gap-1 md:gap-4">
|
||||
<div className="grid grid-cols-4 gap-1 md:gap-4">
|
||||
{isOwnProfile ? (
|
||||
<>
|
||||
<button
|
||||
@@ -314,6 +323,13 @@ function ModelContent({ user, searchParams }: ModelContentProps) {
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={goToGift}
|
||||
className={profileActionBtnClass}
|
||||
>
|
||||
{t("models.actions.specialGift")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowModelDetailModal(!showModelDetailModal)}
|
||||
|
||||
75
src/components/notifications/NotificationBridge.tsx
Normal file
75
src/components/notifications/NotificationBridge.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useUser } from "@/hooks/useUser";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
acquireChatSocket,
|
||||
joinUserRoom,
|
||||
releaseChatSocket,
|
||||
} from "@/lib/chat/socketClient";
|
||||
|
||||
type IncomingNotification = {
|
||||
type?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Always-mounted, app-wide bridge: asks for browser Notification permission
|
||||
* once per user, then shows every live 'notification' socket event (emitted
|
||||
* server-side alongside NotificationModel.create) as an OS-level Notification.
|
||||
*/
|
||||
export default function NotificationBridge() {
|
||||
const user = useUser();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
if (!user?._id) return;
|
||||
if (typeof Notification === "undefined") return;
|
||||
if (Notification.permission === "default") {
|
||||
void Notification.requestPermission();
|
||||
}
|
||||
}, [user?._id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user?._id) return;
|
||||
|
||||
const socket = acquireChatSocket();
|
||||
const uid = String(user._id);
|
||||
const join = () => joinUserRoom(uid);
|
||||
socket.on("connect", join);
|
||||
if (socket.connected) join();
|
||||
|
||||
const handleNotification = (payload: IncomingNotification) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["notifications"] });
|
||||
|
||||
if (
|
||||
typeof Notification === "undefined" ||
|
||||
Notification.permission !== "granted"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const browserNotif = new Notification(payload.title || "مدستاگرام", {
|
||||
body: payload.description || "",
|
||||
icon: "/favicon.png",
|
||||
});
|
||||
browserNotif.onclick = () => window.focus();
|
||||
} catch {
|
||||
// برخی مرورگرها/وبویوها از Notification API پشتیبانی نمیکنند
|
||||
}
|
||||
};
|
||||
|
||||
socket.on("notification", handleNotification);
|
||||
|
||||
return () => {
|
||||
socket.off("connect", join);
|
||||
socket.off("notification", handleNotification);
|
||||
releaseChatSocket();
|
||||
};
|
||||
}, [user?._id, queryClient]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -4,20 +4,30 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useUser } from "@/hooks/useUser";
|
||||
import { Course, IAdvertising, Project } from "@/types/types";
|
||||
import { Course, IAdvertising, IShopProductListing, Project } from "@/types/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
|
||||
import { SelectedPostLink } from "@/lib/postLinkCaption";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type MyShop = {
|
||||
_id: string;
|
||||
name: string;
|
||||
logo?: string | null;
|
||||
};
|
||||
|
||||
interface PostLinkPickerProps {
|
||||
selectedPackages: SelectedPostLink[];
|
||||
selectedProjects: SelectedPostLink[];
|
||||
selectedBillboards: SelectedPostLink[];
|
||||
selectedShops: SelectedPostLink[];
|
||||
selectedShopProducts: SelectedPostLink[];
|
||||
onPackagesChange: (links: SelectedPostLink[]) => void;
|
||||
onProjectsChange: (links: SelectedPostLink[]) => void;
|
||||
onBillboardsChange: (links: SelectedPostLink[]) => void;
|
||||
onShopsChange: (links: SelectedPostLink[]) => void;
|
||||
onShopProductsChange: (links: SelectedPostLink[]) => void;
|
||||
}
|
||||
|
||||
function LinkListItem({
|
||||
@@ -144,6 +154,157 @@ function LinkSection({
|
||||
);
|
||||
}
|
||||
|
||||
function shopProductImage(listing: IShopProductListing) {
|
||||
const img = listing.images?.[listing.primaryImageIndex] || listing.images?.[0];
|
||||
return img ? IMAGE_BASE_URL + img : null;
|
||||
}
|
||||
|
||||
function ShopLinkSection({
|
||||
shops,
|
||||
loading,
|
||||
selectedShopIds,
|
||||
selectedProductIds,
|
||||
onToggleShop,
|
||||
onToggleProduct,
|
||||
}: {
|
||||
shops: MyShop[];
|
||||
loading: boolean;
|
||||
selectedShopIds: string[];
|
||||
selectedProductIds: string[];
|
||||
onToggleShop: (shop: MyShop) => void;
|
||||
onToggleProduct: (shopId: string, product: { _id: string; title: string }) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("common");
|
||||
const { request } = useAxios();
|
||||
const [expandedShopId, setExpandedShopId] = useState<string | null>(null);
|
||||
const [productsByShop, setProductsByShop] = useState<
|
||||
Record<string, IShopProductListing[]>
|
||||
>({});
|
||||
const [loadingProducts, setLoadingProducts] = useState(false);
|
||||
|
||||
const toggleExpand = async (shopId: string) => {
|
||||
if (expandedShopId === shopId) {
|
||||
setExpandedShopId(null);
|
||||
return;
|
||||
}
|
||||
setExpandedShopId(shopId);
|
||||
if (productsByShop[shopId]) return;
|
||||
setLoadingProducts(true);
|
||||
try {
|
||||
const res = await request<{ docs: IShopProductListing[] }>(
|
||||
"GET",
|
||||
`/shop-products?shopId=${shopId}&limit=100`,
|
||||
null,
|
||||
{ noToast: true }
|
||||
);
|
||||
setProductsByShop((prev) => ({ ...prev, [shopId]: res?.docs || [] }));
|
||||
} catch {
|
||||
setProductsByShop((prev) => ({ ...prev, [shopId]: [] }));
|
||||
} finally {
|
||||
setLoadingProducts(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold text-neutral-600 dark:text-neutral-300">
|
||||
{t("posts.shop")}
|
||||
</p>
|
||||
<p className="text-xs text-neutral-400">{t("common.loading")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (shops.length === 0) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold text-neutral-600 dark:text-neutral-300">
|
||||
{t("posts.shop")} ({t("posts.optional")})
|
||||
</p>
|
||||
<p className="rounded-xl border border-dashed border-neutral-200 px-3 py-2 text-xs text-neutral-400 dark:border-neutral-700">
|
||||
{t("posts.noShop")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold text-neutral-600 dark:text-neutral-300">
|
||||
{t("posts.shop")} ({t("posts.optionalMulti")})
|
||||
</p>
|
||||
<ul className="max-h-64 space-y-1.5 overflow-y-auto rounded-xl border border-neutral-200 p-2 dark:border-neutral-700">
|
||||
{shops.map((shop) => {
|
||||
const isExpanded = expandedShopId === shop._id;
|
||||
const products = productsByShop[shop._id] || [];
|
||||
return (
|
||||
<li key={shop._id}>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex-1">
|
||||
<LinkListItem
|
||||
title={shop.name}
|
||||
imageSrc={shop.logo ? IMAGE_BASE_URL + shop.logo : null}
|
||||
fallbackIcon="shop"
|
||||
isSelected={selectedShopIds.includes(shop._id)}
|
||||
onToggle={() => onToggleShop(shop)}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void toggleExpand(shop._id)}
|
||||
aria-label={t("posts.viewProducts")}
|
||||
className="shrink-0 p-2"
|
||||
>
|
||||
<BoldIcon
|
||||
name="arrow-down-1"
|
||||
size={14}
|
||||
tinted
|
||||
className={cn(
|
||||
"text-neutral-400 transition-transform",
|
||||
isExpanded && "rotate-180"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<ul className="mr-6 mt-1 space-y-1 border-r-2 border-neutral-100 pr-2 dark:border-neutral-800">
|
||||
{loadingProducts && !productsByShop[shop._id] ? (
|
||||
<li className="p-1 text-xs text-neutral-400">
|
||||
{t("common.loading")}
|
||||
</li>
|
||||
) : products.length === 0 ? (
|
||||
<li className="p-1 text-xs text-neutral-400">
|
||||
{t("shops.noProductsYet")}
|
||||
</li>
|
||||
) : (
|
||||
products.map((product) => (
|
||||
<LinkListItem
|
||||
key={product._id}
|
||||
title={product.title}
|
||||
imageSrc={shopProductImage(product)}
|
||||
fallbackIcon="box"
|
||||
isSelected={selectedProductIds.includes(product._id)}
|
||||
onToggle={() =>
|
||||
onToggleProduct(shop._id, {
|
||||
_id: product._id,
|
||||
title: product.title,
|
||||
})
|
||||
}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function courseImageSrc(course: Course) {
|
||||
if (!course.course_image) return null;
|
||||
return course.course_image.startsWith("http")
|
||||
@@ -173,9 +334,13 @@ export default function PostLinkPicker({
|
||||
selectedPackages,
|
||||
selectedProjects,
|
||||
selectedBillboards,
|
||||
selectedShops,
|
||||
selectedShopProducts,
|
||||
onPackagesChange,
|
||||
onProjectsChange,
|
||||
onBillboardsChange,
|
||||
onShopsChange,
|
||||
onShopProductsChange,
|
||||
}: PostLinkPickerProps) {
|
||||
const { t } = useTranslation("common");
|
||||
const { request } = useAxios();
|
||||
@@ -184,9 +349,11 @@ export default function PostLinkPicker({
|
||||
const [courses, setCourses] = useState<Course[]>([]);
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [billboards, setBillboards] = useState<IAdvertising[]>([]);
|
||||
const [shops, setShops] = useState<MyShop[]>([]);
|
||||
const [loadingCourses, setLoadingCourses] = useState(true);
|
||||
const [loadingProjects, setLoadingProjects] = useState(true);
|
||||
const [loadingBillboards, setLoadingBillboards] = useState(true);
|
||||
const [loadingShops, setLoadingShops] = useState(true);
|
||||
|
||||
const loadCourses = useCallback(async () => {
|
||||
setLoadingCourses(true);
|
||||
@@ -262,6 +429,23 @@ export default function PostLinkPicker({
|
||||
}
|
||||
}, [request]);
|
||||
|
||||
const loadShops = useCallback(async () => {
|
||||
setLoadingShops(true);
|
||||
try {
|
||||
const response = await request<{ shops?: MyShop[] }>(
|
||||
"GET",
|
||||
"/shops/mine",
|
||||
null,
|
||||
{ noToast: true }
|
||||
);
|
||||
setShops(Array.isArray(response?.shops) ? response.shops : []);
|
||||
} catch {
|
||||
setShops([]);
|
||||
} finally {
|
||||
setLoadingShops(false);
|
||||
}
|
||||
}, [request]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadCourses();
|
||||
}, [loadCourses]);
|
||||
@@ -274,6 +458,10 @@ export default function PostLinkPicker({
|
||||
void loadBillboards();
|
||||
}, [loadBillboards]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadShops();
|
||||
}, [loadShops]);
|
||||
|
||||
const courseItems = courses
|
||||
.map((c) => ({
|
||||
_id: String(c._id || c.id || ""),
|
||||
@@ -342,6 +530,18 @@ export default function PostLinkPicker({
|
||||
}}
|
||||
fallbackIcon="flash-circle"
|
||||
/>
|
||||
<ShopLinkSection
|
||||
shops={shops}
|
||||
loading={loadingShops}
|
||||
selectedShopIds={selectedShops.map((s) => s._id)}
|
||||
selectedProductIds={selectedShopProducts.map((s) => s._id)}
|
||||
onToggleShop={(shop) =>
|
||||
onShopsChange(toggleLink(selectedShops, "shop", { _id: shop._id, title: shop.name }))
|
||||
}
|
||||
onToggleProduct={(_shopId, product) =>
|
||||
onShopProductsChange(toggleLink(selectedShopProducts, "shopproduct", product))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import { useTranslation } from "react-i18next";
|
||||
import { useAppLanguage } from "@/contexts/LanguageProvider";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { canEditPostWithinWindow } from "@/lib/postEditWindow";
|
||||
|
||||
const REPORT_REASON_IDS = [
|
||||
"inappropriate",
|
||||
@@ -42,7 +41,7 @@ export default function ReelsPostOptionsMenu({
|
||||
const { request } = useAxios();
|
||||
const queryClient = useQueryClient();
|
||||
const router = useRouter();
|
||||
const canEdit = isOwnPost && canEditPostWithinWindow(postData.createdAt);
|
||||
const canEdit = isOwnPost;
|
||||
const [open, setOpen] = useState(false);
|
||||
const [reportOpen, setReportOpen] = useState(false);
|
||||
const [analyticsOpen, setAnalyticsOpen] = useState(false);
|
||||
@@ -50,11 +49,16 @@ export default function ReelsPostOptionsMenu({
|
||||
const [reportText, setReportText] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [isPinned, setIsPinned] = useState(Boolean(postData.is_pinned));
|
||||
const [isArchived, setIsArchived] = useState(Boolean(postData.is_archived));
|
||||
|
||||
useEffect(() => {
|
||||
setIsPinned(Boolean(postData.is_pinned));
|
||||
}, [postData.is_pinned, postData._id]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsArchived(Boolean(postData.is_archived));
|
||||
}, [postData.is_archived, postData._id]);
|
||||
|
||||
const submitReport = async () => {
|
||||
if (!reportReason.trim()) {
|
||||
toast.error(t("posts.selectReportReason"));
|
||||
@@ -109,6 +113,31 @@ export default function ReelsPostOptionsMenu({
|
||||
}
|
||||
};
|
||||
|
||||
const toggleArchive = async () => {
|
||||
try {
|
||||
const res = await request<{ is_archived?: boolean; message?: string }>(
|
||||
"POST",
|
||||
"/account/archive-post",
|
||||
{ postId: postData._id },
|
||||
{ noToast: true }
|
||||
);
|
||||
const archived = Boolean(res?.is_archived);
|
||||
setIsArchived(archived);
|
||||
toast.success(archived ? t("posts.postArchived") : t("posts.postUnarchived"));
|
||||
setOpen(false);
|
||||
void queryClient.invalidateQueries({ queryKey: ["posts"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["archived-posts"] });
|
||||
if (archived) {
|
||||
router.back();
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message = (
|
||||
err as { response?: { data?: { message?: string } } }
|
||||
)?.response?.data?.message;
|
||||
toast.error(message || t("posts.archiveFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
@@ -195,6 +224,13 @@ export default function ReelsPostOptionsMenu({
|
||||
>
|
||||
{isPinned ? t("posts.unpinPost") : t("posts.pinPost")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void toggleArchive()}
|
||||
className="px-5 py-3.5 text-right text-sm active:bg-neutral-100 dark:active:bg-neutral-800"
|
||||
>
|
||||
{isArchived ? t("posts.publishPost") : t("posts.archivePost")}
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -103,24 +103,23 @@ export default function ShareProfileModal({
|
||||
{profileUrl || "—"}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="mx-auto grid w-full max-w-sm grid-cols-2 gap-4">
|
||||
<RoundedButton
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="!border-transparent h-9 w-full !bg-neutral-100 text-neutral-600 dark:!bg-neutral-800 dark:text-neutral-300"
|
||||
>
|
||||
{t("models.shareProfile.close")}
|
||||
</RoundedButton>
|
||||
<RoundedButton
|
||||
type="button"
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
onClick={() => void handleCopy()}
|
||||
className="!border-transparent h-9 w-full !bg-sky-100 text-sky-600"
|
||||
>
|
||||
{copied
|
||||
? t("models.shareProfile.copied")
|
||||
: t("models.shareProfile.copyLink")}
|
||||
</RoundedButton>
|
||||
<RoundedButton
|
||||
type="button"
|
||||
className="w-full"
|
||||
onClick={onClose}
|
||||
>
|
||||
{t("models.shareProfile.close")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -39,7 +39,7 @@ function ProjectRequestsAction({
|
||||
|
||||
const acceptHandler = async () => {
|
||||
try {
|
||||
await request<{ type?: string }>(
|
||||
const response = await request<{ authority?: string; paymentUrl?: string }>(
|
||||
"POST",
|
||||
"/projects/payment-request-step-one/web",
|
||||
{
|
||||
@@ -47,7 +47,11 @@ function ProjectRequestsAction({
|
||||
projectId,
|
||||
}
|
||||
);
|
||||
router.refresh();
|
||||
if (response?.paymentUrl) {
|
||||
router.push(response.paymentUrl);
|
||||
} else if (response?.authority) {
|
||||
router.push(`https://www.zarinpal.com/pg/StartPay/${response.authority}`);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.log(error);
|
||||
}
|
||||
|
||||
@@ -56,17 +56,33 @@ function ProjectWorkroomRate({
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
project_id: projectId,
|
||||
comment: commentText,
|
||||
user_id: targetUserId,
|
||||
};
|
||||
if (!hasRatedUser && rating) body.rate = rating;
|
||||
// نظر و امتیاز تا برگشت از درگاه پرداخت نگه داشته میشود تا بعد از پرداخت باقیمانده ثبت شود
|
||||
if (typeof window !== "undefined") {
|
||||
sessionStorage.setItem(
|
||||
`project-done-${projectId}`,
|
||||
JSON.stringify({
|
||||
comment: commentText,
|
||||
rate: !hasRatedUser && rating ? rating : undefined,
|
||||
user_id: targetUserId,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
await request("POST", "/projects/done/web", body);
|
||||
router.push("/settings/workroom");
|
||||
const response = await request<{ authority?: string; paymentUrl?: string }>(
|
||||
"POST",
|
||||
"/projects/initiate-completion-payment/web",
|
||||
{ projectId }
|
||||
);
|
||||
if (response?.paymentUrl) {
|
||||
router.push(response.paymentUrl);
|
||||
} else if (response?.authority) {
|
||||
router.push(`https://www.zarinpal.com/pg/StartPay/${response.authority}`);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.log(error);
|
||||
const message =
|
||||
(error as { response?: { data?: { message?: string } } })?.response
|
||||
?.data?.message || t("shops.unknownError");
|
||||
toast.error(message);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -110,6 +126,9 @@ function ProjectWorkroomRate({
|
||||
style={{ fontSize: "28px" }}
|
||||
/>
|
||||
)}
|
||||
<p className="max-w-sm text-center text-[11px] text-neutral-500">
|
||||
{t("settings.workroom.payRemainingHint")}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 w-full max-w-sm gap-4 text-sm">
|
||||
<RoundedButton
|
||||
onClick={doneHandler}
|
||||
|
||||
141
src/components/settings/AccountAnalyticsWidget.tsx
Normal file
141
src/components/settings/AccountAnalyticsWidget.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
} from "recharts";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type SeriesPoint = {
|
||||
date: string;
|
||||
posts: number;
|
||||
followers: number;
|
||||
likes: number;
|
||||
comments: number;
|
||||
};
|
||||
|
||||
type AnalyticsResponse = {
|
||||
range: string;
|
||||
series: SeriesPoint[];
|
||||
totals: {
|
||||
posts: number;
|
||||
followers: number;
|
||||
likes: number;
|
||||
comments: number;
|
||||
};
|
||||
};
|
||||
|
||||
const RANGE_OPTIONS = ["7d", "30d", "90d", "all"] as const;
|
||||
type RangeOption = (typeof RANGE_OPTIONS)[number];
|
||||
|
||||
export default function AccountAnalyticsWidget() {
|
||||
const { t } = useTranslation("common");
|
||||
const { request } = useAxios();
|
||||
const [range, setRange] = useState<RangeOption>("30d");
|
||||
const [data, setData] = useState<AnalyticsResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
request<AnalyticsResponse>(
|
||||
"GET",
|
||||
`/account/analytics?range=${range}`,
|
||||
null,
|
||||
{ noToast: true }
|
||||
)
|
||||
.then((res) => setData(res || null))
|
||||
.catch(() => setData(null))
|
||||
.finally(() => setLoading(false));
|
||||
}, [request, range]);
|
||||
|
||||
const totals = data?.totals;
|
||||
const series = data?.series || [];
|
||||
|
||||
return (
|
||||
<div className="my-6 rounded-2xl border border-neutral-200 p-4 dark:border-neutral-800">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<h3 className="text-sm font-bold">{t("settings.analytics.title")}</h3>
|
||||
<div className="flex gap-1 rounded-full bg-neutral-100 p-1 dark:bg-neutral-800">
|
||||
{RANGE_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt}
|
||||
type="button"
|
||||
onClick={() => setRange(opt)}
|
||||
className={`rounded-full px-3 py-1 text-xs font-semibold transition ${
|
||||
range === opt
|
||||
? "bg-white text-neutral-900 shadow dark:bg-neutral-700 dark:text-white"
|
||||
: "text-neutral-500"
|
||||
}`}
|
||||
>
|
||||
{t(`settings.analytics.range.${opt}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 grid grid-cols-4 gap-2 text-center">
|
||||
<div>
|
||||
<p className="text-lg font-bold">{totals?.posts ?? 0}</p>
|
||||
<p className="text-[11px] text-neutral-500">
|
||||
{t("settings.analytics.metrics.posts")}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-bold">{totals?.followers ?? 0}</p>
|
||||
<p className="text-[11px] text-neutral-500">
|
||||
{t("settings.analytics.metrics.followers")}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-bold">{totals?.likes ?? 0}</p>
|
||||
<p className="text-[11px] text-neutral-500">
|
||||
{t("settings.analytics.metrics.likes")}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-bold">{totals?.comments ?? 0}</p>
|
||||
<p className="text-[11px] text-neutral-500">
|
||||
{t("settings.analytics.metrics.comments")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="h-56 w-full animate-pulse rounded-xl bg-neutral-100 dark:bg-neutral-800" />
|
||||
) : series.length === 0 ? (
|
||||
<p className="py-10 text-center text-sm text-neutral-500">
|
||||
{t("settings.analytics.empty")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="h-56 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={series} margin={{ top: 5, right: 10, left: -20, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" opacity={0.2} />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 10 }} />
|
||||
<YAxis tick={{ fontSize: 10 }} allowDecimals={false} />
|
||||
<Tooltip />
|
||||
<Legend
|
||||
formatter={(value) =>
|
||||
t(`settings.analytics.metrics.${value}`)
|
||||
}
|
||||
wrapperStyle={{ fontSize: 11 }}
|
||||
/>
|
||||
<Line type="monotone" dataKey="posts" stroke="#6366f1" strokeWidth={2} dot={false} />
|
||||
<Line type="monotone" dataKey="followers" stroke="#10b981" strokeWidth={2} dot={false} />
|
||||
<Line type="monotone" dataKey="likes" stroke="#ec4899" strokeWidth={2} dot={false} />
|
||||
<Line type="monotone" dataKey="comments" stroke="#f59e0b" strokeWidth={2} dot={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
165
src/components/settings/ActivityPostGrid.tsx
Normal file
165
src/components/settings/ActivityPostGrid.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { Post } from "@/types/types";
|
||||
import { getPostMedia } from "@/lib/explore/postMedia";
|
||||
import ExploreVideoThumb from "@/components/explore/ExploreVideoThumb";
|
||||
import { buildPostPath } from "@/lib/postSlug";
|
||||
import { cacheReelsSeedPost } from "@/lib/reelsSeedPost";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const ASPECT_RATIOS = [
|
||||
"aspect-[2/3]",
|
||||
"aspect-[3/4]",
|
||||
"aspect-[4/5]",
|
||||
"aspect-[5/6]",
|
||||
"aspect-square",
|
||||
"aspect-[3/5]",
|
||||
] as const;
|
||||
const SKELETON_COUNT = 8;
|
||||
|
||||
function hashAspect(postId: string): (typeof ASPECT_RATIOS)[number] {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < postId.length; i++) {
|
||||
hash = (hash + postId.charCodeAt(i)) % ASPECT_RATIOS.length;
|
||||
}
|
||||
return ASPECT_RATIOS[hash];
|
||||
}
|
||||
|
||||
function ActivityCardSkeleton({ seed }: { seed: number }) {
|
||||
const aspect = ASPECT_RATIOS[seed % ASPECT_RATIOS.length];
|
||||
return (
|
||||
<div
|
||||
className={`mb-2 w-full break-inside-avoid overflow-hidden rounded-2xl bg-neutral-200 dark:bg-neutral-800 ${aspect} animate-pulse`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type Props = {
|
||||
endpoint: string;
|
||||
};
|
||||
|
||||
export default function ActivityPostGrid({ endpoint }: Props) {
|
||||
const { t } = useTranslation("common");
|
||||
const { request } = useAxios();
|
||||
const router = useRouter();
|
||||
const [posts, setPosts] = useState<Post[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [fetchingMore, setFetchingMore] = useState(false);
|
||||
|
||||
const loadPage = useCallback(
|
||||
async (pageNum: number) => {
|
||||
const res = await request<{ posts: Post[]; totalPages: number }>(
|
||||
"GET",
|
||||
`${endpoint}?page=${pageNum}&limit=12`,
|
||||
null,
|
||||
{ noToast: true }
|
||||
);
|
||||
return res;
|
||||
},
|
||||
[request, endpoint]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
loadPage(1)
|
||||
.then((res) => {
|
||||
setPosts(res?.posts || []);
|
||||
setHasMore((res?.totalPages || 1) > 1);
|
||||
setPage(1);
|
||||
})
|
||||
.catch(() => setPosts([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, [loadPage]);
|
||||
|
||||
const fetchMore = useCallback(() => {
|
||||
if (fetchingMore || !hasMore) return;
|
||||
setFetchingMore(true);
|
||||
const nextPage = page + 1;
|
||||
loadPage(nextPage)
|
||||
.then((res) => {
|
||||
setPosts((prev) => [...prev, ...(res?.posts || [])]);
|
||||
setHasMore(nextPage < (res?.totalPages || 1));
|
||||
setPage(nextPage);
|
||||
})
|
||||
.catch(() => setHasMore(false))
|
||||
.finally(() => setFetchingMore(false));
|
||||
}, [fetchingMore, hasMore, page, loadPage]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
if (
|
||||
window.innerHeight + window.scrollY >=
|
||||
document.body.offsetHeight - 800
|
||||
) {
|
||||
fetchMore();
|
||||
}
|
||||
};
|
||||
window.addEventListener("scroll", handleScroll);
|
||||
return () => window.removeEventListener("scroll", handleScroll);
|
||||
}, [fetchMore]);
|
||||
|
||||
const openPost = (post: Post) => {
|
||||
cacheReelsSeedPost(post);
|
||||
router.push(buildPostPath(post._id, post));
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="columns-2 gap-2">
|
||||
{Array.from({ length: SKELETON_COUNT }).map((_, i) => (
|
||||
<ActivityCardSkeleton key={i} seed={i} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (posts.length === 0) {
|
||||
return (
|
||||
<p className="py-16 text-center text-sm text-neutral-500">
|
||||
{t("models.nothingFound")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="columns-2 gap-2">
|
||||
{posts.map((post) => {
|
||||
const media = getPostMedia(post);
|
||||
if (!media) return null;
|
||||
const aspect = hashAspect(post._id);
|
||||
return (
|
||||
<button
|
||||
key={post._id}
|
||||
type="button"
|
||||
onClick={() => openPost(post)}
|
||||
className={`gentle-transition relative mb-2 block w-full overflow-hidden rounded-2xl bg-neutral-200 text-right dark:bg-neutral-900 active:scale-[0.98] ${aspect}`}
|
||||
>
|
||||
{media.kind === "video" ? (
|
||||
<ExploreVideoThumb src={media.src} poster={media.poster} />
|
||||
) : (
|
||||
<Image
|
||||
src={media.src}
|
||||
alt=""
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="50vw"
|
||||
unoptimized
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{fetchingMore
|
||||
? Array.from({ length: 4 }).map((_, i) => (
|
||||
<ActivityCardSkeleton key={`more-${i}`} seed={i} />
|
||||
))
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -169,7 +169,7 @@ export default function FavoritesGrid() {
|
||||
if (!posts.length) {
|
||||
return (
|
||||
<p className="py-8 text-center text-sm text-neutral-500">
|
||||
{t("settings.favoritesEmpty")}
|
||||
{t("settings.favorites.empty")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,11 +15,11 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
const SETTINGS_NAV = [
|
||||
{ key: "wallet", href: "/wallet", icon: "card.svg" },
|
||||
{ key: "workroom", href: "/workroom", icon: "box.svg" },
|
||||
{ key: "shop", href: "/shop", icon: "shop-add.svg" },
|
||||
{ key: "billboards", href: "/my-billboards", icon: "flash-circle.svg" },
|
||||
{ key: "academy", href: "/academy/Dashboard", icon: "academy.svg" },
|
||||
{ key: "workroom", href: "/workroom", icon: "box.svg" },
|
||||
{ key: "offers", href: "/offers", icon: "offer.svg" },
|
||||
{ key: "shop", href: "/shop", icon: "shop-add.svg" },
|
||||
{ key: "favorites", href: "/favorites", icon: "bookmark-post.svg" },
|
||||
{ key: "chats", href: "/chats", icon: "sms.svg" },
|
||||
{ key: "notifications", href: "/notifications", icon: "notification.svg" },
|
||||
@@ -55,8 +55,8 @@ export default function SettingsClient() {
|
||||
<span>{t(`settings.nav.${item.key}`)}</span>
|
||||
</Link>
|
||||
))}
|
||||
<Roules />
|
||||
<Suggestions />
|
||||
<Roules />
|
||||
<Link href="/about-us" className="flex items-center gap-2">
|
||||
<Image
|
||||
width={24}
|
||||
|
||||
@@ -14,7 +14,8 @@ export default function SettingsShell({
|
||||
const pathname = usePathname();
|
||||
const isChatThread =
|
||||
/^\/settings\/chats\/[^/]+\/[^/]+$/.test(pathname ?? "") ||
|
||||
/^\/settings\/chats\/rooms\/[^/]+$/.test(pathname ?? "");
|
||||
/^\/settings\/chats\/rooms\/[^/]+$/.test(pathname ?? "") ||
|
||||
/^\/settings\/chats\/shop\/[^/]+\/[^/]+$/.test(pathname ?? "");
|
||||
|
||||
return (
|
||||
<section className={cn(!isChatThread && "pb-24")}>
|
||||
|
||||
69
src/components/settings/gifts/GiftItem.tsx
Normal file
69
src/components/settings/gifts/GiftItem.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import UserInfo from "@/components/main/UserInfo";
|
||||
import { IGift, IReceiver, ISender } from "@/types/types";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
function counterpartyUser(item: IGift, mode: "sent" | "received"): ISender | IReceiver {
|
||||
return mode === "received" ? item.sender : item.receiver;
|
||||
}
|
||||
|
||||
function GiftItem({
|
||||
item,
|
||||
mode = "sent",
|
||||
actionHandler,
|
||||
}: {
|
||||
item: IGift;
|
||||
mode?: "sent" | "received";
|
||||
actionHandler?: (item: IGift) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("common");
|
||||
const user = counterpartyUser(item, mode);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="mb-4 block w-full rounded-3xl border border-border-primary-light p-4 font-semibold"
|
||||
onClick={() => {
|
||||
if (item && actionHandler) actionHandler(item);
|
||||
}}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between max-[350px]:flex-col max-sm:gap-5">
|
||||
<UserInfo
|
||||
profile_image={user?.profile_image}
|
||||
user_level={user?.user_level}
|
||||
first_name={user?.first_name}
|
||||
last_name={user?.last_name}
|
||||
user_name={user?.user_name}
|
||||
is_verified={user?.is_verified}
|
||||
/>
|
||||
<div className="hidden w-full h-[1px] max-[350px]:flex dark:bg-white/30 bg-black/30"></div>
|
||||
<div className="flex flex-col items-end max-[350px]:w-full max-[350px]:flex-row max-[350px]:items-center max-[350px]:justify-between max-sm:items-center max-sm:text-[11px]">
|
||||
<span>{item?.createdAt}</span>
|
||||
<div className="mt-3 flex items-center gap-1 max-[350px]:mt-0">
|
||||
{mode === "sent" ? (
|
||||
<span className="text-[#008D0E]">
|
||||
{Number(item.amount).toLocaleString()} {t("settings.toman")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-neutral-500">
|
||||
{item.reply ? t("settings.gifts.replied") : t("settings.gifts.tapToReply")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{item.message ? (
|
||||
<p className="mt-3 rounded-2xl bg-neutral-100 p-3 text-xs font-normal text-neutral-600 dark:bg-neutral-800 dark:text-neutral-300">
|
||||
{item.message}
|
||||
</p>
|
||||
) : null}
|
||||
{item.reply ? (
|
||||
<p className="mt-2 rounded-2xl bg-sky-50 p-3 text-xs font-normal text-sky-700 dark:bg-sky-950/40 dark:text-sky-300">
|
||||
{t("settings.gifts.yourReply")}: {item.reply}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default GiftItem;
|
||||
74
src/components/settings/gifts/GiftReplyModal.tsx
Normal file
74
src/components/settings/gifts/GiftReplyModal.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Modal from "@/components/elements/Modal";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { IGift } from "@/types/types";
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type GiftReplyModalProps = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
item: IGift | null;
|
||||
onReplied: () => void;
|
||||
};
|
||||
|
||||
function GiftReplyModal({ isOpen, onClose, item, onReplied }: GiftReplyModalProps) {
|
||||
const { t } = useTranslation("common");
|
||||
const { request } = useAxios();
|
||||
const [replyText, setReplyText] = useState(item?.reply || "");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const submitReply = async () => {
|
||||
if (!replyText.trim()) {
|
||||
toast.error(t("settings.gifts.replyRequired"));
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await request("POST", "/gifts/reply", {
|
||||
giftId: item?._id,
|
||||
reply: replyText,
|
||||
});
|
||||
toast.success(t("settings.gifts.replySubmitted"));
|
||||
onReplied();
|
||||
onClose();
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
(err as { response?: { data?: { message?: string } } })?.response
|
||||
?.data?.message || t("shops.unknownError");
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal height="fit" isOpen={isOpen} onClose={onClose}>
|
||||
<div className="flex flex-col items-center gap-4 py-5">
|
||||
<span className="text-sm font-bold">{t("settings.gifts.replyTitle")}</span>
|
||||
{item?.message ? (
|
||||
<p className="w-full rounded-2xl bg-neutral-100 p-3 text-xs text-neutral-600 dark:bg-neutral-800 dark:text-neutral-300">
|
||||
{item.message}
|
||||
</p>
|
||||
) : null}
|
||||
<textarea
|
||||
value={replyText}
|
||||
onChange={(e) => setReplyText(e.target.value)}
|
||||
className="h-24 w-full rounded-3xl border border-border-secondary-light bg-secondary-light p-4 font-medium dark:border-border-secondary-dark dark:bg-secondary-dark"
|
||||
/>
|
||||
<RoundedButton
|
||||
className="mt-2 h-9 w-36"
|
||||
disabled={submitting}
|
||||
onClick={() => void submitReply()}
|
||||
>
|
||||
{t("settings.gifts.submitReply")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default GiftReplyModal;
|
||||
@@ -18,10 +18,10 @@ export default function ShopProductAuthor({
|
||||
<img
|
||||
src={IMAGE_BASE_URL + shopLogo}
|
||||
alt={shopName}
|
||||
className="h-5 w-5 shrink-0 rounded-full object-cover"
|
||||
className="h-8 w-8 shrink-0 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="h-5 w-5 shrink-0 rounded-full bg-neutral-300 dark:bg-neutral-700" />
|
||||
<span className="h-8 w-8 shrink-0 rounded-full bg-neutral-300 dark:bg-neutral-700" />
|
||||
)}
|
||||
<span className="truncate text-xs font-medium text-neutral-600 dark:text-neutral-300">
|
||||
{shopName}
|
||||
|
||||
@@ -22,6 +22,25 @@ function cheapestPrice(listing: IShopProductListing): number | null {
|
||||
return Math.min(...listing.variants.map((v) => v.discount_price || v.price));
|
||||
}
|
||||
|
||||
const ASPECT_RATIOS = [
|
||||
"aspect-[2/3]",
|
||||
"aspect-[3/4]",
|
||||
"aspect-[4/5]",
|
||||
"aspect-[5/6]",
|
||||
"aspect-square",
|
||||
"aspect-[3/5]",
|
||||
] as const;
|
||||
|
||||
const SKELETON_COUNT = 8;
|
||||
|
||||
function hashAspect(id: string): (typeof ASPECT_RATIOS)[number] {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < id.length; i++) {
|
||||
hash = (hash + id.charCodeAt(i)) % ASPECT_RATIOS.length;
|
||||
}
|
||||
return ASPECT_RATIOS[hash];
|
||||
}
|
||||
|
||||
function defaultHref(listing: IShopProductListing): string {
|
||||
const catalogId =
|
||||
typeof listing.catalogProduct === "string"
|
||||
@@ -30,10 +49,12 @@ function defaultHref(listing: IShopProductListing): string {
|
||||
return `/shops/product/${catalogId}`;
|
||||
}
|
||||
|
||||
function ProductCardSkeleton() {
|
||||
function ProductCardSkeleton({ aspect }: { aspect: string }) {
|
||||
return (
|
||||
<article className="mb-2 break-inside-avoid">
|
||||
<div className="relative aspect-square w-full animate-pulse overflow-hidden rounded-2xl bg-neutral-200 dark:bg-neutral-800" />
|
||||
<div
|
||||
className={`relative w-full animate-pulse overflow-hidden rounded-2xl bg-neutral-200 dark:bg-neutral-800 ${aspect}`}
|
||||
/>
|
||||
<div className="flex flex-col gap-1 px-0.5 py-1.5">
|
||||
<div className="h-3 w-20 animate-pulse rounded-full bg-neutral-200 dark:bg-neutral-800" />
|
||||
<div className="h-3 w-14 animate-pulse rounded-full bg-neutral-200 dark:bg-neutral-800" />
|
||||
@@ -56,8 +77,8 @@ export default function ShopProductGrid({
|
||||
return (
|
||||
<div className="px-2 pb-2">
|
||||
<div className="columns-2 gap-2">
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<ProductCardSkeleton key={index} />
|
||||
{Array.from({ length: SKELETON_COUNT }).map((_, index) => (
|
||||
<ProductCardSkeleton key={index} aspect={ASPECT_RATIOS[index % ASPECT_RATIOS.length]} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -83,6 +104,7 @@ export default function ShopProductGrid({
|
||||
typeof listing.shop === "object" && listing.shop
|
||||
? listing.shop
|
||||
: null;
|
||||
const aspect = hashAspect(listing._id);
|
||||
|
||||
return (
|
||||
<article key={listing._id} className="mb-2 break-inside-avoid">
|
||||
@@ -91,7 +113,9 @@ export default function ShopProductGrid({
|
||||
onClick={() => router.push(getHref(listing))}
|
||||
className="gentle-transition block w-full text-right active:scale-[0.98]"
|
||||
>
|
||||
<div className="relative aspect-square w-full overflow-hidden rounded-2xl bg-neutral-200 dark:bg-neutral-900">
|
||||
<div
|
||||
className={`relative w-full overflow-hidden rounded-2xl bg-neutral-200 dark:bg-neutral-900 ${aspect}`}
|
||||
>
|
||||
{primaryImage ? (
|
||||
<Image
|
||||
src={IMAGE_BASE_URL + primaryImage}
|
||||
@@ -124,6 +148,11 @@ export default function ShopProductGrid({
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{listing.shop_count != null && listing.shop_count > 1 && (
|
||||
<span className="text-[11px] font-medium text-[#387E65]">
|
||||
{t("shops.availableInShopsCount", { count: listing.shop_count })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
|
||||
@@ -19,8 +19,13 @@ import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type ShopReelsViewProps = {
|
||||
shopId: string;
|
||||
/** Single-shop mode: fetches that shop's products. Omit when passing `listings` directly (cross-shop mode). */
|
||||
shopId?: string;
|
||||
initialListingId: string;
|
||||
/** Cross-shop mode: a pre-fetched, already-ordered list of listings (e.g. from the discover feed). */
|
||||
listings?: IShopProductListing[];
|
||||
/** Overrides the top-right close button destination (defaults to the shop profile in single-shop mode, or the discover feed otherwise). */
|
||||
backHref?: string;
|
||||
};
|
||||
|
||||
function cheapestPrice(listing: IShopProductListing): number | null {
|
||||
@@ -28,7 +33,20 @@ function cheapestPrice(listing: IShopProductListing): number | null {
|
||||
return Math.min(...listing.variants.map((v) => v.discount_price || v.price));
|
||||
}
|
||||
|
||||
export default function ShopReelsView({ shopId, initialListingId }: ShopReelsViewProps) {
|
||||
function catalogHref(listing: IShopProductListing): string {
|
||||
const catalogId =
|
||||
typeof listing.catalogProduct === "string"
|
||||
? listing.catalogProduct
|
||||
: listing.catalogProduct?._id;
|
||||
return `/shops/product/${catalogId}`;
|
||||
}
|
||||
|
||||
export default function ShopReelsView({
|
||||
shopId,
|
||||
initialListingId,
|
||||
listings: providedListings,
|
||||
backHref,
|
||||
}: ShopReelsViewProps) {
|
||||
const { t } = useTranslation("common");
|
||||
const { request } = useAxios();
|
||||
const requireAuth = useRequireAuth();
|
||||
@@ -36,7 +54,9 @@ export default function ShopReelsView({ shopId, initialListingId }: ShopReelsVie
|
||||
useReelsSnapScroll(scrollRef);
|
||||
const scrolledRef = useRef(false);
|
||||
|
||||
const [listings, setListings] = useState<IShopProductListing[] | null>(null);
|
||||
const [listings, setListings] = useState<IShopProductListing[] | null>(
|
||||
providedListings || null
|
||||
);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [favoriteIds, setFavoriteIds] = useState<Set<string>>(new Set());
|
||||
const [shareListing, setShareListing] = useState<IShopProductListing | null>(null);
|
||||
@@ -44,6 +64,14 @@ export default function ShopReelsView({ shopId, initialListingId }: ShopReelsVie
|
||||
const [commentListingId, setCommentListingId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (providedListings) {
|
||||
setListings(providedListings);
|
||||
setFavoriteIds(
|
||||
new Set(providedListings.filter((l) => isFavorite(l._id)).map((l) => l._id))
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!shopId) return;
|
||||
request<{ docs: IShopProductListing[] }>(
|
||||
"GET",
|
||||
`/shop-products?shopId=${shopId}&limit=100`
|
||||
@@ -57,7 +85,7 @@ export default function ShopReelsView({ shopId, initialListingId }: ShopReelsVie
|
||||
})
|
||||
.catch(() => setListings([]));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [shopId]);
|
||||
}, [shopId, providedListings]);
|
||||
|
||||
const handleToggleFavorite = (listingId: string) => {
|
||||
const next = toggleFavorite(listingId);
|
||||
@@ -127,7 +155,7 @@ export default function ShopReelsView({ shopId, initialListingId }: ShopReelsVie
|
||||
return (
|
||||
<div className="relative min-h-[100dvh] bg-black">
|
||||
<Link
|
||||
href={`/shops/profile/${shopId}`}
|
||||
href={backHref || (shopId ? `/shops/profile/${shopId}` : "/shops/discover")}
|
||||
aria-label={t("common.close")}
|
||||
className="absolute right-4 top-4 z-10 flex h-9 w-9 items-center justify-center rounded-full bg-black/50"
|
||||
>
|
||||
@@ -224,6 +252,11 @@ export default function ShopReelsView({ shopId, initialListingId }: ShopReelsVie
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent p-4 pb-8 text-white">
|
||||
{typeof listing.shop === "object" && listing.shop?.name && (
|
||||
<p className="mb-1 text-xs font-semibold text-neutral-300">
|
||||
{listing.shop.name}
|
||||
</p>
|
||||
)}
|
||||
<p className="mb-1 line-clamp-1 text-base font-bold">{listing.title}</p>
|
||||
{price != null && (
|
||||
<p className="mb-3 text-sm text-neutral-200">
|
||||
@@ -232,7 +265,7 @@ export default function ShopReelsView({ shopId, initialListingId }: ShopReelsVie
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href={`/shops/listing/${listing._id}`}
|
||||
href={catalogHref(listing)}
|
||||
className="inline-block rounded-3xl bg-white px-5 py-2 text-sm font-bold text-black"
|
||||
>
|
||||
{t("shops.viewProduct")}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -59,11 +60,6 @@ export default function BuyerOrderItem({ order }: { order: BuyerOrder }) {
|
||||
<p className="text-xs text-neutral-500">
|
||||
{order.total_amount.toLocaleString()} {t("settings.toman")}
|
||||
</p>
|
||||
{order.order_number && (
|
||||
<p className="text-[11px] text-neutral-400">
|
||||
{t("shops.orderNumberLabel")}: {order.order_number}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end max-sm:items-center max-sm:text-[11px]">
|
||||
@@ -79,6 +75,22 @@ export default function BuyerOrderItem({ order }: { order: BuyerOrder }) {
|
||||
{order.tracking_code}
|
||||
</span>
|
||||
)}
|
||||
{order.order_number && (
|
||||
<span className="mt-1 text-[11px] text-neutral-400">
|
||||
{t("shops.orderNumberLabel")}: {order.order_number}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
router.push(`/shops/orders/${order._id}?autoDownload=1`);
|
||||
}}
|
||||
className="mt-2 flex items-center gap-1 text-[11px] text-[#0095f6]"
|
||||
>
|
||||
<BoldIcon name="document-download" size={14} tinted className="text-current" />
|
||||
{t("shops.downloadInvoice")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,6 +15,7 @@ type SellerOrder = {
|
||||
tracking_code?: string | null;
|
||||
buyerAddressSnapshot?: { first_name?: string | null; last_name?: string | null } | null;
|
||||
listing?: { title: string; images: string[]; primaryImageIndex: number } | string;
|
||||
shop?: { name: string } | string;
|
||||
};
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
@@ -33,6 +34,7 @@ export default function SellerOrderItem({ order }: { order: SellerOrder }) {
|
||||
const { t } = useTranslation("common");
|
||||
const router = useRouter();
|
||||
const listing = typeof order.listing === "object" ? order.listing : null;
|
||||
const shopName = typeof order.shop === "object" ? order.shop?.name : "";
|
||||
const primaryImage =
|
||||
listing?.images?.[listing.primaryImageIndex] || listing?.images?.[0];
|
||||
const buyerName = [
|
||||
@@ -62,14 +64,12 @@ export default function SellerOrderItem({ order }: { order: SellerOrder }) {
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm">{listing?.title}</p>
|
||||
<p className="text-xs text-neutral-500">{buyerName}</p>
|
||||
{shopName && (
|
||||
<p className="text-[11px] text-neutral-400">{shopName}</p>
|
||||
)}
|
||||
<p className="text-xs text-neutral-500">
|
||||
{order.total_amount.toLocaleString()} {t("settings.toman")}
|
||||
</p>
|
||||
{order.order_number && (
|
||||
<p className="text-[11px] text-neutral-400">
|
||||
{t("shops.orderNumberLabel")}: {order.order_number}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end max-sm:items-center max-sm:text-[11px]">
|
||||
@@ -86,6 +86,11 @@ export default function SellerOrderItem({ order }: { order: SellerOrder }) {
|
||||
{order.tracking_code ? `: ${order.tracking_code}` : ""}
|
||||
</span>
|
||||
)}
|
||||
{order.order_number && (
|
||||
<span className="mt-1 text-[11px] text-neutral-400">
|
||||
{t("shops.orderNumberLabel")}: {order.order_number}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
import { IMAGE_BASE_URL, staticIconUrl } from "@/components/main/BaseUrl";
|
||||
import { btnPrimary, btnDefault } from "@/lib/ui/buttonStyles";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -10,6 +12,7 @@ import { useTranslation } from "react-i18next";
|
||||
type ShopInfo = {
|
||||
_id: string;
|
||||
name: string;
|
||||
logo?: string | null;
|
||||
has_physical_location?: boolean;
|
||||
province?: { name?: string } | null;
|
||||
city?: { name?: string } | null;
|
||||
@@ -58,10 +61,34 @@ export default function ShopInfoModal({ isOpen, onClose, shop }: ShopInfoModalPr
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 text-sm text-gray-600 dark:text-neutral-300">
|
||||
<p className="text-base font-semibold text-gray-800 dark:text-neutral-100">
|
||||
<Link
|
||||
href={`/shops/profile/${shop._id}`}
|
||||
className="mb-3 flex items-center gap-2"
|
||||
>
|
||||
<span className="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-full bg-neutral-100 dark:bg-neutral-800">
|
||||
{shop.logo ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={IMAGE_BASE_URL + shop.logo}
|
||||
alt={shop.name}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
src={staticIconUrl("/images/icons/vuesax/bold/shop.svg")}
|
||||
width={18}
|
||||
height={18}
|
||||
alt=""
|
||||
className="dark:invert"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-base font-bold text-gray-800 dark:text-neutral-100">
|
||||
{shop.name}
|
||||
</p>
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<div className="mb-4 text-sm text-gray-600 dark:text-neutral-300">
|
||||
{shop.has_physical_location && (
|
||||
<>
|
||||
<p className="mt-2">
|
||||
|
||||
@@ -133,6 +133,21 @@ export const editUserNavLinks = [
|
||||
href: "/location",
|
||||
icon: "location.svg",
|
||||
},
|
||||
{
|
||||
labelKey: "settings.edit.nav.blockedUsers",
|
||||
href: "/blocked-users",
|
||||
icon: "user-remove.svg",
|
||||
},
|
||||
{
|
||||
labelKey: "settings.edit.nav.archive",
|
||||
href: "/archive",
|
||||
icon: "archive-book.svg",
|
||||
},
|
||||
{
|
||||
labelKey: "settings.edit.nav.activity",
|
||||
href: "/activity",
|
||||
icon: "activity.svg",
|
||||
},
|
||||
];
|
||||
|
||||
export const editEmployerNavLinks = [
|
||||
|
||||
@@ -51,11 +51,8 @@ export function buildProfileSeo(
|
||||
user.bio ||
|
||||
translateCommon(lang, "profileSeo.defaultBio", { siteName });
|
||||
|
||||
const cityPart = city
|
||||
? translateCommon(lang, "profileSeo.inCity", { city })
|
||||
: "";
|
||||
|
||||
const titleCore = [fullName || username, expertise, cityPart]
|
||||
const nameSegments = fullName ? [fullName, username] : [username];
|
||||
const titleCore = [...nameSegments, expertise, city]
|
||||
.filter(Boolean)
|
||||
.join(" – ");
|
||||
|
||||
@@ -69,7 +66,7 @@ export function buildProfileSeo(
|
||||
: "";
|
||||
|
||||
return {
|
||||
title: formatLocalizedSeoTitle(titleCore, lang),
|
||||
title: `${titleCore} | ${siteName}`,
|
||||
description,
|
||||
path,
|
||||
h1: fullName
|
||||
|
||||
@@ -13,11 +13,13 @@ export function buildFullPostCaption(
|
||||
packages: SelectedPostLink[],
|
||||
projects: SelectedPostLink[],
|
||||
billboards: SelectedPostLink[],
|
||||
lang?: AppLanguage
|
||||
lang?: AppLanguage,
|
||||
shops: SelectedPostLink[] = [],
|
||||
shopProducts: SelectedPostLink[] = []
|
||||
): string {
|
||||
const tagLine = taggedUsers.map((u) => `@${u.user_name}`).join(" ");
|
||||
const linkHeader = buildPostLinkCaptionHeader(
|
||||
[...packages, ...projects, ...billboards],
|
||||
[...packages, ...projects, ...billboards, ...shops, ...shopProducts],
|
||||
lang
|
||||
);
|
||||
return [description.trim(), tagLine, linkHeader].filter(Boolean).join("\n");
|
||||
@@ -29,7 +31,9 @@ export function countPostCaptionLength(
|
||||
packages: SelectedPostLink[],
|
||||
projects: SelectedPostLink[],
|
||||
billboards: SelectedPostLink[],
|
||||
lang?: AppLanguage
|
||||
lang?: AppLanguage,
|
||||
shops: SelectedPostLink[] = [],
|
||||
shopProducts: SelectedPostLink[] = []
|
||||
): number {
|
||||
return buildFullPostCaption(
|
||||
description,
|
||||
@@ -37,6 +41,8 @@ export function countPostCaptionLength(
|
||||
packages,
|
||||
projects,
|
||||
billboards,
|
||||
lang
|
||||
lang,
|
||||
shops,
|
||||
shopProducts
|
||||
).length;
|
||||
}
|
||||
|
||||
@@ -55,14 +55,19 @@ export async function getDmConversationKey(
|
||||
const cached = dmKeyCache.get(ck);
|
||||
if (cached) return cached;
|
||||
|
||||
const { privateKey } = await ensureIdentityKeys(me);
|
||||
const peerPubB64 = await fetchPeerPublicKey(peer);
|
||||
if (!peerPubB64) return null;
|
||||
try {
|
||||
const { privateKey } = await ensureIdentityKeys(me);
|
||||
const peerPubB64 = await fetchPeerPublicKey(peer);
|
||||
if (!peerPubB64) return null;
|
||||
|
||||
const peerPub = await importPublicKeySpkiB64(peerPubB64);
|
||||
const key = await deriveDmAesKey(privateKey, peerPub, me, peer);
|
||||
dmKeyCache.set(ck, key);
|
||||
return key;
|
||||
const peerPub = await importPublicKeySpkiB64(peerPubB64);
|
||||
const key = await deriveDmAesKey(privateKey, peerPub, me, peer);
|
||||
dmKeyCache.set(ck, key);
|
||||
return key;
|
||||
} catch {
|
||||
// شبکه/IndexedDB میتواند گذرا خطا بدهد — نباید کل صفحه رمزگشایی را خراب کند
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearDmKeyCache(): void {
|
||||
@@ -95,15 +100,20 @@ export async function decryptDmContent(
|
||||
const remembered = recallOutboundPlaintext(raw);
|
||||
if (remembered) return remembered;
|
||||
|
||||
const key = await getDmConversationKey(myUserId, peerUserId);
|
||||
if (!key) return E2EE_LOCKED_PREVIEW;
|
||||
try {
|
||||
const key = await getDmConversationKey(myUserId, peerUserId);
|
||||
if (!key) return E2EE_LOCKED_PREVIEW;
|
||||
|
||||
const pt = await decryptText(key, raw);
|
||||
if (pt != null) {
|
||||
rememberOutboundPlaintext(raw, pt);
|
||||
return pt;
|
||||
const pt = await decryptText(key, raw);
|
||||
if (pt != null) {
|
||||
rememberOutboundPlaintext(raw, pt);
|
||||
return pt;
|
||||
}
|
||||
return E2EE_LOCKED_PREVIEW;
|
||||
} catch {
|
||||
// خطای گذرا در رمزگشایی یک پیام نباید بقیه پیامهای صفحه را قفل نشان دهد
|
||||
return E2EE_LOCKED_PREVIEW;
|
||||
}
|
||||
return E2EE_LOCKED_PREVIEW;
|
||||
}
|
||||
|
||||
export async function decryptDmMessageList<
|
||||
@@ -120,26 +130,38 @@ export async function decryptDmMessageList<
|
||||
): Promise<T[]> {
|
||||
const out: T[] = [];
|
||||
for (const msg of messages) {
|
||||
// خطای رمزگشایی یک پیام نباید کل صفحه را از حالت رمزگشاییشده خارج کند —
|
||||
// هر پیام مستقل پردازش میشود و در بدترین حالت فقط همان یکی قفل نمایش داده میشود
|
||||
let next = msg;
|
||||
const content = normalizeMessageContent(msg.content);
|
||||
if (content && isE2eeEnvelope(content)) {
|
||||
const decrypted = await decryptDmContent(myUserId, peerUserId, content);
|
||||
next = { ...next, content: decrypted };
|
||||
} else if (typeof msg.content !== "string" && content) {
|
||||
next = { ...next, content };
|
||||
try {
|
||||
const content = normalizeMessageContent(msg.content);
|
||||
if (content && isE2eeEnvelope(content)) {
|
||||
const decrypted = await decryptDmContent(myUserId, peerUserId, content);
|
||||
next = { ...next, content: decrypted };
|
||||
} else if (typeof msg.content !== "string" && content) {
|
||||
next = { ...next, content };
|
||||
}
|
||||
} catch {
|
||||
next = { ...next, content: E2EE_LOCKED_PREVIEW };
|
||||
}
|
||||
if (next.replyTo?.content) {
|
||||
const replyRaw = normalizeMessageContent(next.replyTo.content);
|
||||
if (isE2eeEnvelope(replyRaw)) {
|
||||
const replyContent = await decryptDmContent(
|
||||
myUserId,
|
||||
peerUserId,
|
||||
replyRaw
|
||||
);
|
||||
next = {
|
||||
...next,
|
||||
replyTo: { ...next.replyTo, content: replyContent },
|
||||
};
|
||||
try {
|
||||
if (next.replyTo?.content) {
|
||||
const replyRaw = normalizeMessageContent(next.replyTo.content);
|
||||
if (isE2eeEnvelope(replyRaw)) {
|
||||
const replyContent = await decryptDmContent(
|
||||
myUserId,
|
||||
peerUserId,
|
||||
replyRaw
|
||||
);
|
||||
next = {
|
||||
...next,
|
||||
replyTo: { ...next.replyTo, content: replyContent },
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (next.replyTo) {
|
||||
next = { ...next, replyTo: { ...next.replyTo, content: E2EE_LOCKED_PREVIEW } };
|
||||
}
|
||||
}
|
||||
out.push(next);
|
||||
|
||||
@@ -144,7 +144,10 @@ export function buildLocalizedMetadataFields(
|
||||
const finalDescription = options.description || site.defaultDescription;
|
||||
|
||||
return {
|
||||
title: finalTitle,
|
||||
// {absolute} skips the root layout's "%s | siteName" template — finalTitle
|
||||
// already carries the site-name suffix, so applying the template again
|
||||
// would duplicate it.
|
||||
title: { absolute: finalTitle },
|
||||
description: finalDescription,
|
||||
keywords: options.keywords ?? site.keywords,
|
||||
alternates: buildHreflangAlternates(options.path),
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
const POST_EDIT_WINDOW_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export function canEditPostWithinWindow(
|
||||
createdAt?: string | Date | null
|
||||
): boolean {
|
||||
if (!createdAt) return false;
|
||||
const ts = new Date(createdAt).getTime();
|
||||
if (Number.isNaN(ts)) return false;
|
||||
return Date.now() - ts < POST_EDIT_WINDOW_MS;
|
||||
}
|
||||
|
||||
export function postEditRemainingMs(
|
||||
createdAt?: string | Date | null
|
||||
): number {
|
||||
if (!createdAt) return 0;
|
||||
const ts = new Date(createdAt).getTime();
|
||||
if (Number.isNaN(ts)) return 0;
|
||||
return Math.max(0, POST_EDIT_WINDOW_MS - (Date.now() - ts));
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { DEFAULT_LANGUAGE, type AppLanguage } from "@/lib/i18n/registry";
|
||||
|
||||
export type PostLinkKind = "pkg" | "project" | "billboard";
|
||||
export type PostLinkKind = "pkg" | "project" | "billboard" | "shop" | "shopproduct";
|
||||
|
||||
/** مارکر لینک داخل کپشن */
|
||||
export const POST_LINK_TOKEN_REGEX =
|
||||
/\[\[(pkg|project|billboard):([a-f0-9]{24})\]\]([\s\S]*?)\[\[\/\1\]\]/gi;
|
||||
/\[\[(pkg|project|billboard|shop|shopproduct):([a-f0-9]{24})\]\]([\s\S]*?)\[\[\/\1\]\]/gi;
|
||||
|
||||
/** @deprecated use POST_LINK_TOKEN_REGEX */
|
||||
export const POST_PACKAGE_TOKEN_REGEX =
|
||||
@@ -19,12 +19,16 @@ const FA_LINK_LABELS = {
|
||||
pkg: "پکیج آموزشی",
|
||||
project: "پروژه",
|
||||
billboard: "بیلبورد",
|
||||
shop: "فروشگاه",
|
||||
shopproduct: "کالا",
|
||||
} as const;
|
||||
|
||||
const EN_LINK_LABELS = {
|
||||
pkg: "Training package",
|
||||
project: "Project",
|
||||
billboard: "Billboard",
|
||||
shop: "Shop",
|
||||
shopproduct: "Product",
|
||||
} as const;
|
||||
|
||||
export function getPostLinkLabels(lang: AppLanguage = DEFAULT_LANGUAGE) {
|
||||
@@ -53,6 +57,10 @@ export function buildPostLinkPath(
|
||||
return `/projects/${id}/${name}`;
|
||||
case "billboard":
|
||||
return `/billboards/${id}/${name}`;
|
||||
case "shop":
|
||||
return `/shops/profile/${id}`;
|
||||
case "shopproduct":
|
||||
return `/shops/listing/${id}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,13 +85,15 @@ export function buildPostLinkCaptionHeader(
|
||||
pkg: [],
|
||||
project: [],
|
||||
billboard: [],
|
||||
shop: [],
|
||||
shopproduct: [],
|
||||
};
|
||||
for (const link of list) {
|
||||
groups[link.kind].push(link);
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
(["pkg", "project", "billboard"] as const).forEach((kind) => {
|
||||
(["pkg", "project", "billboard", "shop", "shopproduct"] as const).forEach((kind) => {
|
||||
if (!groups[kind].length) return;
|
||||
const tokens = groups[kind]
|
||||
.map((l) => buildPostLinkCaptionToken(l.kind, l._id, l.title))
|
||||
|
||||
@@ -15,7 +15,9 @@
|
||||
"user": "User",
|
||||
"retry": "Try again",
|
||||
"search": "Search…",
|
||||
"clear": "Clear"
|
||||
"clear": "Clear",
|
||||
"error": "Error",
|
||||
"close": "Close"
|
||||
},
|
||||
"verificationBadge": {
|
||||
"licenseAlt": "License badge",
|
||||
@@ -145,6 +147,19 @@
|
||||
"ratingOptional": "Rating (optional)",
|
||||
"submit": "Submit"
|
||||
},
|
||||
"gifts": {
|
||||
"title": "Special gift",
|
||||
"given": "Gifts given",
|
||||
"received": "Gifts received",
|
||||
"empty": "No gifts yet.",
|
||||
"replied": "Replied",
|
||||
"tapToReply": "Tap to reply",
|
||||
"yourReply": "Your reply",
|
||||
"replyTitle": "Reply to gift",
|
||||
"replyRequired": "Reply text is required",
|
||||
"replySubmitted": "Your reply was submitted",
|
||||
"submitReply": "Submit reply"
|
||||
},
|
||||
"notifications": {
|
||||
"searchPlaceholder": "Search notifications",
|
||||
"empty": "No notifications yet.",
|
||||
@@ -235,6 +250,7 @@
|
||||
"empty": "No projects yet.",
|
||||
"projectRequestsHeading": "Users who applied for this project",
|
||||
"fetchError": "Could not load data",
|
||||
"payRemainingHint": "Completing the project will charge the remaining 70% of the project amount.",
|
||||
"drafts": "Drafts",
|
||||
"draftsEmpty": "No drafts yet.",
|
||||
"draftStep": "Step {{step}}",
|
||||
@@ -251,6 +267,32 @@
|
||||
"cancelled": "Cancelled"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"items": {
|
||||
"likes": "Likes",
|
||||
"comments": "Comments",
|
||||
"academyLikes": "Academy likes",
|
||||
"academyComments": "Academy comments",
|
||||
"billboardLikes": "Billboard likes",
|
||||
"billboardComments": "Billboard comments"
|
||||
}
|
||||
},
|
||||
"analytics": {
|
||||
"title": "Analytics",
|
||||
"empty": "No data to show",
|
||||
"range": {
|
||||
"7d": "7 days",
|
||||
"30d": "30 days",
|
||||
"90d": "90 days",
|
||||
"all": "All time"
|
||||
},
|
||||
"metrics": {
|
||||
"posts": "Posts",
|
||||
"followers": "Followers",
|
||||
"likes": "Likes",
|
||||
"comments": "Comments"
|
||||
}
|
||||
},
|
||||
"edit": {
|
||||
"save": "Save",
|
||||
"add": "Add",
|
||||
@@ -272,6 +314,9 @@
|
||||
"publicRelations": "Public relations",
|
||||
"shaba": "Identity Information",
|
||||
"location": "Location",
|
||||
"blockedUsers": "Blocked users",
|
||||
"archive": "Archive",
|
||||
"activity": "Your activity",
|
||||
"bio": "Bio"
|
||||
},
|
||||
"bio": {
|
||||
@@ -755,6 +800,11 @@
|
||||
"postUnpinned": "Post unpinned",
|
||||
"pinFailed": "Could not pin post (max 3)",
|
||||
"pinnedBadge": "Pinned",
|
||||
"archivePost": "Archive",
|
||||
"publishPost": "Publish post",
|
||||
"postArchived": "Post archived",
|
||||
"postUnarchived": "Post published again",
|
||||
"archiveFailed": "Could not archive post",
|
||||
"reportReasons": {
|
||||
"inappropriate": "Inappropriate content",
|
||||
"spam": "Spam",
|
||||
@@ -806,6 +856,9 @@
|
||||
"noProject": "No projects yet",
|
||||
"billboard": "Billboard",
|
||||
"noBillboard": "No billboards yet",
|
||||
"shop": "Shop",
|
||||
"noShop": "No shops yet",
|
||||
"viewProducts": "View products",
|
||||
"optional": "optional",
|
||||
"optionalMulti": "optional — multiple",
|
||||
"defaultPackageTitle": "Course package",
|
||||
@@ -1008,6 +1061,27 @@
|
||||
"retry": "Pay again"
|
||||
}
|
||||
},
|
||||
"giftPage": {
|
||||
"title": "Special gift",
|
||||
"customAmount": "Custom amount",
|
||||
"customAmountPlaceholder": "Between {{min}} and {{max}} Toman",
|
||||
"customAmountInvalid": "Amount must be between {{min}} and {{max}} Toman",
|
||||
"messagePlaceholder": "Write a message",
|
||||
"payButton": "Pay",
|
||||
"paymentInfoError": "Error retrieving payment info",
|
||||
"paymentStartError": "Error starting payment",
|
||||
"paymentSuccess": {
|
||||
"title": "Payment successful",
|
||||
"message": "Your gift was sent successfully",
|
||||
"sendMessage": "Send message",
|
||||
"later": "Later"
|
||||
},
|
||||
"paymentFailed": {
|
||||
"title": "Payment failed",
|
||||
"message": "Your payment encountered an error.",
|
||||
"retry": "Pay again"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"pageLoad": "Error loading page",
|
||||
"safariHint": "If you're using Safari, clear your browser cache or reload the page.",
|
||||
@@ -1346,6 +1420,7 @@
|
||||
"you": "You"
|
||||
},
|
||||
"empty": "No conversations yet.",
|
||||
"shopChatSubtitle": "Shop chat",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"onlineCount": "{{count}} online",
|
||||
@@ -1369,6 +1444,16 @@
|
||||
"searchInMessages": "Search messages…",
|
||||
"typing": "{{name}} is typing…",
|
||||
"mediaOptions": "Media send options",
|
||||
"voice": {
|
||||
"slideUpToLock": "Slide up to lock"
|
||||
},
|
||||
"gift": {
|
||||
"modalTitle": "Special gift",
|
||||
"tapToOpen": "Tap to open",
|
||||
"sentSuccess": "Your gift was sent",
|
||||
"sending": "Sending…",
|
||||
"creditedHint": "This amount was added to your wallet"
|
||||
},
|
||||
"timedBanner": "Timed message — auto-delete after {{duration}}",
|
||||
"viewOnceBanner": "{{label}} — recipient can view only once",
|
||||
"viewOnceBadge": "1",
|
||||
@@ -1424,6 +1509,9 @@
|
||||
"attach": "Attach",
|
||||
"send": "Send",
|
||||
"recordVoice": "Record voice",
|
||||
"cancelVoice": "Cancel recording",
|
||||
"sendVoice": "Send voice",
|
||||
"gift": "Special gift",
|
||||
"menu": "Menu",
|
||||
"createRoom": "Create chat room",
|
||||
"addUser": "Add user",
|
||||
@@ -1462,6 +1550,10 @@
|
||||
"createRoomTitle": "Create chat room",
|
||||
"roomImage": "Room image",
|
||||
"roomName": "Room name",
|
||||
"idleTimeout": "Room idle timeout",
|
||||
"idleTimeoutDays_one": "{{count}} day",
|
||||
"idleTimeoutDays_other": "{{count}} days",
|
||||
"idleTimeoutHint": "If no message is exchanged for this long, the room is automatically deleted with all its messages. Default: 1 month.",
|
||||
"roomType": "Room type",
|
||||
"public": "Public",
|
||||
"private": "Private",
|
||||
@@ -1499,6 +1591,7 @@
|
||||
"toast": {
|
||||
"fileTooLarge": "File size must not exceed 200 MB.",
|
||||
"fileTooLargeNamed": "{{name}}: exceeds 200 MB",
|
||||
"shopChatVideoTooLarge": "Video size must not exceed 100 MB.",
|
||||
"micDenied": "Microphone access was denied.",
|
||||
"viewOnceDeleteFailed": "Failed to delete view-once message",
|
||||
"viewOnceOpenFailed": "Can't open view-once message",
|
||||
@@ -2065,6 +2158,7 @@
|
||||
"unfollow": "Unfollow",
|
||||
"block": "Block",
|
||||
"unblock": "Unblock",
|
||||
"specialGift": "Special gift",
|
||||
"appearance": "Appearance details",
|
||||
"services": "Service list",
|
||||
"collaboration": "Collaboration request",
|
||||
@@ -2197,6 +2291,7 @@
|
||||
"categoryTitle": "Shop category",
|
||||
"categoryRequired": "Category, sub-category, and a display choice are required",
|
||||
"shippingTitle": "Shipping methods",
|
||||
"shippingMethodRequired": "Please select a shipping method",
|
||||
"shippingMethodRequired": "Select at least one shipping method",
|
||||
"activeShippingMethods": "Active shipping methods",
|
||||
"supplementarySettings": "Supplementary settings & services",
|
||||
@@ -2330,6 +2425,7 @@
|
||||
"productNamePlaceholder": "Enter product name",
|
||||
"productNameRequired": "Product name is required",
|
||||
"productDescriptionPlaceholder": "Product description (optional)",
|
||||
"prefillFromSimilarProductHint": "Category, images, and details from this similar product will be pre-filled in the next steps — you can edit them.",
|
||||
"productCategoryTitle": "Product category",
|
||||
"productCategoryRequired": "Selecting a main category, subcategory, and tertiary category is required",
|
||||
"selectMainCategory": "Select the main category",
|
||||
@@ -2376,10 +2472,12 @@
|
||||
"proceedToPayment": "Pay",
|
||||
"paymentSuccessTitle": "Payment successful",
|
||||
"paymentSuccessHint": "Your order has been placed and is being prepared",
|
||||
"viewOrder": "View order",
|
||||
"viewOrder": "Track order",
|
||||
"backToHome": "Back to home",
|
||||
"paymentFailedTitle": "Payment failed",
|
||||
"paymentFailedHint": "Your payment encountered an error. Please try again",
|
||||
"retryPayment": "Retry payment",
|
||||
"backToShopList": "Back to shops",
|
||||
"orderStatus": {
|
||||
"pending_payment": "Pending payment",
|
||||
"on_hold": "On hold",
|
||||
@@ -2396,6 +2494,7 @@
|
||||
"printLabelA5": "Print A5 label",
|
||||
"printLabelA6": "Print A6 label",
|
||||
"printInvoice": "Print invoice",
|
||||
"downloadInvoice": "Download invoice",
|
||||
"trackingCodeLabel": "Tracking code",
|
||||
"changeStatusLabel": "Change status",
|
||||
"trackingSaved": "Tracking code saved",
|
||||
@@ -2403,6 +2502,8 @@
|
||||
"statusUpdated": "Order status updated",
|
||||
"invoiceTitle": "Invoice",
|
||||
"confirmReceipt": "I received the item",
|
||||
"completePurchase": "Complete purchase",
|
||||
"repurchase": "Buy again",
|
||||
"receiptConfirmed": "Receipt confirmed",
|
||||
"rateShop": "Rate & review",
|
||||
"ratingCommentPlaceholder": "Your review of the shop (optional)",
|
||||
@@ -2419,6 +2520,8 @@
|
||||
"buyerMobileLabel": "Mobile:",
|
||||
"shopInfoTitle": "Shop info",
|
||||
"orderNumberLabel": "Order number",
|
||||
"viewAndBuyButton": "View and buy",
|
||||
"availableInShopsCount": "Available in {{count}} shops",
|
||||
"searchOrderPlaceholder": "Search order number",
|
||||
"provinceRequired": "Province is required",
|
||||
"cityRequired": "City is required",
|
||||
@@ -2444,8 +2547,12 @@
|
||||
"walletShopTab": "Shop",
|
||||
"walletGiftTab": "Gift",
|
||||
"walletChargedTab": "Charged",
|
||||
"walletAcademyTab": "Academy",
|
||||
"academyBalance": "Academy balance",
|
||||
"walletProjectTab": "Project collaboration",
|
||||
"projectBalance": "Project collaboration balance",
|
||||
"availableToWithdraw": "Available to withdraw",
|
||||
"pendingConfirmation": "Pending confirmation",
|
||||
"pendingConfirmation": "Registered orders amount",
|
||||
"withdrawAmountPlaceholder": "Amount (Toman)",
|
||||
"requestWithdrawal": "Request withdrawal",
|
||||
"giftBalance": "Gift balance",
|
||||
|
||||
@@ -15,7 +15,9 @@
|
||||
"user": "کاربر",
|
||||
"retry": "تلاش مجدد",
|
||||
"search": "جستجو…",
|
||||
"clear": "پاک"
|
||||
"clear": "پاک",
|
||||
"error": "خطا",
|
||||
"close": "بستن"
|
||||
},
|
||||
"verificationBadge": {
|
||||
"licenseAlt": "تیک مجوز",
|
||||
@@ -145,6 +147,19 @@
|
||||
"ratingOptional": "امتیاز (اختیاری)",
|
||||
"submit": "ثبت"
|
||||
},
|
||||
"gifts": {
|
||||
"title": "هدیه ویژه",
|
||||
"given": "هدیه دادم",
|
||||
"received": "هدیه گرفتم",
|
||||
"empty": "هدیهای ثبت نشده است.",
|
||||
"replied": "پاسخ داده شد",
|
||||
"tapToReply": "برای پاسخ لمس کنید",
|
||||
"yourReply": "پاسخ شما",
|
||||
"replyTitle": "پاسخ به هدیه",
|
||||
"replyRequired": "متن پاسخ الزامی است",
|
||||
"replySubmitted": "پاسخ شما ثبت شد",
|
||||
"submitReply": "ثبت پاسخ"
|
||||
},
|
||||
"notifications": {
|
||||
"searchPlaceholder": "جستجو در اعلانها",
|
||||
"empty": "اعلانی ثبت نشده است.",
|
||||
@@ -235,6 +250,7 @@
|
||||
"empty": "پروژهای ثبت نشده است.",
|
||||
"projectRequestsHeading": "کاربرانی که برای این پروژه درخواست ارسال کرده اند",
|
||||
"fetchError": "خطا در دریافت اطلاعات",
|
||||
"payRemainingHint": "با تکمیل پروژه، ۷۰٪ باقیمانده مبلغ پروژه پرداخت میشود.",
|
||||
"drafts": "پیشنویسها",
|
||||
"draftsEmpty": "پیشنویسی وجود ندارد.",
|
||||
"draftStep": "مرحله {{step}}",
|
||||
@@ -251,6 +267,32 @@
|
||||
"cancelled": "کنسل شده"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"items": {
|
||||
"likes": "پسند",
|
||||
"comments": "نظرها",
|
||||
"academyLikes": "پسند آموزشگاه",
|
||||
"academyComments": "نظرات آموزشگاه",
|
||||
"billboardLikes": "پسند بیلبورد",
|
||||
"billboardComments": "نظرات بیلبورد"
|
||||
}
|
||||
},
|
||||
"analytics": {
|
||||
"title": "آنالیتیکس",
|
||||
"empty": "دادهای برای نمایش وجود ندارد",
|
||||
"range": {
|
||||
"7d": "۷ روز",
|
||||
"30d": "۳۰ روز",
|
||||
"90d": "۹۰ روز",
|
||||
"all": "همه"
|
||||
},
|
||||
"metrics": {
|
||||
"posts": "پستها",
|
||||
"followers": "دنبالکننده",
|
||||
"likes": "پسندها",
|
||||
"comments": "نظرها"
|
||||
}
|
||||
},
|
||||
"edit": {
|
||||
"save": "ویرایش",
|
||||
"add": "افزودن",
|
||||
@@ -272,6 +314,9 @@
|
||||
"publicRelations": "روابط عمومی",
|
||||
"shaba": "اطلاعات هویتی",
|
||||
"location": "لوکیشن",
|
||||
"blockedUsers": "لیست کاربران مسدود",
|
||||
"archive": "بایگانی",
|
||||
"activity": "فعالیت شما",
|
||||
"bio": "بیو"
|
||||
},
|
||||
"bio": {
|
||||
@@ -755,6 +800,11 @@
|
||||
"postUnpinned": "پین پست برداشته شد",
|
||||
"pinFailed": "پین کردن پست ناموفق بود (حداکثر ۳ پست)",
|
||||
"pinnedBadge": "پین شده",
|
||||
"archivePost": "بایگانی",
|
||||
"publishPost": "انتشار پست",
|
||||
"postArchived": "پست بایگانی شد",
|
||||
"postUnarchived": "پست دوباره منتشر شد",
|
||||
"archiveFailed": "بایگانی کردن پست ناموفق بود",
|
||||
"reportReasons": {
|
||||
"inappropriate": "محتوای نامناسب",
|
||||
"spam": "اسپم",
|
||||
@@ -806,6 +856,9 @@
|
||||
"noProject": "پروژهای ثبت نشده",
|
||||
"billboard": "بیلبورد",
|
||||
"noBillboard": "بیلبوردی ثبت نشده",
|
||||
"shop": "فروشگاه",
|
||||
"noShop": "فروشگاهی ثبت نشده",
|
||||
"viewProducts": "نمایش کالاها",
|
||||
"optional": "اختیاری",
|
||||
"optionalMulti": "اختیاری — چندتایی",
|
||||
"defaultPackageTitle": "پکیج آموزشی",
|
||||
@@ -1008,6 +1061,27 @@
|
||||
"retry": "پرداخت مجدد"
|
||||
}
|
||||
},
|
||||
"giftPage": {
|
||||
"title": "هدیه ویژه",
|
||||
"customAmount": "مبلغ دلخواه",
|
||||
"customAmountPlaceholder": "بین {{min}} تا {{max}} تومان",
|
||||
"customAmountInvalid": "مبلغ باید بین {{min}} تا {{max}} تومان باشد",
|
||||
"messagePlaceholder": "متنی بنویسید",
|
||||
"payButton": "پرداخت",
|
||||
"paymentInfoError": "خطا در دریافت اطلاعات پرداخت",
|
||||
"paymentStartError": "خطا در شروع پرداخت",
|
||||
"paymentSuccess": {
|
||||
"title": "پرداخت موفق",
|
||||
"message": "هدیه شما با موفقیت ارسال شد",
|
||||
"sendMessage": "ارسال پیام",
|
||||
"later": "بعدا"
|
||||
},
|
||||
"paymentFailed": {
|
||||
"title": "پرداخت ناموفق",
|
||||
"message": "پرداخت شما با خطا مواجه شد.",
|
||||
"retry": "پرداخت مجدد"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"pageLoad": "خطا در بارگذاری صفحه",
|
||||
"safariHint": "اگر در Safari هستید، کش مرورگر را پاک کنید یا صفحه را دوباره بارگذاری کنید.",
|
||||
@@ -1346,6 +1420,7 @@
|
||||
"you": "شما"
|
||||
},
|
||||
"empty": "هنوز مکالمهای ندارید.",
|
||||
"shopChatSubtitle": "چت فروشگاه",
|
||||
"online": "آنلاین",
|
||||
"offline": "آفلاین",
|
||||
"onlineCount": "{{count}} آنلاین",
|
||||
@@ -1369,6 +1444,16 @@
|
||||
"searchInMessages": "جستجو در پیامها…",
|
||||
"typing": "{{name}} در حال نوشتن…",
|
||||
"mediaOptions": "گزینههای ارسال مدیا",
|
||||
"voice": {
|
||||
"slideUpToLock": "برای قفل، بکشید بالا"
|
||||
},
|
||||
"gift": {
|
||||
"modalTitle": "هدیه ویژه",
|
||||
"tapToOpen": "برای باز کردن لمس کنید",
|
||||
"sentSuccess": "هدیه شما ارسال شد",
|
||||
"sending": "در حال ارسال…",
|
||||
"creditedHint": "این مبلغ به کیف پول شما اضافه شد"
|
||||
},
|
||||
"timedBanner": "پیام زماندار — حذف خودکار بعد از {{duration}}",
|
||||
"viewOnceBanner": "{{label}} — گیرنده فقط یکبار میتواند ببیند",
|
||||
"viewOnceBadge": "۱",
|
||||
@@ -1424,6 +1509,9 @@
|
||||
"attach": "پیوست",
|
||||
"send": "ارسال",
|
||||
"recordVoice": "ضبط ویس",
|
||||
"cancelVoice": "لغو ضبط",
|
||||
"sendVoice": "ارسال ویس",
|
||||
"gift": "هدیه ویژه",
|
||||
"menu": "منو",
|
||||
"createRoom": "ساخت اتاق چت روم",
|
||||
"addUser": "افزودن کاربر",
|
||||
@@ -1462,6 +1550,9 @@
|
||||
"createRoomTitle": "ساخت اتاق چت روم",
|
||||
"roomImage": "تصویر اتاق",
|
||||
"roomName": "نام اتاق",
|
||||
"idleTimeout": "زمان بیکاری اتاق",
|
||||
"idleTimeoutDays": "{{count}} روز",
|
||||
"idleTimeoutHint": "اگر بعد از این مدت پیامی رد و بدل نشود، اتاق بهطور خودکار با همه پیامهایش حذف میشود. پیشفرض: ۱ ماه.",
|
||||
"roomType": "نوع اتاق",
|
||||
"public": "عمومی",
|
||||
"private": "خصوصی",
|
||||
@@ -1499,6 +1590,7 @@
|
||||
"toast": {
|
||||
"fileTooLarge": "حجم فایل نباید بیشتر از 200 مگابایت باشد.",
|
||||
"fileTooLargeNamed": "{{name}}: حجم بیش از ۲۰۰ مگابایت",
|
||||
"shopChatVideoTooLarge": "حجم ویدیو نباید بیشتر از ۱۰۰ مگابایت باشد.",
|
||||
"micDenied": "دسترسی به میکروفون داده نشد.",
|
||||
"viewOnceDeleteFailed": "حذف پیام یکبارمصرف ناموفق بود",
|
||||
"viewOnceOpenFailed": "باز کردن پیام یکبارمصرف ممکن نیست",
|
||||
@@ -2065,6 +2157,7 @@
|
||||
"unfollow": "لغو دنبال کردن",
|
||||
"block": "مسدود کردن",
|
||||
"unblock": "رفع مسدودیت",
|
||||
"specialGift": "هدیه ویژه",
|
||||
"appearance": "مشخصات ظاهری",
|
||||
"services": "لیست خدمات",
|
||||
"collaboration": "درخواست همکاری",
|
||||
@@ -2197,6 +2290,7 @@
|
||||
"categoryTitle": "دستهبندی فروشگاه",
|
||||
"categoryRequired": "انتخاب دستهبندی، زیردسته و مورد نمایشی الزامی است",
|
||||
"shippingTitle": "روشهای ارسال",
|
||||
"shippingMethodRequired": "انتخاب روش ارسال الزامی است",
|
||||
"shippingMethodRequired": "حداقل یک روش ارسال را انتخاب کنید",
|
||||
"activeShippingMethods": "لیست روشهای ارسال فعال",
|
||||
"supplementarySettings": "تنظیمات مکمل و خدمات",
|
||||
@@ -2330,6 +2424,7 @@
|
||||
"productNamePlaceholder": "نام کالا را وارد کنید",
|
||||
"productNameRequired": "نام کالا الزامی است",
|
||||
"productDescriptionPlaceholder": "توضیحات کالا (اختیاری)",
|
||||
"prefillFromSimilarProductHint": "دستهبندی، تصاویر و مشخصات این کالای مشابه در مراحل بعد از قبل پر میشود؛ میتوانید ویرایش کنید.",
|
||||
"productCategoryTitle": "دستهبندی کالا",
|
||||
"productCategoryRequired": "انتخاب دستهبندی اصلی، زیرگروه و دستهبندی فرعی الزامی است",
|
||||
"selectMainCategory": "دستهبندی اصلی را انتخاب کنید",
|
||||
@@ -2376,10 +2471,12 @@
|
||||
"proceedToPayment": "پرداخت",
|
||||
"paymentSuccessTitle": "پرداخت با موفقیت انجام شد",
|
||||
"paymentSuccessHint": "سفارش شما ثبت شد و در حال آمادهسازی است",
|
||||
"viewOrder": "مشاهده سفارش",
|
||||
"viewOrder": "پیگیری سفارش",
|
||||
"backToHome": "بازگشت به خانه",
|
||||
"paymentFailedTitle": "پرداخت ناموفق بود",
|
||||
"paymentFailedHint": "پرداخت شما با خطا مواجه شد. لطفاً دوباره تلاش کنید",
|
||||
"retryPayment": "تلاش مجدد",
|
||||
"backToShopList": "بازگشت به فروشگاه",
|
||||
"orderStatus": {
|
||||
"pending_payment": "در انتظار پرداخت",
|
||||
"on_hold": "در انتظار بررسی",
|
||||
@@ -2396,6 +2493,7 @@
|
||||
"printLabelA5": "چاپ برچسب A5",
|
||||
"printLabelA6": "چاپ برچسب A6",
|
||||
"printInvoice": "چاپ فاکتور",
|
||||
"downloadInvoice": "دانلود فاکتور",
|
||||
"trackingCodeLabel": "کد رهگیری",
|
||||
"changeStatusLabel": "تغییر وضعیت",
|
||||
"trackingSaved": "کد رهگیری ثبت شد",
|
||||
@@ -2403,6 +2501,8 @@
|
||||
"statusUpdated": "وضعیت سفارش بهروزرسانی شد",
|
||||
"invoiceTitle": "فاکتور خرید",
|
||||
"confirmReceipt": "کالا را دریافت کردم",
|
||||
"completePurchase": "تکمیل خرید",
|
||||
"repurchase": "خرید مجدد",
|
||||
"receiptConfirmed": "دریافت کالا ثبت شد",
|
||||
"rateShop": "ثبت نظر و امتیاز",
|
||||
"ratingCommentPlaceholder": "نظر شما درباره فروشگاه (اختیاری)",
|
||||
@@ -2419,6 +2519,8 @@
|
||||
"buyerMobileLabel": "شماره موبایل:",
|
||||
"shopInfoTitle": "اطلاعات فروشگاه",
|
||||
"orderNumberLabel": "شماره سفارش",
|
||||
"viewAndBuyButton": "نمایش و خرید",
|
||||
"availableInShopsCount": "موجود در {{count}} فروشگاه",
|
||||
"searchOrderPlaceholder": "جستجوی شماره سفارش",
|
||||
"provinceRequired": "انتخاب استان الزامی است",
|
||||
"cityRequired": "انتخاب شهر الزامی است",
|
||||
@@ -2444,8 +2546,12 @@
|
||||
"walletShopTab": "فروشگاه",
|
||||
"walletGiftTab": "هدیه",
|
||||
"walletChargedTab": "شارژ شده",
|
||||
"availableToWithdraw": "قابل تسویه",
|
||||
"pendingConfirmation": "در انتظار تایید",
|
||||
"walletAcademyTab": "آموزشگاه",
|
||||
"academyBalance": "موجودی آموزشگاه",
|
||||
"walletProjectTab": "همکاری پروژه",
|
||||
"projectBalance": "موجودی همکاری پروژه",
|
||||
"availableToWithdraw": "قابل برداشت",
|
||||
"pendingConfirmation": "مبلغ سفارشهای ثبتشده",
|
||||
"withdrawAmountPlaceholder": "مبلغ (تومان)",
|
||||
"requestWithdrawal": "درخواست تسویه",
|
||||
"giftBalance": "موجودی هدیه",
|
||||
|
||||
@@ -126,6 +126,8 @@ export interface IShopProductListing {
|
||||
variants: IShopProductVariant[];
|
||||
status: "pending_shop_approval" | "active" | "inactive";
|
||||
createdAt?: string;
|
||||
/** Number of active listings across all shops sharing this catalog product (discover feed only). */
|
||||
shop_count?: number;
|
||||
}
|
||||
export interface LastPost {
|
||||
_id: string;
|
||||
@@ -370,6 +372,7 @@ export interface Project {
|
||||
selected_user: string | null | SelectedUser;
|
||||
final_price: number | null;
|
||||
final_time: number | null;
|
||||
completionShareCredited?: boolean;
|
||||
reject_reason: string | null;
|
||||
installments: Installment[];
|
||||
ratings: string[];
|
||||
@@ -538,6 +541,7 @@ export interface Post {
|
||||
is_saved?: boolean;
|
||||
is_following?: boolean;
|
||||
is_pinned?: boolean;
|
||||
is_archived?: boolean;
|
||||
rate?: number;
|
||||
type?: string;
|
||||
files: PostFile[];
|
||||
@@ -593,6 +597,17 @@ export interface IReceiver {
|
||||
user_score: string | null;
|
||||
}
|
||||
|
||||
export interface IGift {
|
||||
_id: string;
|
||||
sender: ISender;
|
||||
receiver: IReceiver;
|
||||
amount: number;
|
||||
message: string | null;
|
||||
reply: string | null;
|
||||
repliedAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface IProjectType {
|
||||
name: string;
|
||||
price: string | number;
|
||||
|
||||
@@ -31,7 +31,9 @@ export function generatePageMetadata(options: PageMetadataOptions): Metadata {
|
||||
|
||||
const ogTitle =
|
||||
options.ogTitle ||
|
||||
(typeof localized.title === "string" ? localized.title : options.title);
|
||||
(typeof localized.openGraph?.title === "string"
|
||||
? localized.openGraph.title
|
||||
: options.title);
|
||||
|
||||
return {
|
||||
...localized,
|
||||
|
||||
Reference in New Issue
Block a user