shop full

This commit is contained in:
payacom
2026-08-04 21:12:03 +03:30
parent dc4467d43d
commit 89320d57b9
89 changed files with 5761 additions and 2386 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

After

Width:  |  Height:  |  Size: 781 B

View File

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

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

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

After

Width:  |  Height:  |  Size: 764 B

View File

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

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

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

View File

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

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

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

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

View File

@@ -674,10 +674,10 @@ select {
@keyframes nearby-radar-sweep {
from {
transform: rotate(0deg);
transform: rotate(360deg);
}
to {
transform: rotate(360deg);
transform: rotate(0deg);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

View File

@@ -0,0 +1,24 @@
"use client";
import Container from "@/components/elements/Container";
import PageTitle from "@/components/settings/PageTitle";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import 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;

View File

@@ -0,0 +1,24 @@
"use client";
import Container from "@/components/elements/Container";
import PageTitle from "@/components/settings/PageTitle";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import 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;

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

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

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

View File

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

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

View File

@@ -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 || []) || []
: [];

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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": "موجودی هدیه",

View File

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

View File

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