This commit is contained in:
payacom
2026-08-14 21:31:11 +03:30
parent a92a3ca5e6
commit c879976c56
176 changed files with 30270 additions and 1441 deletions

View File

@@ -100,7 +100,7 @@ function ChangePassword() {
return (
<LocalePageShell>
<Container>
<div className="p-4 flex flex-col items-center h-screen justify-center">
<div className="p-4 flex flex-col items-center h-dvh justify-center">
<AuthHead title={t("auth.changePasswordTitle")} />
<p className="text-sm text-gray-500 text-center mb-4 max-w-sm">
{isEmailReset

View File

@@ -74,7 +74,7 @@ function ForgetPasswordOtp() {
return (
<LocalePageShell>
<Container>
<div className="p-4 flex flex-col items-center h-screen justify-center">
<div className="p-4 flex flex-col items-center h-dvh justify-center">
<AuthHead title={t("auth.forgetPasswordTitle")} />
<AuthOtpStatus
mobile={mobile}

View File

@@ -76,7 +76,7 @@ function ForgetPasswordPage() {
return (
<LocalePageShell>
<Container>
<div className="p-4 flex flex-col items-center h-screen justify-center">
<div className="p-4 flex flex-col items-center h-dvh justify-center">
<AuthHead title={t("auth.forgetPasswordTitle")} />
<p className="text-sm text-gray-500 text-center mb-4 max-w-sm">
{t("auth.forgetPasswordDesc")}

View File

@@ -58,7 +58,7 @@ export default function LoginTwoFactorPage() {
return (
<LocalePageShell>
<Container>
<div className="flex h-screen flex-col items-center justify-center p-4">
<div className="flex h-dvh flex-col items-center justify-center p-4">
<AuthHead title={t("auth.twoFactorTitle")} />
<p className="mb-6 max-w-sm text-center text-sm text-neutral-500">
{t("auth.twoFactorHint")}

View File

@@ -95,7 +95,7 @@ function LoginWithUsername() {
return (
<LocalePageShell>
<Container>
<div className="p-4 flex flex-col items-center h-screen justify-center">
<div className="p-4 flex flex-col items-center h-dvh justify-center">
<AuthHead title={t("auth.login")} />
<form
onSubmit={formik.handleSubmit}

View File

@@ -13,7 +13,7 @@ import * as yup from "yup";
import { useRouter } from "next/navigation";
import { markOtpSent } from "@/lib/auth/otpTimer";
import { getSafeRedirectPath } from "@/lib/auth/postLogin";
import { GoogleSignInButton, InstagramSignInButton } from "@/app/(auth)/AuthProviders";
import { GoogleSignInButton } from "@/app/(auth)/AuthProviders";
import { MOBILE_NUMERIC_INPUT_PROPS } from "@/lib/auth/inputProps";
import { useAuthSessionRedirect } from "@/hooks/useAuthSessionRedirect";
import { useTranslation } from "react-i18next";
@@ -61,7 +61,7 @@ function Login() {
return (
<LocalePageShell>
<Container>
<div className="relative p-4 flex flex-col items-center h-screen justify-center">
<div className="relative p-4 flex flex-col items-center h-dvh justify-center">
<AuthLanguageToggle className="absolute top-6 left-1/2 -translate-x-1/2" />
<AuthHead title={t("auth.login")} />
<form
@@ -93,7 +93,7 @@ function Login() {
</AuthButton>
</form>
<GoogleSignInButton mode="login" />
<InstagramSignInButton mode="login" />
{/* <InstagramSignInButton mode="login" /> */}
<Link href={usernameLoginHref}>
<small className="text-[#FF5C00] font-bold text-xs mt-8 block">
{t("auth.loginWithUsername")}

View File

@@ -79,7 +79,7 @@ function VerifyOtp() {
return (
<LocalePageShell>
<Container>
<div className="p-4 flex flex-col items-center h-screen justify-center">
<div className="p-4 flex flex-col items-center h-dvh justify-center">
<AuthHead title={t("auth.login")} />
<AuthOtpStatus
mobile={mobile}

View File

@@ -72,7 +72,7 @@ function RegisterOtp() {
return (
<LocalePageShell>
<Container>
<div className="p-4 flex flex-col items-center h-screen justify-center">
<div className="p-4 flex flex-col items-center h-dvh justify-center">
<AuthHead title={t("auth.register")} />
<AuthOtpStatus
mobile={mobile}

View File

@@ -11,7 +11,7 @@ import { useFormik } from "formik";
import * as yup from "yup";
import { useRouter } from "next/navigation";
import { markOtpSent } from "@/lib/auth/otpTimer";
import { GoogleSignInButton, InstagramSignInButton } from "@/app/(auth)/AuthProviders";
import { GoogleSignInButton } from "@/app/(auth)/AuthProviders";
import { MOBILE_NUMERIC_INPUT_PROPS } from "@/lib/auth/inputProps";
import { getAxiosErrorMessage } from "@/lib/auth/postLogin";
import { useTranslation } from "react-i18next";
@@ -85,7 +85,7 @@ function Register() {
return (
<LocalePageShell>
<Container>
<div className="relative p-4 flex flex-col items-center h-screen justify-center">
<div className="relative p-4 flex flex-col items-center h-dvh justify-center">
<AuthLanguageToggle className="absolute top-6 left-1/2 -translate-x-1/2" />
<AuthHead title={t("auth.register")} />
<form
@@ -127,7 +127,7 @@ function Register() {
</AuthButton>
</form>
<GoogleSignInButton mode="register" />
<InstagramSignInButton mode="register" />
{/* <InstagramSignInButton mode="register" /> */}
<Link href="/login">
<small className="text-[#292D32] dark:text-neutral-300 font-bold text-xs mt-6 block text-center">
{t("auth.haveAccount")}{" "}

View File

@@ -1,261 +1,198 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
"use client";
import AuthPageLayout, {
AuthFormFooter,
AuthPageContent,
} from "@/components/auth/AuthPageLayout";
import React, { useEffect, useMemo, useState } from "react";
import useAxios from "@/hooks/useAxios";
import { useFormik } from "formik";
import * as yup from "yup";
import { useRouter } from "next/navigation";
import AuthNextButton from "@/components/auth/AuthNextButton";
import toast from "react-hot-toast";
import AuthUserDetails from "@/components/auth/AuthUserDetails";
import SelectBox from "@/components/elements/SelectBox";
import { ICity, IProvince } from "@/types/types";
import Map, { GeolocateControl, Marker } from "react-map-gl";
import "mapbox-gl/dist/mapbox-gl.css";
import Image from "next/image";
import { useTranslation } from "react-i18next";
import LocalePageShell from "@/components/i18n/LocalePageShell";
function LocationPage() {
const { t } = useTranslation("common");
const router = useRouter();
const { request, loading } = useAxios();
const [isCheckedOne, setIsCheckedOne] = useState(false);
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 schema = useMemo(
() =>
yup.object().shape({
markerCoordinate: yup.mixed().required(t("auth.locationRequired")),
address: yup.string().required(t("auth.addressRequired")),
cityId: yup.string().required(t("auth.cityRequired")),
stateId: yup.string().required(t("auth.provinceRequired")),
}),
[t]
);
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();
}, []);
const toggleCheckBox = () => {
setIsCheckedOne(!isCheckedOne);
};
const mobile =
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
const formik = useFormik({
initialValues: {
markerCoordinate: [],
address: "",
cityId: "",
stateId: "",
},
validationSchema: schema,
onSubmit: async (values, { setSubmitting }) => {
try {
await request("POST", "/verify/address", {
mobile: mobile,
address: values.address,
province_id: values.stateId,
city_id: values.cityId,
lat: values.markerCoordinate[1],
lng: values.markerCoordinate[0],
show_location: isCheckedOne,
});
router.push("/verify/national-cart");
} catch (err: any) {
toast.error(err?.response?.data?.message || t("auth.unknownError"));
} finally {
setSubmitting(false);
}
},
});
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 (
<LocalePageShell>
<AuthPageLayout className="address-page">
<form
onSubmit={formik.handleSubmit}
className="flex w-full flex-1 flex-col items-center"
>
<AuthPageContent>
<span className="text-xl font-bold">{t("auth.locationTitle")}</span>
<div className="mt-5">
<AuthUserDetails />
</div>
<small className="font-bold mt-10 mb-4">
{t("auth.locationActivityHint")}
</small>
<div className="w-full max-w-sm flex flex-col items-center">
<SelectBox
className={`mt-4 ${
formik.touched.stateId && formik.errors.stateId
? "border-red-500 dark:border-red-500"
: "border-gray-300"
}`}
value={formik.values.stateId}
onChange={handleProvinceChange}
>
<option disabled value="">
{t("auth.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 mt-2 block text-center">
{formik.errors.stateId}
</small>
)}
<SelectBox
className={`mt-4 ${
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("auth.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 mt-2 block text-center">
{formik.errors.cityId}
</small>
)}
<textarea
name="address"
placeholder={t("auth.addressPlaceholder")}
className={`border mt-4 w-full max-w-[300px] p-3 rounded-2xl border-border-primary-light dark:border-border-primary-dark bg-secondary-light dark:bg-secondary-dark font-medium ${
formik.touched.address && formik.errors.address
? "border-red-500 dark:border-red-500"
: "border-gray-300"
}`}
value={formik.values.address}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
/>
{formik.touched.address && formik.errors.address && (
<small className="text-red-500 mt-2 block">
{formik.errors.address}
</small>
)}
<Map
style={{ height: "calc(100vh - 260px)" }}
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>
<label className="flex items-center gap-2">
<input
className="scale-125"
type="checkbox"
checked={isCheckedOne}
onChange={toggleCheckBox}
/>
<span className="text-xs font-bold">
{t("auth.showLocationPublic")}
</span>
</label>
</div>
</AuthPageContent>
<AuthFormFooter onSkip={() => router.push("/verify/national-cart")}>
<AuthNextButton type="submit" loading={loading} disabled={loading}>
{t("auth.saveAndContinue")}
</AuthNextButton>
</AuthFormFooter>
</form>
</AuthPageLayout>
</LocalePageShell>
);
}
export default LocationPage;
/* eslint-disable @typescript-eslint/no-explicit-any */
"use client";
import AuthPageLayout, {
AuthFormFooter,
AuthPageContent,
} from "@/components/auth/AuthPageLayout";
import React, { useMemo, useState } from "react";
import useAxios from "@/hooks/useAxios";
import { useFormik } from "formik";
import * as yup from "yup";
import { useRouter } from "next/navigation";
import AuthNextButton from "@/components/auth/AuthNextButton";
import toast from "react-hot-toast";
import AuthUserDetails from "@/components/auth/AuthUserDetails";
import CountryProvinceCitySelect, {
type GeoSelection,
countryHasProvinces,
} from "@/components/ui/CountryProvinceCitySelect";
import Map, { GeolocateControl, Marker } from "react-map-gl";
import "mapbox-gl/dist/mapbox-gl.css";
import Image from "next/image";
import { useTranslation } from "react-i18next";
import LocalePageShell from "@/components/i18n/LocalePageShell";
function LocationPage() {
const { t } = useTranslation("common");
const router = useRouter();
const { request, loading } = useAxios();
const [isCheckedOne, setIsCheckedOne] = useState(false);
const [geoSelection, setGeoSelection] = useState<GeoSelection>({
countryId: null,
provinceId: null,
cityId: null,
});
const [selectedLocation, setSelectedLocation] = useState<{
lat: number;
lng: number;
} | null>(null);
const schema = useMemo(
() =>
yup.object().shape({
markerCoordinate: yup.mixed().required(t("auth.locationRequired")),
address: yup.string().required(t("auth.addressRequired")),
}),
[t]
);
const mobile =
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
const formik = useFormik({
initialValues: {
markerCoordinate: [],
address: "",
},
validationSchema: schema,
onSubmit: async (values, { setSubmitting }) => {
const provinceRequired = countryHasProvinces(geoSelection.countryId);
if (provinceRequired && !geoSelection.provinceId) {
toast.error(t("auth.provinceRequired"));
setSubmitting(false);
return;
}
if (provinceRequired && !geoSelection.cityId) {
toast.error(t("auth.cityRequired"));
setSubmitting(false);
return;
}
try {
await request("POST", "/verify/address", {
mobile: mobile,
address: values.address,
country_id: geoSelection.countryId,
province_id: geoSelection.provinceId,
city_id: geoSelection.cityId,
lat: values.markerCoordinate[1],
lng: values.markerCoordinate[0],
show_location: isCheckedOne,
});
router.push("/verify/national-cart");
} catch (err: any) {
toast.error(err?.response?.data?.message || t("auth.unknownError"));
} finally {
setSubmitting(false);
}
},
});
const toggleCheckBox = () => {
setIsCheckedOne(!isCheckedOne);
};
const handleMapClick = (event: any) => {
const { lngLat } = event;
setSelectedLocation({
lat: lngLat.lat,
lng: lngLat.lng,
});
formik.setFieldValue("markerCoordinate", [lngLat.lng, lngLat.lat]);
};
return (
<LocalePageShell>
<AuthPageLayout className="address-page">
<form
onSubmit={formik.handleSubmit}
className="flex w-full flex-1 flex-col items-center"
>
<AuthPageContent>
<span className="text-xl font-bold">{t("auth.locationTitle")}</span>
<div className="mt-5">
<AuthUserDetails />
</div>
<small className="font-bold mt-10 mb-4">
{t("auth.locationActivityHint")}
</small>
<div className="w-full max-w-sm flex flex-col items-center">
<CountryProvinceCitySelect
value={geoSelection}
onChange={setGeoSelection}
className="mt-4 w-full"
/>
<textarea
name="address"
placeholder={t("auth.addressPlaceholder")}
className={`border mt-4 w-full max-w-[300px] p-3 rounded-2xl border-border-primary-light dark:border-border-primary-dark bg-secondary-light dark:bg-secondary-dark font-medium ${
formik.touched.address && formik.errors.address
? "border-red-500 dark:border-red-500"
: "border-gray-300"
}`}
value={formik.values.address}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
/>
{formik.touched.address && formik.errors.address && (
<small className="text-red-500 mt-2 block">
{formik.errors.address}
</small>
)}
<Map
style={{ height: "calc(100dvh - 260px)" }}
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>
<label className="flex items-center gap-2">
<input
className="scale-125"
type="checkbox"
checked={isCheckedOne}
onChange={toggleCheckBox}
/>
<span className="text-xs font-bold">
{t("auth.showLocationPublic")}
</span>
</label>
</div>
</AuthPageContent>
<AuthFormFooter onSkip={() => router.push("/verify/national-cart")}>
<AuthNextButton type="submit" loading={loading} disabled={loading}>
{t("auth.saveAndContinue")}
</AuthNextButton>
</AuthFormFooter>
</form>
</AuthPageLayout>
</LocalePageShell>
);
}
export default LocationPage;

View File

@@ -23,7 +23,7 @@ import { cn } from "@/lib/utils";
import { Course } from "@/types/types";
import useAxios from "@/hooks/useAxios";
import { Comments } from "@/components/academy/CommentsModal";
import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl";
import { buildStorageUrl } from "@/components/main/BaseUrl";
import VerificationBadge from "@/components/main/VerificationBadge";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import { usePathname } from "next/navigation";
@@ -639,7 +639,7 @@ useEffect(() => {
}
return (
<div className="min-h-screen bg-white dark:bg-neutral-950">
<div className="min-h-dvh bg-white dark:bg-neutral-950">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* سمت راست - پخش ویدیو و اطلاعات */}
@@ -826,7 +826,7 @@ useEffect(() => {
<div className="relative h-48 bg-gradient-to-r from-[#FF107D] to-[#FF107D50]">
{courses[0]?.course_image && (
<Image
src={IMAGE_BASE_URL + courses[0]?.course_image}
src={buildStorageUrl(courses[0]?.course_image)}
alt={courses[0]?.cuorse_name || "course"}
fill
className="object-cover"

View File

@@ -8,7 +8,7 @@ import {
AcademyProfileHeadSkeleton,
} from "@/components/academy/AcademySkeletons";
import Container from "@/components/elements/Container";
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
import { buildStorageUrl } from "@/components/main/BaseUrl";
import useAxios from "@/hooks/useAxios";
import { useUserById } from "@/hooks/getUserById";
import { Course, IContactInfo } from "@/types/types";
@@ -247,7 +247,7 @@ export default function AcademyProfileClient() {
<ProfileAvatar
src={
academy.academy_image
? `${IMAGE_BASE_URL}${academy.academy_image}`
? buildStorageUrl(academy.academy_image)
: undefined
}
alt={academyName}

View File

@@ -5,6 +5,7 @@ import MainProjectCard from "@/components/projects/MainProjectCard";
import ProjectRequestForm from "@/components/projects/ProjectPage/ProjectRequestForm";
import ProjectRequests from "@/components/projects/ProjectPage/ProjectRequests";
import PageLoader from "@/components/ui/PageLoader";
import BackButton from "@/components/ui/BackButton";
import useAxios from "@/hooks/useAxios";
import { Project, IProjectRequest, ProjectRole } from "@/types/types";
import { useEffect, useMemo, useState } from "react";
@@ -74,6 +75,7 @@ export default function ProjectDetailClient({ id }: { id: string }) {
return (
<Container className="pb-28">
<BackButton />
<MainProjectCard
project={project}
full

View File

@@ -8,6 +8,7 @@ import MainProjectCard from "@/components/projects/MainProjectCard";
import RoundedDiv from "@/components/elements/RoundedDiv";
import RoundedButton from "@/components/elements/RoundedButton";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import BackButton from "@/components/ui/BackButton";
import { useTranslation } from "react-i18next";
import toast from "react-hot-toast";
import { AxiosError } from "axios";
@@ -117,6 +118,7 @@ function ProjectPaymentPage({ params }: ProjectPaymentProps) {
return (
<LocalePageShell>
<Container>
<BackButton />
<h6 className="mb-4 mt-2 line-clamp-2 text-center text-lg font-bold text-text-blue-light dark:text-text-blue-dark md:text-xl">
{t("projects.payment.title")}
</h6>

View File

@@ -11,6 +11,7 @@ import Image from "next/image";
import MainProjectCard from "@/components/projects/MainProjectCard";
import { useTranslation } from "react-i18next";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import BackButton from "@/components/ui/BackButton";
import toast from "react-hot-toast";
function FailedProject() {
@@ -99,6 +100,7 @@ function FailedProject() {
return (
<LocalePageShell>
<Container>
<BackButton />
<h6 className="mb-4 mt-2 line-clamp-2 text-center text-lg font-bold text-[#FF0000] md:text-xl">
{t("projects.payment.failed")}
</h6>

View File

@@ -12,6 +12,7 @@ import Link from "next/link";
import MainProjectCard from "@/components/projects/MainProjectCard";
import { useTranslation } from "react-i18next";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import BackButton from "@/components/ui/BackButton";
function SuccessProject() {
const { t } = useTranslation("common");
@@ -103,6 +104,7 @@ function SuccessProject() {
return (
<LocalePageShell>
<Container>
<BackButton />
<h6 className="mb-4 mt-2 line-clamp-2 text-center text-lg font-bold text-[#17A600] md:text-xl">
{t("projects.payment.success")}
</h6>

View File

@@ -7,6 +7,7 @@ import { useRouter } from "next/navigation";
import { AnimatePresence, motion, PanInfo } from "framer-motion";
import FilterModal from "@/components/models/FilterModal";
import BoldIcon from "@/components/ui/BoldIcon";
import BackButton from "@/components/ui/BackButton";
import Container from "@/components/elements/Container";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import useAxios from "@/hooks/useAxios";
@@ -328,14 +329,7 @@ export default function SpecialistsPage() {
<LocalePageShell>
<Container className="pb-28">
<div className="mb-4 flex items-center justify-between px-1 pt-2">
<button
type="button"
onClick={() => router.push("/projects")}
className="flex h-9 w-9 items-center justify-center"
aria-label={t("common.back")}
>
<BoldIcon name="arrow-right" size={22} />
</button>
<BackButton href="/projects" />
<h1 className="text-base font-bold">
{t("projects.specialists.title")}
</h1>

View File

@@ -2,6 +2,7 @@
import Container from "@/components/elements/Container";
import RoundedDiv from "@/components/elements/RoundedDiv";
import EditPageHeader from "@/components/settings/EditPageHeader";
import Link from "next/link";
import { useTranslation } from "react-i18next";
@@ -12,7 +13,7 @@ function AboutUs() {
<Container>
<div className="min-h-[500px] flex flex-col justify-around max-w-md mx-auto px-4">
<div className="">
<h6 className="text-center mt-10 font-bold text-xl">{t("aboutPage.title")}</h6>
<EditPageHeader title={t("aboutPage.title")} className="mt-10" />
<div className="flex flex-col w-full mt-20 gap-1 ">
<span>{t("aboutPage.emailLabel")}</span>
<span> modstagram.com@gmail.com</span>

View File

@@ -8,6 +8,7 @@ import MainBillboardCard from "@/components/billboards/MainBillboardCard/MainBil
import RoundedDiv from "@/components/elements/RoundedDiv";
import RoundedButton from "@/components/elements/RoundedButton";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import BackButton from "@/components/ui/BackButton";
import { getBillboardDisplayTypeLabel } from "@/lib/billboards/displayTypeLabel";
import { useRouter } from "next/navigation";
import { useTranslation } from "react-i18next";
@@ -84,6 +85,7 @@ function BillboardPayment({ params }: IBillboardProps) {
return (
<LocalePageShell>
<Container>
<BackButton />
<h6 className="font-bold md:text-xl text-lg text-center mt-2 line-clamp-2 mb-4 text-text-blue-light dark:text-text-blue-dark">
{t("billboards.payment.title")}
</h6>

View File

@@ -8,6 +8,7 @@ import MainBillboardCard from "@/components/billboards/MainBillboardCard/MainBil
import RoundedDiv from "@/components/elements/RoundedDiv";
import RoundedButton from "@/components/elements/RoundedButton";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import BackButton from "@/components/ui/BackButton";
import { getBillboardDisplayTypeLabel } from "@/lib/billboards/displayTypeLabel";
import { useRouter, useSearchParams } from "next/navigation";
import Image from "next/image";
@@ -107,6 +108,7 @@ function FailedBillboard() {
return (
<LocalePageShell>
<Container>
<BackButton />
<h6 className="font-bold md:text-xl text-lg text-center mt-2 line-clamp-2 mb-4 text-[#FF0000] ">
{t("billboards.payment.failed")}
</h6>

View File

@@ -8,6 +8,7 @@ import MainBillboardCard from "@/components/billboards/MainBillboardCard/MainBil
import RoundedDiv from "@/components/elements/RoundedDiv";
import RoundedButton from "@/components/elements/RoundedButton";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import BackButton from "@/components/ui/BackButton";
import { getBillboardDisplayTypeLabel } from "@/lib/billboards/displayTypeLabel";
import { useSearchParams } from "next/navigation";
import Image from "next/image";
@@ -58,6 +59,7 @@ function SuccessBillboard() {
return (
<LocalePageShell>
<Container>
<BackButton />
<h6 className="font-bold md:text-xl text-lg text-center mt-2 line-clamp-2 mb-4 text-[#17A600] ">
{t("billboards.payment.success")}
</h6>

View File

@@ -23,7 +23,7 @@ export default function GlobalError({
return (
<html lang={lang} dir={dir}>
<body className="flex min-h-screen flex-col items-center justify-center gap-4 bg-white px-6 text-center text-neutral-900">
<body className="flex min-h-dvh flex-col items-center justify-center gap-4 bg-white px-6 text-center text-neutral-900">
<p className="text-lg font-bold">{t("errors.appLoad")}</p>
<p className="max-w-md text-sm text-neutral-600">{t("errors.safariIosHint")}</p>
<button

View File

@@ -6,7 +6,7 @@ export default async function Loading() {
const lang = await getServerLanguage();
return (
<div className="flex h-screen w-full flex-col items-center justify-center gap-3 bg-background">
<div className="flex h-dvh w-full flex-col items-center justify-center gap-3 bg-background">
<IOSSpinner size={32} color="#007aff" />
<span className="text-sm text-neutral-500">{translateCommon(lang, "common.loading")}</span>
</div>

View File

@@ -34,13 +34,17 @@ import {
} from "@/lib/postLocationNotice";
import { buildStorageUrl } from "@/components/main/BaseUrl";
import { fetchPostById } from "@/api/fetchPostById";
import SelectBox from "@/components/elements/SelectBox";
import CountryProvinceCitySelect, {
type GeoSelection,
} from "@/components/ui/CountryProvinceCitySelect";
import BackButton from "@/components/ui/BackButton";
import { useDefaultCountryId } from "@/hooks/useDefaultCountryId";
import Cookies from "js-cookie";
import { useRouter, useSearchParams } from "next/navigation";
import React, { useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { ICity, IProvince, User } from "@/types/types";
import { User } from "@/types/types";
type CreateMode = "image" | "video" | "story";
@@ -95,29 +99,20 @@ export default function NewPostPage() {
const [expertiseModalOpen, setExpertiseModalOpen] = useState(false);
const [loadingEdit, setLoadingEdit] = useState(false);
// شهر/استان اختیاری پست — مستقل از موقعیت پروفایل کاربر، مثل بیلبورد/پروژه
const [provinces, setProvinces] = useState<IProvince[] | null>(null);
const [cities, setCities] = useState<ICity[] | null>(null);
const [provinceId, setProvinceId] = useState("");
const [cityId, setCityId] = useState("");
// شهر/استان/کشور اختیاری پست — مستقل از موقعیت پروفایل کاربر، مثل بیلبورد/پروژه
const [postGeoSelection, setPostGeoSelection] = useState<GeoSelection>({
countryId: null,
provinceId: null,
cityId: null,
});
const defaultCountryId = useDefaultCountryId();
useEffect(() => {
request<{ provinces: IProvince[] }>("GET", "/provinces")
.then((res) => setProvinces(res?.provinces || null))
.catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!provinceId) {
setCities(null);
return;
}
request<{ cities: ICity[] }>("GET", `/cities/${provinceId}`)
.then((res) => setCities(res?.cities || []))
.catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [provinceId]);
if (isEditMode || !defaultCountryId) return;
setPostGeoSelection((prev) =>
prev.countryId ? prev : { ...prev, countryId: defaultCountryId }
);
}, [isEditMode, defaultCountryId]);
const modeLabels: Record<CreateMode, string> = useMemo(
() => ({
@@ -472,8 +467,9 @@ export default function NewPostPage() {
linked_shop_id: selectedShops[0]?._id ?? null,
linked_shop_product_id: selectedShopProducts[0]?._id ?? null,
linked_booking_id: selectedBooking[0]?._id ?? null,
province_id: provinceId || null,
city_id: cityId || null,
country_id: postGeoSelection.countryId || null,
province_id: postGeoSelection.provinceId || null,
city_id: postGeoSelection.cityId || null,
}, { noToast: true });
toast.success(t("posts.postPublished"), { id: "post-upload" });
router.push("/");
@@ -537,14 +533,7 @@ export default function NewPostPage() {
<div className="mx-auto flex min-h-[calc(100dvh-8rem)] max-w-lg flex-col bg-background">
{/* Header — Instagram style */}
<header className="sticky top-0 z-10 flex items-center justify-between border-b border-neutral-200 px-4 py-3 dark:border-neutral-800">
<button
type="button"
onClick={() => router.back()}
className="flex h-9 w-9 items-center justify-center"
aria-label={t("common.back")}
>
<BoldIcon name="arrow-right-3" size={22} className="block dark:invert" />
</button>
<BackButton />
<span className="text-base font-semibold">
{isEditMode
? t("posts.editPostTitle")
@@ -716,33 +705,11 @@ export default function NewPostPage() {
onBookingChange={setSelectedBooking}
/>
<div className="mt-4 flex items-center gap-2">
<SelectBox
value={provinceId}
onChange={(e) => {
setProvinceId(e.target.value);
setCityId("");
}}
>
<option value="">{t("posts.provinceOptional")}</option>
{provinces?.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</SelectBox>
<SelectBox
value={cityId}
onChange={(e) => setCityId(e.target.value)}
disabled={!provinceId}
>
<option value="">{t("posts.cityOptional")}</option>
{cities?.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</SelectBox>
<div className="mt-4">
<CountryProvinceCitySelect
value={postGeoSelection}
onChange={setPostGeoSelection}
/>
</div>
</div>
)}

View File

@@ -6,6 +6,7 @@ import {
SidebarInset,
SidebarProvider,
} from "@/components/ui/sidebar"
import BackButton from "@/components/ui/BackButton"
export default function Page() {
return (
@@ -19,9 +20,12 @@ export default function Page() {
>
<SidebarInset>
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
<div className="px-4 pt-4 lg:px-6">
<BackButton />
</div>
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
<SectionCards />
<div className="px-4 lg:px-6">

View File

@@ -3,7 +3,7 @@ import { CourseManager } from "@/components/course/CourseManager";
export default function CoursesPage() {
return (
<div className="min-h-screen bg-background">
<div className="min-h-dvh bg-background">
<CourseManager />
</div>
);

View File

@@ -11,6 +11,7 @@ import Image from "next/image";
import toast from "react-hot-toast";
import { motion, AnimatePresence } from "framer-motion";
import { useTranslation } from "react-i18next";
import BackButton from "@/components/ui/BackButton";
interface PaginationData {
page: number;
@@ -86,7 +87,7 @@ export default function PurchasedCoursesPage() {
if (isLoading) {
return (
<LocalePageShell>
<div className="min-h-screen container mx-auto px-4 py-8 lg:px-6">
<div className="min-h-dvh container mx-auto px-4 py-8 lg:px-6">
<AcademyListSkeleton count={4} />
</div>
</LocalePageShell>
@@ -95,10 +96,11 @@ export default function PurchasedCoursesPage() {
return (
<LocalePageShell>
<div className="min-h-screen bg-white dark:bg-neutral-950">
<div className="min-h-dvh bg-white dark:bg-neutral-950">
<div className="relative bg-gradient-to-r from-purple-400 via-purple-600 to-pink-600 dark:from-purple-600 dark:via-purple-800 dark:to-pink-800">
<div className="absolute inset-0 bg-black/10"></div>
<div className="relative container mx-auto px-4 py-12 lg:px-6 md:py-16">
<BackButton tinted className="absolute left-4 top-4 text-white sm:left-6 sm:top-6" />
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}

View File

@@ -8,6 +8,7 @@ import * as z from "zod";
import { motion } from "framer-motion";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import BackButton from "@/components/ui/BackButton";
import {
Form,
@@ -261,6 +262,7 @@ export default function StoreSettings() {
className="w-full max-w-4xl mx-auto px-4 pt-6 lg:px-6 pb-[calc(6rem+env(safe-area-inset-bottom))]"
dir={t("dir") || "rtl"}
>
<BackButton className="mb-4" />
<h2 className="text-3xl font-bold bg-gradient-to-r from-primary to-primary/60 bg-clip-text text-transparent mb-6">
{t("settings.academy.storeProfile.title")}
</h2>

View File

@@ -36,6 +36,7 @@ import { useTranslation } from "react-i18next";
import useAxios from "@/hooks/useAxios";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import { useAppLanguage } from "@/contexts/LanguageProvider";
import EditPageHeader from "@/components/settings/EditPageHeader";
interface Payment {
_id: string;
@@ -228,8 +229,9 @@ const WalletDashboard = () => {
return (
<LocalePageShell>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<div className="min-h-screen text-foreground">
<div className="min-h-dvh text-foreground">
<div className="container mx-auto space-y-8 px-4 py-6 lg:px-6">
<EditPageHeader title={t("settings.nav.wallet")} className="my-0" />
{/* کارت‌های آمار - 3 کارت */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">

View File

@@ -5,6 +5,7 @@ import AuthPageLayout, {
AuthPageContent,
} from "@/components/auth/AuthPageLayout";
import AuthNextButton from "@/components/auth/AuthNextButton";
import BackButton from "@/components/ui/BackButton";
import RoundedInput from "@/components/elements/RoundedInput";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import useAxios from "@/hooks/useAxios";
@@ -74,6 +75,9 @@ function BookingNamePage() {
return (
<LocalePageShell>
<AuthPageLayout>
<div className="flex w-full max-w-sm justify-start">
<BackButton />
</div>
<form
onSubmit={formik.handleSubmit}
className="flex w-full flex-1 flex-col items-center pb-28"

View File

@@ -5,6 +5,7 @@ import AuthPageLayout, {
AuthPageContent,
} from "@/components/auth/AuthPageLayout";
import AuthNextButton from "@/components/auth/AuthNextButton";
import BackButton from "@/components/ui/BackButton";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import useAxios from "@/hooks/useAxios";
import {
@@ -58,6 +59,9 @@ function BookingPreviewPage() {
return (
<LocalePageShell>
<AuthPageLayout>
<div className="flex w-full max-w-sm justify-start">
<BackButton />
</div>
<div className="flex w-full flex-1 flex-col items-center pb-28">
<AuthPageContent>
<span className="mb-4 text-xl font-bold">

View File

@@ -5,6 +5,7 @@ import AuthPageLayout, {
AuthPageContent,
} from "@/components/auth/AuthPageLayout";
import AuthNextButton from "@/components/auth/AuthNextButton";
import BackButton from "@/components/ui/BackButton";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import TimeSelect from "@/components/shops/TimeSelect";
import ToggleSwitch from "@/components/shops/ToggleSwitch";
@@ -160,6 +161,9 @@ function BookingSchedulePage() {
return (
<LocalePageShell>
<AuthPageLayout>
<div className="flex w-full max-w-sm justify-start">
<BackButton />
</div>
<div className="flex w-full flex-1 flex-col items-center overflow-y-auto pb-28">
<AuthPageContent>
<span className="mb-4 text-xl font-bold">

View File

@@ -5,6 +5,7 @@ import AuthPageLayout, {
AuthPageContent,
} from "@/components/auth/AuthPageLayout";
import AuthNextButton from "@/components/auth/AuthNextButton";
import BackButton from "@/components/ui/BackButton";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import ServiceItem from "@/components/main/Services/ServiceItem";
import AddServiceModal from "@/components/main/Services/AddServiceModal";
@@ -126,6 +127,9 @@ function BookingServicesPage() {
return (
<LocalePageShell>
<AuthPageLayout>
<div className="flex w-full max-w-sm justify-start">
<BackButton />
</div>
<div className="flex w-full flex-1 flex-col items-center pb-28">
<AuthPageContent>
<div className="mb-4 flex w-full max-w-sm items-center justify-between">

View File

@@ -16,37 +16,40 @@ function BookingSettingsPage() {
return (
<LocalePageShell>
<Container>
<div className="flex items-center justify-between">
<PageTitle>{t("settings.nav.booking")}</PageTitle>
<div className="flex shrink-0 items-center justify-end gap-3">
<Link
href="/settings/booking/list"
aria-label={t("booking.myListingsSwitcher")}
className="p-2"
>
<Image
width={24}
height={24}
alt=""
src={staticIconUrl("/images/icons/archive-book.svg")}
className="dark:brightness-0 dark:invert"
/>
</Link>
<Link
href="/settings/booking/new/name"
aria-label={t("booking.addNew")}
className="p-2"
>
<Image
width={24}
height={24}
alt=""
src={staticIconUrl("/images/icons/add.svg")}
className="dark:brightness-0 dark:invert"
/>
</Link>
</div>
</div>
<PageTitle
action={
<div className="flex items-center gap-3">
<Link
href="/settings/booking/list"
aria-label={t("booking.myListingsSwitcher")}
className="p-2"
>
<Image
width={24}
height={24}
alt=""
src={staticIconUrl("/images/icons/archive-book.svg")}
className="dark:brightness-0 dark:invert"
/>
</Link>
<Link
href="/settings/booking/new/name"
aria-label={t("booking.addNew")}
className="p-2"
>
<Image
width={24}
height={24}
alt=""
src={staticIconUrl("/images/icons/add.svg")}
className="dark:brightness-0 dark:invert"
/>
</Link>
</div>
}
>
{t("settings.nav.booking")}
</PageTitle>
<div className="text-xs md:text-sm">
<UserDetails />
<IncomingBookingsList />

View File

@@ -7,6 +7,7 @@ import ProfileAvatar from "@/components/main/ProfileAvatar";
import VerificationBadge from "@/components/main/VerificationBadge";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import RoundedButton from "@/components/elements/RoundedButton";
import BackButton from "@/components/ui/BackButton";
import IOSSpinner from "@/components/ui/IOSSpinner";
import useAxios from "@/hooks/useAxios";
import { useUser } from "@/hooks/useUser";
@@ -153,13 +154,10 @@ export default function NearbyFriendsPage() {
<LocalePageShell>
<Container className="pb-28">
<div className="mb-4 flex items-center gap-3 pt-2">
<Link
<BackButton
href="/settings/chats"
className="flex h-9 w-9 items-center justify-center rounded-full bg-neutral-100 dark:bg-neutral-800"
aria-label={t("nearbyFriends.back")}
>
<span className="text-lg leading-none">‹</span>
</Link>
className="bg-neutral-100 dark:bg-neutral-800"
/>
<h1 className="text-base font-bold md:text-lg">
{t("nearbyFriends.title")}
</h1>
@@ -309,7 +307,7 @@ export default function NearbyFriendsPage() {
{t("nearbyFriends.locationOffDesc")}
</p>
<Link href="/settings/profile/user-settings" className="mt-4 block">
<RoundedButton variant="primary" className="w-full max-w-none">
<RoundedButton className="!border-transparent h-9 w-full max-w-none !bg-sky-100 text-sky-600">
{t("nearbyFriends.openSettings")}
</RoundedButton>
</Link>

View File

@@ -4,6 +4,7 @@ import React, { useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import Container from "@/components/elements/Container";
import BackButton from "@/components/ui/BackButton";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import VerificationBadge from "@/components/main/VerificationBadge";
import useAxios from "@/hooks/useAxios";
@@ -158,6 +159,7 @@ function RoomMessageRow({
const swipeDragStart =
"onDragStart" in dragProps ? dragProps.onDragStart : undefined;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { onDragStart: _, ...restDragProps } = dragProps;
return (
@@ -774,14 +776,12 @@ export default function ChatRoomThreadPage({
className="fixed left-0 right-0 top-0 z-40 border-b border-white/10 bg-black/20 px-3 pb-3 pt-[calc(0.75rem+env(safe-area-inset-top))] backdrop-blur-md"
>
<div className="mx-auto flex w-full max-w-lg items-center gap-3">
<Link
<BackButton
href="/settings/chats/rooms"
className="gentle-transition flex h-10 w-10 items-center justify-center rounded-full bg-white/10 active:scale-95"
aria-label={t("chats.actions.back")}
>
<BoldIcon name="arrow-right-2" size={20} tinted className="text-white" />
</Link>
<div className="min-w-0 flex flex-1 items-center gap-3">
tinted
className="bg-white/10 text-white"
/>
<div className="flex min-w-0 flex-1 items-center justify-center gap-3">
<ProfileAvatar
src={room?.image}
alt={room?.title ?? t("chats.chatRoom")}
@@ -789,32 +789,36 @@ export default function ChatRoomThreadPage({
rounded="full"
className="h-10 w-10 shrink-0"
/>
<div className="min-w-0 flex-1">
<h1 className="truncate text-base font-bold text-white">
{room?.title ?? t("chats.chatRoom")}
</h1>
<div className="min-w-0 max-w-[65%]">
<div className="flex items-center justify-between gap-2">
<h1 className="truncate text-base font-bold text-white">
{room?.title ?? t("chats.chatRoom")}
</h1>
{room?.onlineCount != null ? (
<span className="shrink-0 text-[10px] text-[#22c55e]">
{t("chats.onlineCount", { count: room.onlineCount })}
</span>
) : null}
</div>
{room?.description ? (
<p className="truncate text-[11px] text-white/70">
<p className="truncate text-center text-[11px] text-white/70">
{room.description}
</p>
) : null}
{room?.onlineCount != null ? (
<p className="text-[10px] text-[#22c55e]">
{t("chats.onlineCount", { count: room.onlineCount })}
</p>
) : null}
</div>
</div>
{canAddMembers ? (
<button
type="button"
onClick={() => setShowAddMembers(true)}
className="gentle-transition flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-white/10 active:scale-95"
className="gentle-transition flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-white/10 active:scale-95"
aria-label={t("chats.aria.addUser")}
>
<BoldIcon name="user-add" size={20} tinted className="text-white" />
</button>
) : null}
) : (
<span className="h-9 w-9 shrink-0" />
)}
</div>
{room?.members?.length ? (
<div className="mx-auto mt-3 flex w-full max-w-lg gap-2 overflow-x-auto pb-1">

View File

@@ -9,6 +9,7 @@ import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import IOSSpinner from "@/components/ui/IOSSpinner";
import BoldIcon from "@/components/ui/BoldIcon";
import BackButton from "@/components/ui/BackButton";
import { usePageTitle } from "@/hooks/usePageTitle";
import {
acquireChatSocket,
@@ -86,15 +87,18 @@ export default function ChatRoomsPage() {
return (
<LocalePageShell>
<Container>
<div className="flex items-center justify-between gap-3 px-4 py-7">
<h1 className="text-2xl font-bold text-foreground">{t("chats.chatRoom")}</h1>
<div className="grid grid-cols-[2.5rem_1fr_2.5rem] items-center gap-3 px-4 py-7">
<BackButton className="justify-self-start" />
<h1 className="truncate text-center text-2xl font-bold text-foreground">
{t("chats.chatRoom")}
</h1>
<button
type="button"
onClick={() => setShowCreateModal(true)}
className="gentle-transition flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-[#0095f6] text-white shadow-md active:scale-95"
className="gentle-transition flex h-9 w-9 shrink-0 items-center justify-center justify-self-end rounded-full bg-[#0095f6] text-white shadow-md active:scale-95"
aria-label={t("chats.aria.createRoom")}
>
<BoldIcon name="add" size={22} tinted className="text-white" />
<BoldIcon name="add" size={20} tinted className="text-white" />
</button>
</div>

View File

@@ -1,13 +1,14 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { useParams } from "next/navigation";
import Container from "@/components/elements/Container";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import ChatBoldIcon from "@/components/chat/ChatBoldIcon";
import { chatHeaderCircleBtn, chatHeaderInfoPill, chatHeaderGlassStyle } from "@/components/chat/ChatHeader";
import VideoMessageBubble from "@/components/chat/VideoMessageBubble";
import BackButton from "@/components/ui/BackButton";
import IOSSpinner from "@/components/ui/IOSSpinner";
import useAxios from "@/hooks/useAxios";
import { cn } from "@/lib/utils";
@@ -54,7 +55,6 @@ function getGroupPosition(
export default function ShopChatPage() {
const { t } = useTranslation("common");
const router = useRouter();
const params = useParams<{ shopId: string; buyerId: string }>();
const { shopId, buyerId } = params;
const { request } = useAxios();
@@ -149,15 +149,12 @@ export default function ShopChatPage() {
dir="rtl"
className="pointer-events-auto mx-auto flex max-w-lg items-center gap-2"
>
<button
type="button"
onClick={() => router.back()}
<BackButton
tinted
iconSize={22}
className={chatHeaderCircleBtn}
style={chatHeaderGlassStyle}
aria-label={t("chats.actions.back")}
>
<ChatBoldIcon name="back" size={22} className="text-current" />
</button>
/>
<div dir="ltr" className={chatHeaderInfoPill} style={chatHeaderGlassStyle}>
<div dir="rtl" className="min-w-0 flex-1 text-right">

View File

@@ -9,6 +9,7 @@ import { useRouter } from "next/navigation";
import AuthNextButton from "@/components/auth/AuthNextButton";
import toast from "react-hot-toast";
import AuthUserDetails from "@/components/auth/AuthUserDetails";
import EditPageHeader from "@/components/settings/EditPageHeader";
import Image from "next/image";
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
import AuthRules from "@/components/auth/AuthRules";
@@ -63,10 +64,8 @@ function NationalCart() {
return (
<LocalePageShell>
<Container>
<div className="p-4 flex flex-col items-center h-screen justify-center address-page">
<span className="text-xl font-bold text-foreground">
{t("settings.edit.nav.authentication")}
</span>
<div className="p-4 flex flex-col items-center h-dvh justify-center address-page">
<EditPageHeader title={t("settings.edit.nav.authentication")} />
<div className="mt-5">
<AuthUserDetails />
</div>

View File

@@ -9,12 +9,13 @@ import Image from "next/image";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import { useUser } from "@/hooks/useUser";
import { formatFullName } from "@/lib/formatFullName";
import { BASE_URL, IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl";
import { BASE_URL } from "@/components/main/BaseUrl";
import useAxios from "@/hooks/useAxios";
import toast from "react-hot-toast";
import Cookies from "js-cookie";
import axios from "axios";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import EditPageHeader from "@/components/settings/EditPageHeader";
import { useTranslation } from "react-i18next";
function LicensePage() {
@@ -181,7 +182,7 @@ function LicensePage() {
<LocalePageShell>
<Container>
<div className="p-4 flex flex-col items-center justify-center address-page">
<span className="text-xl mb-9 font-bold">{t("settings.edit.license.title")}</span>
<EditPageHeader title={t("settings.edit.license.title")} className="mb-9" />
<div className="flex flex-col justify-center items-center">
<ProfileAvatar

View File

@@ -62,7 +62,7 @@ export default function AnalyticsHubPage() {
<div className="flex w-full items-center gap-3 rounded-2xl border border-neutral-200 p-4 text-right dark:border-neutral-700">
<BoldIcon name="global" size={22} tinted />
<span className="flex-1 text-sm font-semibold">
{t("settings.region.title")}
{t("settings.edit.region.title")}
</span>
</div>
</Link>

View File

@@ -43,6 +43,7 @@ type PostAnalyticsResponse = {
};
geography: {
locatedViewsCount: number;
viewsByCountry: { name: string; count: number }[];
viewsByProvince: { name: string; count: number }[];
viewsByCity: { name: string; count: number }[];
};
@@ -163,6 +164,43 @@ export default function PostAnalyticsDetailPage() {
</div>
</div>
<div className="mt-4 rounded-2xl border border-neutral-200 p-4 dark:border-neutral-800">
<h3 className="mb-1 text-sm font-bold">
{t("settings.edit.postsAnalytics.countryTitle")}
</h3>
<p className="mb-3 text-[11px] text-neutral-400">
{t("settings.edit.postsAnalytics.geographyHint", {
count: geography.locatedViewsCount,
})}
</p>
{geography.viewsByCountry.length === 0 ? (
<p className="py-6 text-center text-sm text-neutral-500">
{t("settings.edit.postsAnalytics.countryEmpty")}
</p>
) : (
<div className="h-56 w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={geography.viewsByCountry.slice(0, 8)}
layout="vertical"
margin={{ top: 5, right: 10, left: 0, bottom: 0 }}
>
<CartesianGrid strokeDasharray="3 3" opacity={0.2} horizontal={false} />
<XAxis type="number" allowDecimals={false} tick={{ fontSize: 10 }} />
<YAxis
type="category"
dataKey="name"
width={70}
tick={{ fontSize: 10 }}
/>
<Tooltip />
<Bar dataKey="count" fill="#387E65" radius={[0, 6, 6, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
)}
</div>
<div className="mt-4 rounded-2xl border border-neutral-200 p-4 dark:border-neutral-800">
<h3 className="mb-1 text-sm font-bold">
{t("settings.edit.postsAnalytics.geographyTitle")}

View File

@@ -53,7 +53,7 @@ export default function RegionPage() {
province_id: selection.provinceId,
city_id: selection.cityId,
});
toast.success(t("settings.region.saveSuccess"));
toast.success(t("settings.edit.region.saveSuccess"));
} catch {
toast.error(t("shops.unknownError"));
}
@@ -64,17 +64,17 @@ export default function RegionPage() {
return (
<LocalePageShell>
<Container>
<PageTitle>{t("settings.region.title")}</PageTitle>
<PageTitle>{t("settings.edit.region.title")}</PageTitle>
<div className="mx-auto mt-8 w-full max-w-sm">
<p className="mb-4 text-center text-xs text-neutral-500">
{t("settings.region.hint")}
{t("settings.edit.region.hint")}
</p>
<CountryProvinceCitySelect value={selection} onChange={setSelection} />
{selectedCountry && (
<p className="mt-3 text-center text-xs text-neutral-400">
{t("settings.region.selectedLabel", {
{t("settings.edit.region.selectedLabel", {
country: selectedCountry.name_fa,
})}
</p>

View File

@@ -4,6 +4,7 @@ import AuthNextButton from "@/components/auth/AuthNextButton";
import Container from "@/components/elements/Container";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
import EditPageHeader from "@/components/settings/EditPageHeader";
import useAxios from "@/hooks/useAxios";
import { useUser } from "@/hooks/useUser";
import Image from "next/image";
@@ -67,11 +68,9 @@ function AvatarPage() {
return (
<LocalePageShell>
<Container>
<div className="p-4 flex flex-col items-center h-screen justify-center">
<div className="p-4 flex flex-col items-center h-dvh justify-center">
<EditPageHeader title={t("settings.edit.nav.avatar")} />
<div className="flex flex-col items-center mb-4">
<span className="text-xl font-bold text-foreground">
{t("settings.edit.nav.avatar")}
</span>
<div className="relative aspect-square w-[250px] flex items-center justify-center text-white rounded-3xl border border-[#676767] overflow-hidden mt-10">
{avatar ? (
<>

View File

@@ -9,6 +9,7 @@ import { useFormik } from "formik";
import * as yup from "yup";
import { useRouter } from "next/navigation";
import AuthNextButton from "@/components/auth/AuthNextButton";
import EditPageHeader from "@/components/settings/EditPageHeader";
import toast from "react-hot-toast";
import { useUser } from "@/hooks/useUser";
import { useTranslation } from "react-i18next";
@@ -56,10 +57,8 @@ function BioPage() {
return (
<LocalePageShell>
<Container>
<div className="p-4 flex flex-col items-center h-screen justify-center">
<span className="text-xl font-bold text-foreground">
{t("settings.edit.nav.bio")}
</span>
<div className="p-4 flex flex-col items-center h-dvh justify-center">
<EditPageHeader title={t("settings.edit.nav.bio")} />
<div className="mt-5"></div>
<form
onSubmit={formik.handleSubmit}

View File

@@ -7,6 +7,7 @@ import useAxios from "@/hooks/useAxios";
import * as yup from "yup";
import { useRouter } from "next/navigation";
import AuthNextButton from "@/components/auth/AuthNextButton";
import EditPageHeader from "@/components/settings/EditPageHeader";
import toast from "react-hot-toast";
import Image from "next/image";
import { useUser } from "@/hooks/useUser";
@@ -90,8 +91,8 @@ function Colors() {
return (
<LocalePageShell>
<Container>
<div className="p-4 flex flex-col items-center h-screen justify-center">
<span className="text-xl font-bold text-foreground">{t("settings.edit.nav.colors")}</span>
<div className="p-4 flex flex-col items-center h-dvh justify-center">
<EditPageHeader title={t("settings.edit.nav.colors")} />
<p className="mt-8 text-center text-sm font-bold">{t("settings.edit.sizes.appearance")}</p>
{/* Buttons for categories */}

View File

@@ -9,6 +9,7 @@ import { useFormik } from "formik";
import * as yup from "yup";
import { useRouter } from "next/navigation";
import AuthNextButton from "@/components/auth/AuthNextButton";
import EditPageHeader from "@/components/settings/EditPageHeader";
import toast from "react-hot-toast";
import { useUser } from "@/hooks/useUser";
import { useTranslation } from "react-i18next";
@@ -52,10 +53,8 @@ function CooperationType() {
return (
<LocalePageShell>
<Container>
<div className="p-4 flex flex-col items-center h-screen justify-center">
<span className="text-xl font-bold text-foreground">
{t("settings.edit.nav.cooperationType")}
</span>
<div className="p-4 flex flex-col items-center h-dvh justify-center">
<EditPageHeader title={t("settings.edit.nav.cooperationType")} />
<p className="my-10 text-sm font-bold">
{t("settings.edit.cooperationType.question")}
</p>

View File

@@ -16,6 +16,7 @@ import {
subExpertiseListIncludes,
} from "@/lib/subExpertiseDisplay";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import EditPageHeader from "@/components/settings/EditPageHeader";
import { useTranslation } from "react-i18next";
function Expertise() {
@@ -150,10 +151,8 @@ function Expertise() {
return (
<LocalePageShell>
<Container>
<div className="p-4 flex flex-col items-center min-h-screen justify-center">
<span className="text-xl font-bold text-foreground">
{t("settings.edit.nav.expertise")}
</span>
<div className="p-4 flex flex-col items-center min-h-dvh justify-center">
<EditPageHeader title={t("settings.edit.nav.expertise")} />
<p className="mt-8 text-center">{t("settings.edit.expertise.question")}</p>

View File

@@ -1,281 +1,215 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
"use client";
import Container from "@/components/elements/Container";
import React, { useEffect, useState } from "react";
import useAxios from "@/hooks/useAxios";
import { useFormik } from "formik";
import * as yup from "yup";
import { useRouter } from "next/navigation";
import AuthNextButton from "@/components/auth/AuthNextButton";
import toast from "react-hot-toast";
import SelectBox from "@/components/elements/SelectBox";
import { ICity, IProvince } from "@/types/types";
import Map, { GeolocateControl, Marker } from "react-map-gl";
import "mapbox-gl/dist/mapbox-gl.css";
import Image from "next/image";
import { useUser } from "@/hooks/useUser";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import { useTranslation } from "react-i18next";
import { useMemo } from "react";
function LocationPage() {
const { t } = useTranslation("common");
const schema = useMemo(
() =>
yup.object().shape({
markerCoordinate: yup.mixed().required(t("settings.edit.location.markerRequired")),
address: yup.string().required(t("settings.edit.location.addressRequired")),
cityId: yup.string().required(t("settings.edit.location.cityRequired")),
stateId: yup.string().required(t("settings.edit.location.stateRequired")),
}),
[t]
);
const router = useRouter();
const { request, loading } = useAxios();
const user = useUser();
const [isCheckedOne, setIsCheckedOne] = useState<boolean | null>(false);
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);
// console.log(response?.provinces);
} catch (err) {
console.log(err);
}
};
// Fetch cities when a province is selected
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();
}, []);
const toggleCheckBox = () => {
setIsCheckedOne(!isCheckedOne);
};
const formik = useFormik({
initialValues: {
markerCoordinate: [] as number[],
address: "",
cityId: "",
stateId: "",
},
validationSchema: schema,
onSubmit: async (values, { setSubmitting }) => {
try {
await request("PATCH", "/verify/address", {
address: values.address,
province_id: values.stateId,
city_id: values.cityId,
lat: values.markerCoordinate[1],
lng: values.markerCoordinate[0],
show_location: isCheckedOne,
});
router.push("/settings/edit");
} catch (err: any) {
toast.error(err?.message || t("settings.edit.unknownError"));
} finally {
setSubmitting(false);
}
},
});
// Handle province change
const handleProvinceChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const selectedProvinceId = e.target.value;
formik.setFieldValue("stateId", selectedProvinceId);
fetchCities(selectedProvinceId); // Fetch cities for the selected province
};
const handleMapClick = (event: any) => {
const { lngLat } = event;
setSelectedLocation({
lat: lngLat.lat,
lng: lngLat.lng,
});
formik.setFieldValue("markerCoordinate", [lngLat.lng, lngLat.lat]);
};
useEffect(() => {
if (user) {
formik.setValues({
markerCoordinate:
user.lat && user.lng
? [Number(user.lng), Number(user.lat)]
: [],
address: user.address || "",
cityId: user?.city?.id ? String(user.city.id) : "",
stateId: user?.province?.id ? String(user.province.id) : "",
});
if (user.lat && user.lng) {
setSelectedLocation({
lat: Number(user.lat),
lng: Number(user.lng),
});
}
if (user?.province?.id) {
fetchCities(user?.province?.id.toString());
}
if (user?.show_location !== undefined) {
setIsCheckedOne(user?.show_location);
}
}
}, [user]);
return (
<LocalePageShell>
<Container>
<div className="p-4 flex flex-col items-center justify-center address-page ">
<span className="text-xl font-bold text-foreground">{t("settings.edit.nav.location")}</span>
<div className="mt-5"></div>
<small className="font-bold mt-10 mb-4">
{t("settings.edit.location.question")}
</small>
<form
onSubmit={formik.handleSubmit}
className="w-full max-w-sm flex flex-col items-center"
>
<SelectBox
className={`mt-4 ${
formik.touched.stateId && formik.errors.stateId
? "border-red-500 dark:border-red-500"
: "border-gray-300"
}`}
value={formik.values.stateId}
onChange={handleProvinceChange}
>
<option disabled value="">
{t("settings.profile.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 mt-2 block text-center">
{formik.errors.stateId}
</small>
)}
<SelectBox
className={`mt-4 ${
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("settings.profile.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 mt-2 block text-center">
{formik.errors.cityId}
</small>
)}
<textarea
name="address"
placeholder={t("settings.edit.location.addressPlaceholder")}
className={`border mt-4 w-full max-w-[300px] p-3 rounded-2xl border-border-primary-light dark:border-border-primary-dark bg-secondary-light dark:bg-secondary-dark font-medium ${
formik.touched.address && formik.errors.address
? "border-red-500 dark:border-red-500"
: "border-gray-300"
}`}
value={formik.values.address}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
/>
{formik.touched.address && formik.errors.address && (
<small className="text-red-500 mt-2 block">
{formik.errors.address}
</small>
)}
<Map
style={{ height: "calc(100vh - 260px)" }}
initialViewState={{
longitude: Number(user?.lng) || 51.375433528216654,
latitude: Number(user?.lat) || 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>
<label className="flex items-center gap-2">
<input
className="scale-125"
type="checkbox"
onChange={toggleCheckBox}
checked={isCheckedOne || false}
/>
<span className="text-xs font-bold">
{t("settings.edit.location.showToAll")}
</span>
</label>
<AuthNextButton type="submit" className="mt-20" loading={loading} disabled={loading}>
{t("settings.edit.save")}
</AuthNextButton>
</form>
</div>
</Container>
</LocalePageShell>
);
}
export default LocationPage;
/* eslint-disable @typescript-eslint/no-explicit-any */
"use client";
import Container from "@/components/elements/Container";
import React, { useEffect, useState } from "react";
import useAxios from "@/hooks/useAxios";
import { useFormik } from "formik";
import * as yup from "yup";
import { useRouter } from "next/navigation";
import AuthNextButton from "@/components/auth/AuthNextButton";
import toast from "react-hot-toast";
import CountryProvinceCitySelect, {
type GeoSelection,
countryHasProvinces,
} from "@/components/ui/CountryProvinceCitySelect";
import EditPageHeader from "@/components/settings/EditPageHeader";
import Map, { GeolocateControl, Marker } from "react-map-gl";
import "mapbox-gl/dist/mapbox-gl.css";
import Image from "next/image";
import { useUser } from "@/hooks/useUser";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import { useTranslation } from "react-i18next";
import { useMemo } from "react";
function LocationPage() {
const { t } = useTranslation("common");
const schema = useMemo(
() =>
yup.object().shape({
markerCoordinate: yup.mixed().required(t("settings.edit.location.markerRequired")),
address: yup.string().required(t("settings.edit.location.addressRequired")),
}),
[t]
);
const router = useRouter();
const { request, loading } = useAxios();
const user = useUser();
const [isCheckedOne, setIsCheckedOne] = useState<boolean | null>(false);
const [geoSelection, setGeoSelection] = useState<GeoSelection>({
countryId: null,
provinceId: null,
cityId: null,
});
const [selectedLocation, setSelectedLocation] = useState<{
lat: number;
lng: number;
} | null>(null);
const toggleCheckBox = () => {
setIsCheckedOne(!isCheckedOne);
};
const formik = useFormik({
initialValues: {
markerCoordinate: [] as number[],
address: "",
},
validationSchema: schema,
onSubmit: async (values, { setSubmitting }) => {
const provinceRequired = countryHasProvinces(geoSelection.countryId);
if (provinceRequired && !geoSelection.provinceId) {
toast.error(t("settings.edit.location.stateRequired"));
setSubmitting(false);
return;
}
if (provinceRequired && !geoSelection.cityId) {
toast.error(t("settings.edit.location.cityRequired"));
setSubmitting(false);
return;
}
try {
await request("PATCH", "/verify/address", {
address: values.address,
country_id: geoSelection.countryId,
province_id: geoSelection.provinceId,
city_id: geoSelection.cityId,
lat: values.markerCoordinate[1],
lng: values.markerCoordinate[0],
show_location: isCheckedOne,
});
router.push("/settings/edit");
} catch (err: any) {
toast.error(err?.message || t("settings.edit.unknownError"));
} finally {
setSubmitting(false);
}
},
});
const handleMapClick = (event: any) => {
const { lngLat } = event;
setSelectedLocation({
lat: lngLat.lat,
lng: lngLat.lng,
});
formik.setFieldValue("markerCoordinate", [lngLat.lng, lngLat.lat]);
};
useEffect(() => {
if (user) {
formik.setValues({
markerCoordinate:
user.lat && user.lng
? [Number(user.lng), Number(user.lat)]
: [],
address: user.address || "",
});
if (user.lat && user.lng) {
setSelectedLocation({
lat: Number(user.lat),
lng: Number(user.lng),
});
}
setGeoSelection({
countryId: user?.country?.id ?? null,
provinceId: user?.province?.id ?? null,
cityId: user?.city?.id ?? null,
});
if (user?.show_location !== undefined) {
setIsCheckedOne(user?.show_location);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user]);
return (
<LocalePageShell>
<Container>
<div className="p-4 flex flex-col items-center justify-center address-page ">
<EditPageHeader title={t("settings.edit.nav.location")} />
<div className="mt-5"></div>
<small className="font-bold mt-10 mb-4">
{t("settings.edit.location.question")}
</small>
<form
onSubmit={formik.handleSubmit}
className="w-full max-w-sm flex flex-col items-center"
>
<CountryProvinceCitySelect
value={geoSelection}
onChange={setGeoSelection}
className="mt-4 w-full"
/>
<textarea
name="address"
placeholder={t("settings.edit.location.addressPlaceholder")}
className={`border mt-4 w-full max-w-[300px] p-3 rounded-2xl border-border-primary-light dark:border-border-primary-dark bg-secondary-light dark:bg-secondary-dark font-medium ${
formik.touched.address && formik.errors.address
? "border-red-500 dark:border-red-500"
: "border-gray-300"
}`}
value={formik.values.address}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
/>
{formik.touched.address && formik.errors.address && (
<small className="text-red-500 mt-2 block">
{formik.errors.address}
</small>
)}
<Map
style={{ height: "calc(100dvh - 260px)" }}
initialViewState={{
longitude: Number(user?.lng) || 51.375433528216654,
latitude: Number(user?.lat) || 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>
<label className="flex items-center gap-2">
<input
className="scale-125"
type="checkbox"
onChange={toggleCheckBox}
checked={isCheckedOne || false}
/>
<span className="text-xs font-bold">
{t("settings.edit.location.showToAll")}
</span>
</label>
<AuthNextButton type="submit" className="mt-20" loading={loading} disabled={loading}>
{t("settings.edit.save")}
</AuthNextButton>
</form>
</div>
</Container>
</LocalePageShell>
);
}
export default LocationPage;

View File

@@ -12,6 +12,7 @@ import * as yup from "yup";
import { useRouter } from "next/navigation";
import { IVerifyOtp } from "@/types/types";
import AuthNextButton from "@/components/auth/AuthNextButton";
import BackButton from "@/components/ui/BackButton";
import { useUser } from "@/hooks/useUser";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
@@ -74,7 +75,10 @@ function PasswordPage() {
return (
<LocalePageShell>
<Container>
<div className="p-4 flex flex-col items-center h-screen justify-center">
<div className="p-4 flex flex-col items-center h-dvh justify-center">
<div className="w-full max-w-sm">
<BackButton />
</div>
<AuthHead
title={
isGoogleUser

View File

@@ -9,6 +9,7 @@ import { useFormik } from "formik";
import * as yup from "yup";
import { useRouter } from "next/navigation";
import AuthNextButton from "@/components/auth/AuthNextButton";
import EditPageHeader from "@/components/settings/EditPageHeader";
import toast from "react-hot-toast";
import { useUser } from "@/hooks/useUser";
import { useTranslation } from "react-i18next";
@@ -58,10 +59,8 @@ function PublicRelations() {
return (
<LocalePageShell>
<Container>
<div className="p-4 flex flex-col items-center h-screen justify-center">
<span className="text-xl font-bold text-foreground">
{t("settings.edit.nav.publicRelations")}
</span>
<div className="p-4 flex flex-col items-center h-dvh justify-center">
<EditPageHeader title={t("settings.edit.nav.publicRelations")} />
<form onSubmit={formik.handleSubmit} className="w-full max-w-sm flex flex-col items-center">
<small className="font-bold mt-10">{t("settings.edit.bio.aboutYou")}</small>
<textarea

View File

@@ -8,6 +8,7 @@ import AuthUserDetails from "@/components/auth/AuthUserDetails";
import Container from "@/components/elements/Container";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import AuthNextButton from "@/components/auth/AuthNextButton";
import EditPageHeader from "@/components/settings/EditPageHeader";
import useAxios from "@/hooks/useAxios";
import toast from "react-hot-toast";
import { dataURLtoBlob } from "@/helpers/helpers";
@@ -78,10 +79,8 @@ const ServicesPage: React.FC = () => {
return (
<LocalePageShell>
<Container>
<div className="p-4 flex flex-col items-center h-screen justify-center">
<span className="text-xl font-bold text-foreground">
{t("settings.edit.nav.services")}
</span>
<div className="p-4 flex flex-col items-center h-dvh justify-center">
<EditPageHeader title={t("settings.edit.nav.services")} />
<div className="mt-5 w-full max-w-md">
<AuthUserDetails />
</div>

View File

@@ -9,6 +9,7 @@ import { useFormik } from "formik";
import * as yup from "yup";
import { useRouter } from "next/navigation";
import AuthNextButton from "@/components/auth/AuthNextButton";
import EditPageHeader from "@/components/settings/EditPageHeader";
import toast from "react-hot-toast";
import AuthInput from "@/components/auth/AuthInput";
import DatePicker from "react-multi-date-picker";
@@ -128,10 +129,8 @@ function ShabaPage() {
return (
<LocalePageShell>
<Container>
<div className="p-4 flex flex-col items-center min-h-screen justify-center auth-page">
<span className="text-xl font-bold text-foreground">
{t("settings.edit.nav.shaba")}
</span>
<div className="p-4 flex flex-col items-center min-h-dvh justify-center auth-page">
<EditPageHeader title={t("settings.edit.nav.shaba")} />
<form
onSubmit={formik.handleSubmit}
className="w-full max-w-sm flex flex-col items-center"

View File

@@ -8,6 +8,7 @@ import useAxios from "@/hooks/useAxios";
import * as yup from "yup";
import { useRouter } from "next/navigation";
import AuthNextButton from "@/components/auth/AuthNextButton";
import EditPageHeader from "@/components/settings/EditPageHeader";
import toast from "react-hot-toast";
import Image from "next/image";
import { useUser } from "@/hooks/useUser";
@@ -65,10 +66,8 @@ function Sizes() {
return (
<LocalePageShell>
<Container>
<div className="p-4 flex flex-col items-center h-screen justify-center">
<span className="text-xl font-bold text-foreground">
{t("settings.edit.nav.sizes")}
</span>
<div className="p-4 flex flex-col items-center h-dvh justify-center">
<EditPageHeader title={t("settings.edit.nav.sizes")} />
<p className="mt-8 text-center text-sm font-bold mb-4">
{t("settings.edit.sizes.appearance")}
</p>

View File

@@ -10,6 +10,7 @@ import AuthInput from "@/components/auth/AuthInput";
import AuthNextButton from "@/components/auth/AuthNextButton";
import Container from "@/components/elements/Container";
import UsernameSuggestions from "@/components/auth/UsernameSuggestions";
import BackButton from "@/components/ui/BackButton";
import { usernameFormSchema } from "@/lib/validation/usernameSchema";
import { sanitizeUsernameInput, USERNAME_MIN_LENGTH, USERNAME_MAX_LENGTH } from "@/lib/validation/username";
import LocalePageShell from "@/components/i18n/LocalePageShell";
@@ -67,7 +68,10 @@ function UsernamePage() {
return (
<LocalePageShell>
<Container>
<div className="p-4 flex flex-col items-center h-screen justify-center">
<div className="p-4 flex flex-col items-center h-dvh justify-center">
<div className="w-full max-w-sm">
<BackButton />
</div>
<AuthHead title={t("settings.edit.nav.username")} />
<form
onSubmit={formik.handleSubmit}

View File

@@ -9,6 +9,7 @@ import MainBillboardCardActions from "@/components/billboards/MainBillboardCard/
import BillboardDetails from "@/components/billboards/BillboardPage/BillboardDetails";
import MyBillboardStatusActions from "@/components/settings/my-billboards/MyBillboardStatusActions";
import BillboardLocationLabels from "@/components/settings/my-billboards/BillboardLocationLabels";
import BackButton from "@/components/ui/BackButton";
interface IBillboardProps {
params: Promise<{ id: string; title: string }>;
@@ -32,6 +33,7 @@ async function BillboardPage({ params }: IBillboardProps) {
return (
<Container>
<BackButton />
<a
href={`/billboards/profile/${billboard?.creatorId}/${billboard?.title}`}
>

View File

@@ -4,7 +4,9 @@
import React, { useEffect, useState, useCallback, useMemo } from "react";
import dynamic from "next/dynamic";
import Container from "@/components/elements/Container";
import EditPageHeader from "@/components/settings/EditPageHeader";
import useAxios from "@/hooks/useAxios";
import { useDefaultCountryId } from "@/hooks/useDefaultCountryId";
import { useFormik } from "formik";
import * as yup from "yup";
import { useRouter } from "next/navigation";
@@ -81,6 +83,7 @@ function EditAdProfile({ params }: IEditProps) {
[t]
);
const defaultCountryId = useDefaultCountryId();
const [avatar, setAvatar] = useState<string | null>(null);
const [allStates, setAllStates] = useState<IProvince[] | null>(null);
const [cities, setCities] = useState<ICity[] | null>(null);
@@ -124,14 +127,14 @@ function EditAdProfile({ params }: IEditProps) {
try {
const response = await request<{ provinces: IProvince[] }>(
"GET",
"/provinces"
defaultCountryId ? `/provinces?country_id=${defaultCountryId}` : "/provinces"
);
setAllStates(response?.provinces ?? null);
} catch (err) {
console.error("Error fetching provinces:", err);
toast.error(t("settings.billboards.editProfile.loadProvincesError"));
}
}, [request, t]);
}, [request, t, defaultCountryId]);
// دریافت شهرها
const fetchCities = useCallback(
@@ -346,7 +349,7 @@ function EditAdProfile({ params }: IEditProps) {
className="p-4 flex flex-col items-center justify-center address-page"
aria-label={t("settings.billboards.editProfile.pageAria")}
>
<span className="text-xl font-bold">{t("settings.billboards.editProfile.title")}</span>
<EditPageHeader title={t("settings.billboards.editProfile.title")} />
<div className="mt-5" />
<form
onSubmit={formik.handleSubmit}
@@ -370,7 +373,7 @@ function EditAdProfile({ params }: IEditProps) {
onProvinceChange={handleProvinceChange}
/>
<Map
style={{ height: "calc(100vh - 260px)" }}
style={{ height: "calc(100dvh - 260px)" }}
initialViewState={mapInitialViewState}
aria-label={t("settings.billboards.editProfile.mapAria")}
reuseMaps // بهینه‌سازی برای جلوگیری از re-mounting مکرر

View File

@@ -26,7 +26,7 @@ export default function ProfileClient() {
if (loading || !user) {
return (
<div className="flex h-screen items-center justify-center" />
<div className="flex h-dvh items-center justify-center" />
);
}

View File

@@ -0,0 +1,129 @@
"use client";
import Container from "@/components/elements/Container";
import PageTitle from "@/components/settings/PageTitle";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import RoundedInput from "@/components/elements/RoundedInput";
import BoldIcon from "@/components/ui/BoldIcon";
import { useAppLanguage } from "@/contexts/LanguageProvider";
import {
SUPPORTED_LANGUAGES,
LANGUAGE_REGISTRY,
type AppLanguage,
} from "@/lib/i18n/registry";
import useAxios from "@/hooks/useAxios";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
export default function SelectLanguagePage() {
const { t } = useTranslation("common");
const { language, setLanguage } = useAppLanguage();
const { request } = useAxios();
const router = useRouter();
const [query, setQuery] = useState("");
const languages = useMemo(() => {
const q = query.trim().toLowerCase();
return SUPPORTED_LANGUAGES.map((code) => LANGUAGE_REGISTRY[code]).filter(
(def) =>
!q ||
def.nativeLabel.toLowerCase().includes(q) ||
def.englishLabel.toLowerCase().includes(q)
);
}, [query]);
const handleSelect = async (code: AppLanguage) => {
const def = LANGUAGE_REGISTRY[code];
if (!def.enabled) {
toast(t("language.comingSoon"));
return;
}
if (code === language) {
router.back();
return;
}
try {
await setLanguage(code);
try {
await request(
"PATCH",
"/account/privacy",
{ preferred_language: code },
{ noToast: true }
);
} catch {
// local language still applies when offline / old backend
}
toast.success(t("language.saved"));
router.back();
} catch {
toast.error(t("common.saveError"));
}
};
return (
<LocalePageShell>
<Container>
<PageTitle>{t("language.selectTitle")}</PageTitle>
<div className="mx-auto w-full max-w-md">
<RoundedInput
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("language.searchPlaceholder")}
className="mb-4"
/>
{languages.length === 0 ? (
<p className="py-10 text-center text-sm text-neutral-500">
{t("language.noResults")}
</p>
) : (
<div className="flex flex-col gap-2 pb-8">
{languages.map((def) => {
const active = def.code === language;
return (
<button
key={def.code}
type="button"
dir={def.direction}
onClick={() => void handleSelect(def.code as AppLanguage)}
className={`flex items-center justify-between rounded-xl border px-4 py-3 text-right transition-colors ${
active
? "border-[#0095f6] bg-[#0095f6]/10"
: "border-neutral-200 dark:border-neutral-800"
} ${!def.enabled ? "opacity-60" : ""}`}
>
<div className="flex flex-col items-start">
<span className="text-sm font-semibold">
{def.nativeLabel}
</span>
<span className="text-xs text-neutral-500">
{def.englishLabel}
</span>
</div>
{active ? (
<BoldIcon
name="check"
size={18}
tinted
className="text-[#0095f6]"
/>
) : !def.enabled ? (
<span className="rounded-full bg-neutral-100 px-2 py-0.5 text-[10px] font-semibold text-neutral-500 dark:bg-neutral-800">
{t("language.comingSoonBadge")}
</span>
) : null}
</button>
);
})}
</div>
)}
</div>
</Container>
</LocalePageShell>
);
}

View File

@@ -159,6 +159,14 @@ export default function UserSettingsPage() {
<LanguageSettingRow />
<Link
href="/settings/edit/analytics/region"
className="flex items-center justify-between rounded-xl border border-neutral-200 px-4 py-3 dark:border-neutral-800"
>
<span className="font-semibold">{t("settings.edit.region.title")}</span>
<span className="text-neutral-400">›</span>
</Link>
<Link
href="/settings/profile/visitors"
className="flex items-center justify-between rounded-xl border border-neutral-200 px-4 py-3 dark:border-neutral-800"

View File

@@ -90,8 +90,6 @@ export default function ShopSettingsPage() {
router.push(`/shops/${myShops[0]._id}/products/new/name`);
};
const primaryShopId = myShops && myShops.length > 0 ? myShops[0]._id : null;
const { data: sellerOrdersData, isLoading: sellerOrdersLoading } = useInfiniteScroll({
endpoint: "/orders",
queryKey: ["seller-orders", search],
@@ -119,38 +117,38 @@ export default function ShopSettingsPage() {
<Header />
<LocalePageShell>
<Container>
<div className="flex items-center justify-between">
<span className="w-20 shrink-0" />
<PageTitle>{t("settings.nav.shop")}</PageTitle>
{filter === FILTER_SELLER ? (
<div className="flex w-20 shrink-0 items-center justify-end gap-3">
<button
type="button"
onClick={() => router.push("/settings/shop/list")}
aria-label={t("shops.mySwitcher")}
>
<Image
width={24}
height={24}
alt=""
src={staticIconUrl("/images/icons/shop-add.svg")}
className="dark:invert"
/>
</button>
<button type="button" onClick={handleAddShop} aria-label={t("shops.addShop")}>
<Image
width={24}
height={24}
alt=""
src={staticIconUrl("/images/icons/add.svg")}
className="dark:invert"
/>
</button>
</div>
) : (
<span className="w-20 shrink-0" />
)}
</div>
<PageTitle
action={
filter === FILTER_SELLER ? (
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => router.push("/settings/shop/list")}
aria-label={t("shops.mySwitcher")}
>
<Image
width={24}
height={24}
alt=""
src={staticIconUrl("/images/icons/shop-add.svg")}
className="dark:invert"
/>
</button>
<button type="button" onClick={handleAddShop} aria-label={t("shops.addShop")}>
<Image
width={24}
height={24}
alt=""
src={staticIconUrl("/images/icons/add.svg")}
className="dark:invert"
/>
</button>
</div>
) : undefined
}
>
{t("settings.nav.shop")}
</PageTitle>
<div className="text-xs md:text-sm">
<UserDetails />

View File

@@ -7,6 +7,7 @@ import Header from "@/components/main/Header";
import { IMAGE_BASE_URL, staticIconUrl } from "@/components/main/BaseUrl";
import ShareShopModal from "@/components/shops/ShareShopModal";
import ShopProductListRow from "@/components/shops/ShopProductListRow";
import PageTitle from "@/components/settings/PageTitle";
import BoldIcon from "@/components/ui/BoldIcon";
import TabNavigation from "@/components/TabNavigation";
import useAxios from "@/hooks/useAxios";
@@ -111,9 +112,7 @@ export default function ShopProductsPage() {
<span className="truncate text-sm font-semibold">{shop?.name}</span>
</div>
<h1 className="my-6 text-center text-2xl font-bold">
{t("shops.myProductsTitle")}
</h1>
<PageTitle>{t("shops.myProductsTitle")}</PageTitle>
<div className="mx-auto w-full max-w-md">
<div className="flex items-center gap-2">

View File

@@ -5,6 +5,8 @@ import LocalePageShell from "@/components/i18n/LocalePageShell";
import Header from "@/components/main/Header";
import { IMAGE_BASE_URL, staticIconUrl } from "@/components/main/BaseUrl";
import BoldIcon from "@/components/ui/BoldIcon";
import BackButton from "@/components/ui/BackButton";
import DiscountBadge from "@/components/ui/DiscountBadge";
import TabNavigation from "@/components/TabNavigation";
import useAxios from "@/hooks/useAxios";
import { saveProductWizardId } from "@/hooks/useProductWizardId";
@@ -211,7 +213,8 @@ export default function ProductManagePage() {
<Header />
<LocalePageShell>
<Container className="pb-28">
<div className="flex items-center justify-end gap-2">
<div className="flex items-center justify-between gap-2">
<BackButton />
<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 ? (
@@ -235,11 +238,7 @@ export default function ProductManagePage() {
<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-center gap-2">
{discountPercent != null && (
<span className="rounded-full bg-red-50 px-2 py-0.5 text-xs font-bold text-red-500 dark:bg-red-500/10">
{t("shops.discountPercentLabel", { percent: discountPercent })}
</span>
)}
{discountPercent != null && <DiscountBadge percent={discountPercent} />}
<h1 className="text-xl font-bold">{listing.title}</h1>
</div>

View File

@@ -4,6 +4,7 @@ import AuthPageLayout, {
AuthFormFooter,
AuthPageContent,
} from "@/components/auth/AuthPageLayout";
import BackButton from "@/components/ui/BackButton";
import AuthNextButton from "@/components/auth/AuthNextButton";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import ProductCategoryPicker from "@/components/shops/ProductCategoryPicker";
@@ -107,6 +108,9 @@ function ProductCategoryPage() {
return (
<LocalePageShell>
<AuthPageLayout>
<div className="flex w-full max-w-sm justify-start">
<BackButton />
</div>
<AuthPageContent>
<span className="text-xl font-bold">{t("shops.productCategoryTitle")}</span>
<ProductCategoryPicker

View File

@@ -4,6 +4,7 @@ import AuthPageLayout, {
AuthFormFooter,
AuthPageContent,
} from "@/components/auth/AuthPageLayout";
import BackButton from "@/components/ui/BackButton";
import AuthNextButton from "@/components/auth/AuthNextButton";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
@@ -99,6 +100,9 @@ function ProductImagesPage() {
return (
<LocalePageShell>
<AuthPageLayout>
<div className="flex w-full max-w-sm justify-start">
<BackButton />
</div>
<AuthPageContent>
<span className="text-xl font-bold mb-4">{t("shops.productImagesTitle")}</span>
<p className="mb-4 text-xs text-neutral-500">

View File

@@ -4,6 +4,7 @@ import AuthPageLayout, {
AuthFormFooter,
AuthPageContent,
} from "@/components/auth/AuthPageLayout";
import BackButton from "@/components/ui/BackButton";
import AuthNextButton from "@/components/auth/AuthNextButton";
import RoundedInput from "@/components/elements/RoundedInput";
import LocalePageShell from "@/components/i18n/LocalePageShell";
@@ -136,6 +137,9 @@ function ProductNamePage() {
return (
<LocalePageShell>
<AuthPageLayout>
<div className="flex w-full max-w-sm justify-start">
<BackButton />
</div>
<AuthPageContent>
<span className="text-xl font-bold mb-4">{t("shops.productNameTitle")}</span>

View File

@@ -4,9 +4,11 @@ import AuthPageLayout, {
AuthFormFooter,
AuthPageContent,
} from "@/components/auth/AuthPageLayout";
import BackButton from "@/components/ui/BackButton";
import AuthNextButton from "@/components/auth/AuthNextButton";
import RoundedInput from "@/components/elements/RoundedInput";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import DiscountBadge from "@/components/ui/DiscountBadge";
import useAxios from "@/hooks/useAxios";
import {
clearProductWizardId,
@@ -180,6 +182,9 @@ function ProductVariantsPage() {
return (
<LocalePageShell>
<AuthPageLayout>
<div className="flex w-full max-w-sm justify-start">
<BackButton />
</div>
<AuthPageContent>
<span className="text-xl font-bold mb-4">{t("shops.variantsTitle")}</span>
@@ -271,8 +276,8 @@ function ProductVariantsPage() {
/>
</div>
{percent != null && (
<span className="mt-1 inline-block rounded-md bg-red-50 px-1.5 py-0.5 text-[10px] font-bold text-red-600 dark:bg-red-500/10">
{t("shops.discountPercentLabel", { percent })}
<span className="mt-1 inline-block">
<DiscountBadge percent={percent} />
</span>
)}
</div>

View File

@@ -6,6 +6,8 @@ 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 BackButton from "@/components/ui/BackButton";
import DiscountBadge from "@/components/ui/DiscountBadge";
import TabNavigation from "@/components/TabNavigation";
import useAxios from "@/hooks/useAxios";
import { cn } from "@/lib/utils";
@@ -251,6 +253,9 @@ export default function ListingDetailClient() {
<Header />
<LocalePageShell>
<Container className="pb-28">
<div className="flex w-full">
<BackButton />
</div>
{shopInfo?._id ? (
<Link
href={`/shops/profile/${shopInfo._id}`}
@@ -282,13 +287,7 @@ export default function ListingDetailClient() {
<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>
<DiscountBadge percent={discountPercent} className="shrink-0" />
)}
</div>

View File

@@ -4,6 +4,7 @@ import AuthPageLayout, {
AuthFormFooter,
AuthPageContent,
} from "@/components/auth/AuthPageLayout";
import BackButton from "@/components/ui/BackButton";
import AuthNextButton from "@/components/auth/AuthNextButton";
import RoundedInput from "@/components/elements/RoundedInput";
import LocalePageShell from "@/components/i18n/LocalePageShell";
@@ -167,6 +168,9 @@ function ShopContactPage() {
return (
<LocalePageShell>
<AuthPageLayout>
<div className="flex w-full max-w-sm justify-start">
<BackButton />
</div>
<AuthPageContent>
<span className="text-xl font-bold mb-4">{t("shops.contactTitle")}</span>

View File

@@ -4,13 +4,21 @@ import AuthPageLayout, {
AuthFormFooter,
AuthPageContent,
} from "@/components/auth/AuthPageLayout";
import BackButton from "@/components/ui/BackButton";
import AuthNextButton from "@/components/auth/AuthNextButton";
import RoundedInput from "@/components/elements/RoundedInput";
import SelectBox from "@/components/elements/SelectBox";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import CountryProvinceCitySelect, {
type GeoSelection,
countryHasProvinces,
findCountry,
findProvince,
findCity,
} from "@/components/ui/CountryProvinceCitySelect";
import useAxios from "@/hooks/useAxios";
import { useDefaultCountryId } from "@/hooks/useDefaultCountryId";
import { useShopWizardId } from "@/hooks/useShopWizardId";
import { ICity, IProvince } from "@/types/types";
import { ICity, ICountry, IProvince } from "@/types/types";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
@@ -24,30 +32,26 @@ function ShopLocationPage() {
const router = useRouter();
const { request, loading } = useAxios();
const shopId = useShopWizardId();
const defaultCountryId = useDefaultCountryId();
const [hasPhysicalLocation, setHasPhysicalLocation] = useState(true);
const [provinces, setProvinces] = useState<IProvince[] | null>(null);
const [cities, setCities] = useState<ICity[] | null>(null);
const [provinceId, setProvinceId] = useState("");
const [cityId, setCityId] = useState("");
const [geoSelection, setGeoSelection] = useState<GeoSelection>({
countryId: null,
provinceId: null,
cityId: null,
});
const [neighbourhood, setNeighbourhood] = useState("");
const [address, setAddress] = useState("");
const [marker, setMarker] = useState<{ lat: number; lng: number } | null>(
null
);
useEffect(() => {
request<{ provinces: IProvince[] }>("GET", "/provinces")
.then((res) => setProvinces(res?.provinces || []))
.catch(() => setProvinces([]));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!shopId) return;
request<{
shop: {
has_physical_location?: boolean;
country?: ICountry | null;
province?: IProvince | null;
city?: ICity | null;
neighbourhood?: string | null;
@@ -59,13 +63,11 @@ function ShopLocationPage() {
const shop = res?.shop;
if (!shop) return;
setHasPhysicalLocation(shop.has_physical_location ?? true);
if (shop.province) {
setProvinceId(String(shop.province.id));
request<{ cities: ICity[] }>("GET", `/cities/${shop.province.id}`)
.then((r) => setCities(r?.cities || []))
.catch(() => setCities([]));
}
if (shop.city) setCityId(String(shop.city.id));
setGeoSelection({
countryId: shop.country?.id ?? defaultCountryId,
provinceId: shop.province?.id ?? null,
cityId: shop.city?.id ?? null,
});
if (shop.neighbourhood) setNeighbourhood(shop.neighbourhood);
if (shop.address) setAddress(shop.address);
if (shop.lat && shop.lng) {
@@ -75,15 +77,6 @@ function ShopLocationPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [shopId]);
const handleProvinceChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const id = e.target.value;
setProvinceId(id);
setCityId("");
request<{ cities: ICity[] }>("GET", `/cities/${id}`)
.then((res) => setCities(res?.cities || []))
.catch(() => setCities([]));
};
const handleMapClick = (event: MapMouseEvent) => {
setMarker({ lat: event.lngLat.lat, lng: event.lngLat.lng });
};
@@ -91,14 +84,16 @@ function ShopLocationPage() {
const handleSubmit = async () => {
if (!shopId) return;
const province = provinces?.find((p) => String(p.id) === provinceId) || null;
const city = cities?.find((c) => String(c.id) === cityId) || null;
const country = findCountry(geoSelection.countryId);
const province = findProvince(geoSelection.provinceId);
const city = findCity(geoSelection.cityId);
const provinceRequired = countryHasProvinces(geoSelection.countryId);
if (!province) {
if (provinceRequired && !province) {
toast.error(t("shops.provinceRequired"));
return;
}
if (!city) {
if (provinceRequired && !city) {
toast.error(t("shops.cityRequired"));
return;
}
@@ -106,6 +101,7 @@ function ShopLocationPage() {
try {
await request("PATCH", `/shops/${shopId}/location`, {
has_physical_location: hasPhysicalLocation,
country,
province,
city,
neighbourhood,
@@ -127,6 +123,9 @@ function ShopLocationPage() {
return (
<LocalePageShell>
<AuthPageLayout>
<div className="flex w-full max-w-sm justify-start">
<BackButton />
</div>
<AuthPageContent>
<span className="text-xl font-bold mb-4">{t("shops.locationTitle")}</span>
@@ -141,71 +140,51 @@ function ShopLocationPage() {
<span className="text-sm">{t("shops.noPhysicalShop")}</span>
</label>
<SelectBox value={provinceId} onChange={handleProvinceChange}>
<option disabled value="">
{t("filters.province")}
</option>
{provinces?.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</SelectBox>
<CountryProvinceCitySelect
value={geoSelection}
onChange={setGeoSelection}
/>
<SelectBox
value={cityId}
onChange={(e) => setCityId(e.target.value)}
>
<option disabled value="">
{t("filters.city")}
</option>
{cities?.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</SelectBox>
<div className="mt-2 w-full overflow-hidden rounded-2xl">
<Map
style={{ height: "220px" }}
initialViewState={{
longitude: marker?.lng || 51.375433528216654,
latitude: marker?.lat || 35.73356434056531,
zoom: 11,
}}
mapboxAccessToken="pk.eyJ1IjoiYWxkZjkyIiwiYSI6ImNscHh5ZG56dTByMXAycnJ2cDNmdDQ5M3YifQ.a_sUt1rLFMfA0jHrETcM7A"
mapStyle="mapbox://styles/mapbox/streets-v11"
onClick={handleMapClick}
>
<GeolocateControl />
{marker && (
<Marker latitude={marker.lat} longitude={marker.lng}>
<Image
alt="location icon"
className="-mt-5"
width={25}
height={25}
src="/images/icons/location.svg"
/>
</Marker>
)}
</Map>
</div>
<div className="mt-2 w-full overflow-hidden rounded-2xl">
<Map
style={{ height: "220px" }}
initialViewState={{
longitude: marker?.lng || 51.375433528216654,
latitude: marker?.lat || 35.73356434056531,
zoom: 11,
}}
mapboxAccessToken="pk.eyJ1IjoiYWxkZjkyIiwiYSI6ImNscHh5ZG56dTByMXAycnJ2cDNmdDQ5M3YifQ.a_sUt1rLFMfA0jHrETcM7A"
mapStyle="mapbox://styles/mapbox/streets-v11"
onClick={handleMapClick}
>
<GeolocateControl />
{marker && (
<Marker latitude={marker.lat} longitude={marker.lng}>
<Image
alt="location icon"
className="-mt-5"
width={25}
height={25}
src="/images/icons/location.svg"
/>
</Marker>
)}
</Map>
</div>
<RoundedInput
type="text"
className="mt-2 text-xs"
placeholder={t("shops.neighbourhoodPlaceholder")}
value={neighbourhood}
onChange={(e) => setNeighbourhood(e.target.value)}
/>
<textarea
className="mt-2 h-24 w-full rounded-3xl border border-neutral-950 bg-white p-4 font-medium dark:border-neutral-400 dark:bg-neutral-950 dark:text-neutral-50"
placeholder={t("shops.addressPlaceholder")}
value={address}
onChange={(e) => setAddress(e.target.value)}
/>
<RoundedInput
type="text"
className="mt-2 text-xs"
placeholder={t("shops.neighbourhoodPlaceholder")}
value={neighbourhood}
onChange={(e) => setNeighbourhood(e.target.value)}
/>
<textarea
className="mt-2 h-24 w-full rounded-3xl border border-neutral-950 bg-white p-4 font-medium dark:border-neutral-400 dark:bg-neutral-950 dark:text-neutral-50"
placeholder={t("shops.addressPlaceholder")}
value={address}
onChange={(e) => setAddress(e.target.value)}
/>
</div>
</AuthPageContent>
<AuthFormFooter>

View File

@@ -4,6 +4,7 @@ import AuthPageLayout, {
AuthFormFooter,
AuthPageContent,
} from "@/components/auth/AuthPageLayout";
import BackButton from "@/components/ui/BackButton";
import AuthNextButton from "@/components/auth/AuthNextButton";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
@@ -67,6 +68,9 @@ function ShopLogoPage() {
return (
<LocalePageShell>
<AuthPageLayout>
<div className="flex w-full max-w-sm justify-start">
<BackButton />
</div>
<AuthPageContent>
<span className="text-xl font-bold mb-4">{t("shops.logoTitle")}</span>

View File

@@ -4,6 +4,7 @@ import AuthPageLayout, {
AuthFormFooter,
AuthPageContent,
} from "@/components/auth/AuthPageLayout";
import BackButton from "@/components/ui/BackButton";
import AuthNextButton from "@/components/auth/AuthNextButton";
import RoundedInput from "@/components/elements/RoundedInput";
import LocalePageShell from "@/components/i18n/LocalePageShell";
@@ -76,6 +77,9 @@ function ShopNamePage() {
return (
<LocalePageShell>
<AuthPageLayout>
<div className="flex w-full max-w-sm justify-start">
<BackButton />
</div>
<form
onSubmit={formik.handleSubmit}
className="flex w-full flex-1 flex-col items-center"

View File

@@ -4,6 +4,7 @@ import AuthPageLayout, {
AuthFormFooter,
AuthPageContent,
} from "@/components/auth/AuthPageLayout";
import BackButton from "@/components/ui/BackButton";
import AuthNextButton from "@/components/auth/AuthNextButton";
import RoundedInput from "@/components/elements/RoundedInput";
import LocalePageShell from "@/components/i18n/LocalePageShell";
@@ -142,6 +143,9 @@ function ShopShippingPage() {
return (
<LocalePageShell>
<AuthPageLayout>
<div className="flex w-full max-w-sm justify-start">
<BackButton />
</div>
<AuthPageContent>
<span className="text-xl font-bold mb-4">{t("shops.shippingTitle")}</span>

View File

@@ -3,6 +3,7 @@
import Container from "@/components/elements/Container";
import RoundedButton from "@/components/elements/RoundedButton";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import BackButton from "@/components/ui/BackButton";
import useAxios from "@/hooks/useAxios";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
@@ -52,6 +53,7 @@ export default function ShopPaymentFailedPage() {
return (
<LocalePageShell>
<Container>
<BackButton />
<h6 className="mb-4 mt-2 text-center text-lg font-bold text-red-600 md:text-xl">
{t("shops.paymentFailedTitle")}
</h6>

View File

@@ -3,6 +3,7 @@
import Container from "@/components/elements/Container";
import RoundedButton from "@/components/elements/RoundedButton";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import BackButton from "@/components/ui/BackButton";
import { useRouter, useSearchParams } from "next/navigation";
import Image from "next/image";
import { useTranslation } from "react-i18next";
@@ -16,6 +17,7 @@ export default function ShopPaymentSuccessPage() {
return (
<LocalePageShell>
<Container>
<BackButton />
<h6 className="mb-4 mt-2 text-center text-lg font-bold text-green-600 md:text-xl">
{t("shops.paymentSuccessTitle")}
</h6>

View File

@@ -6,7 +6,9 @@ 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 DiscountBadge from "@/components/ui/DiscountBadge";
import useAxios from "@/hooks/useAxios";
import { bestDiscountPercent } from "@/lib/shopDiscount";
import { IShopProductListing } from "@/types/types";
import Image from "next/image";
import { useParams, useRouter } from "next/navigation";
@@ -66,6 +68,7 @@ export default function ProductComparisonPage() {
listing.images?.[listing.primaryImageIndex] ||
listing.images?.[0];
const price = cheapestPrice(listing);
const discountPercent = bestDiscountPercent(listing);
return (
<button
@@ -84,6 +87,12 @@ export default function ProductComparisonPage() {
sizes="64px"
unoptimized
/>
{discountPercent != null && (
<DiscountBadge
percent={discountPercent}
className="absolute right-1 top-1"
/>
)}
</div>
)}
<div className="min-w-0 flex-1 text-right">

View File

@@ -4,6 +4,7 @@ import Container from "@/components/elements/Container";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import Header from "@/components/main/Header";
import TabNavigation from "@/components/TabNavigation";
import BackButton from "@/components/ui/BackButton";
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import ExpandableBio from "@/components/profile/ExpandableBio";
@@ -170,6 +171,9 @@ export default function ShopProfileClient() {
<Header />
<LocalePageShell>
<Container className="pb-28">
<div className="mx-auto flex w-full max-w-md">
<BackButton />
</div>
<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">

View File

@@ -19,7 +19,7 @@ function CommentsModal({
onCommentAdded,
}: CommentsModalProps) {
return (
<Modal isOpen={isOpen} onClose={onClose} height="min(82vh, 520px)">
<Modal isOpen={isOpen} onClose={onClose} height="min(82vh, 520px)" elevated>
<CourseCommentsContent
courseId={postData._id}
courseName={postData.cuorse_name}

View File

@@ -13,6 +13,7 @@ import Rate from "rc-rate";
import "rc-rate/assets/index.css";
import "@/styles/rc-rate-custom.css";
import BoldIcon from "@/components/ui/BoldIcon";
import CommentsSkeleton from "@/components/ui/CommentsSkeleton";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import { useRequireAuth } from "@/lib/auth/useRequireAuth";
import { useUser } from "@/hooks/useUser";
@@ -306,7 +307,9 @@ export default function CourseCommentsContent({
</div>
<div ref={containerRef} className="min-h-0 flex-1 overflow-y-auto px-4 py-3">
{comments.length === 0 && !loading ? (
{comments.length === 0 && loading ? (
<CommentsSkeleton />
) : comments.length === 0 && !loading ? (
<p className="py-8 text-center text-gray-500">{t("posts.noComments")}</p>
) : (
comments.map((item, index) => (
@@ -323,7 +326,7 @@ export default function CourseCommentsContent({
</div>
))
)}
{loading ? (
{loading && comments.length > 0 ? (
<p className="py-4 text-center text-sm text-neutral-400">
{t("common.loading")}
</p>

View File

@@ -17,10 +17,10 @@ import {
reorderCaptionLinksToEnd,
splitCaptionBodyAndLinks,
} from "@/lib/postLinkCaption";
import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl";
import { buildStorageUrl } from "@/components/main/BaseUrl";
import VerificationBadge from "@/components/main/VerificationBadge";
import ScoreRateStats from "@/components/main/ScoreRateStats";
import BoldIcon from "@/components/ui/BoldIcon";
import DiscountBadge from "@/components/ui/DiscountBadge";
import { useUserById } from "@/hooks/getUserById";
import "slick-carousel/slick/slick.css";
import "slick-carousel/slick/slick-theme.css";
@@ -404,7 +404,7 @@ function MainModelCard({
<Image
src={
academy?.academy_image
? IMAGE_BASE_URL + academy?.academy_image
? buildStorageUrl(academy.academy_image)
: "/images/fake-avatar.png"
}
alt={cuorse_name}
@@ -429,15 +429,7 @@ function MainModelCard({
<span className="text-[#0090ff] text-xl font-semibold flex items-center">
{formatCoursePriceLabel(postData, priceLang)}
{!postData.is_free && postData.offer && Number(postData.offer) > 0 ? (
<span className="mr-2 inline-flex items-center gap-1 rounded-full bg-[#ff5c00] px-2 py-0.5 text-xs font-bold text-white">
{t("shops.discountPercent", { percent: postData.offer })}
<BoldIcon
name="receipt-discount"
size={12}
tinted
className="text-white"
/>
</span>
<DiscountBadge percent={Number(postData.offer)} className="mr-2" />
) : null}
</span>
</div>
@@ -466,7 +458,7 @@ function MainModelCard({
<div className="w-full flex-shrink-0 flex justify-center items-center">
<div className="w-full rounded-2xl overflow-hidden shadow-[0_8px_30px_rgba(0,0,0,0.12)] dark:shadow-[0_8px_30px_rgba(0,0,0,0.45)]">
<Image
src={IMAGE_BASE_URL + course_image}
src={buildStorageUrl(course_image)}
alt={`media-${course_image}`}
width={1000}
height={1000}

View File

@@ -1,11 +1,16 @@
"use client";
import { useState } from "react";
import { useAppLanguage } from "@/contexts/LanguageProvider";
import { ENABLED_LANGUAGES, LANGUAGE_LABELS, type AppLanguage } from "@/lib/i18n/registry";
import { PRIMARY_LANGUAGES, LANGUAGE_LABELS, type AppLanguage } from "@/lib/i18n/registry";
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
import OtherLanguagesModal from "./OtherLanguagesModal";
export default function AuthLanguageToggle({ className }: { className?: string }) {
const { t } = useTranslation("common");
const { language, setLanguage } = useAppLanguage();
const [modalOpen, setModalOpen] = useState(false);
const handleChange = (next: AppLanguage) => {
if (next === language) return;
@@ -13,25 +18,35 @@ export default function AuthLanguageToggle({ className }: { className?: string }
};
return (
<div className={cn("flex items-center gap-1.5", className)}>
{ENABLED_LANGUAGES.map((code) => {
const active = language === code;
return (
<button
key={code}
type="button"
onClick={() => handleChange(code)}
className={cn(
"rounded-full border px-3 py-1 text-xs font-semibold transition-colors",
active
? "border-[#0095f6] bg-[#0095f6]/10 text-[#0095f6]"
: "border-neutral-200 text-neutral-500 dark:border-neutral-700 dark:text-neutral-400"
)}
>
{LANGUAGE_LABELS[code].native}
</button>
);
})}
</div>
<>
<div className={cn("flex items-center gap-1.5", className)}>
{PRIMARY_LANGUAGES.map((code) => {
const active = language === code;
return (
<button
key={code}
type="button"
onClick={() => handleChange(code)}
className={cn(
"rounded-full border px-3 py-1 text-xs font-semibold transition-colors",
active
? "border-[#0095f6] bg-[#0095f6]/10 text-[#0095f6]"
: "border-neutral-200 text-neutral-500 dark:border-neutral-700 dark:text-neutral-400"
)}
>
{LANGUAGE_LABELS[code].native}
</button>
);
})}
<button
type="button"
onClick={() => setModalOpen(true)}
className="rounded-full border border-neutral-200 px-3 py-1 text-xs font-semibold text-neutral-500 transition-colors dark:border-neutral-700 dark:text-neutral-400"
>
{t("language.otherLanguages")}
</button>
</div>
<OtherLanguagesModal open={modalOpen} onClose={() => setModalOpen(false)} />
</>
);
}

View File

@@ -71,7 +71,7 @@ function AuthPageLayout({ children, className }: AuthPageLayoutProps) {
<Container>
<div
className={cn(
"flex min-h-screen flex-col items-center py-4",
"flex min-h-dvh flex-col items-center py-4",
className
)}
>

View File

@@ -0,0 +1,110 @@
"use client";
import Modal from "@/components/elements/Modal";
import RoundedInput from "@/components/elements/RoundedInput";
import { useAppLanguage } from "@/contexts/LanguageProvider";
import {
SUPPORTED_LANGUAGES,
LANGUAGE_REGISTRY,
type AppLanguage,
} from "@/lib/i18n/registry";
import { useMemo, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
type OtherLanguagesModalProps = {
open: boolean;
onClose: () => void;
};
export default function OtherLanguagesModal({
open,
onClose,
}: OtherLanguagesModalProps) {
const { t } = useTranslation("common");
const { language, setLanguage } = useAppLanguage();
const [query, setQuery] = useState("");
const languages = useMemo(() => {
const q = query.trim().toLowerCase();
return SUPPORTED_LANGUAGES.map((code) => LANGUAGE_REGISTRY[code]).filter(
(def) =>
!q ||
def.nativeLabel.toLowerCase().includes(q) ||
def.englishLabel.toLowerCase().includes(q)
);
}, [query]);
const handleSelect = async (code: AppLanguage) => {
const def = LANGUAGE_REGISTRY[code];
if (!def.enabled) {
toast(t("language.comingSoon"));
return;
}
if (code !== language) {
await setLanguage(code);
}
onClose();
};
return (
<Modal
isOpen={open}
onClose={onClose}
height="fit"
elevated
panelClassName="!p-0 max-h-[80dvh] w-full overflow-y-auto overscroll-y-contain touch-pan-y [-webkit-overflow-scrolling:touch]"
>
<div className="px-4 pt-5 pb-[calc(2rem+env(safe-area-inset-bottom,0px))]">
<p className="mb-4 text-center font-semibold">
{t("language.selectTitle")}
</p>
<RoundedInput
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("language.searchPlaceholder")}
className="mb-4"
/>
{languages.length === 0 ? (
<p className="py-10 text-center text-sm text-neutral-500">
{t("language.noResults")}
</p>
) : (
<div className="flex flex-col gap-2">
{languages.map((def) => {
const active = def.code === language;
return (
<button
key={def.code}
type="button"
dir={def.direction}
onClick={() => void handleSelect(def.code as AppLanguage)}
className={`flex items-center justify-between rounded-xl border px-4 py-3 text-right transition-colors ${
active
? "border-[#0095f6] bg-[#0095f6]/10"
: "border-neutral-200 dark:border-neutral-800"
} ${!def.enabled ? "opacity-60" : ""}`}
>
<div className="flex flex-col items-start">
<span className="text-sm font-semibold">
{def.nativeLabel}
</span>
<span className="text-xs text-neutral-500">
{def.englishLabel}
</span>
</div>
{!def.enabled && (
<span className="rounded-full bg-neutral-100 px-2 py-0.5 text-[10px] font-semibold text-neutral-500 dark:bg-neutral-800">
{t("language.comingSoonBadge")}
</span>
)}
</button>
);
})}
</div>
)}
</div>
</Modal>
);
}

View File

@@ -4,6 +4,7 @@ import AdProfileContent from "@/components/billboards/Profile/AdProfileContent";
import AdProfileHead from "@/components/billboards/Profile/AdProfileHead";
import Container from "@/components/elements/Container";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import BackButton from "@/components/ui/BackButton";
import { IAdvertisingProfile } from "@/types/types";
import { useTranslation } from "react-i18next";
@@ -26,6 +27,7 @@ export default function AdvertisingProfileContent({
{profile.city?.name}, {profile.neighbourhood}
</h1>
<BackButton />
<AdProfileHead profile={profile} id={id} />
<AdProfileContent profile={profile} />
</Container>

View File

@@ -5,6 +5,7 @@ import BillboardImageSlider from "@/components/billboards/BillboardPage/Billboar
import BillboardDetails from "@/components/billboards/BillboardPage/BillboardDetails";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import MainBillboardCardActions from "@/components/billboards/MainBillboardCard/MainBillboardCardActions";
import BackButton from "@/components/ui/BackButton";
import { IAdvertising, IRate } from "@/types/types";
import { trackExploreInteraction } from "@/api/trackExploreInteraction";
import Cookies from "js-cookie";
@@ -43,6 +44,7 @@ export default function BillboardDetailContent({
return (
<LocalePageShell>
<Container>
<BackButton />
<a
href={`/billboards/profile/${billboard?.creatorId}/${encodeURIComponent(billboard?.title || "")}`}
>

View File

@@ -8,6 +8,7 @@ import SelectBox from "@/components/elements/SelectBox";
import RoundedButton from "@/components/elements/RoundedButton";
import { btnHeightMd, btnHeightSm, toggleBtnClass } from "@/lib/ui/buttonStyles";
import { cn } from "@/lib/utils";
import { useDefaultCountryId } from "@/hooks/useDefaultCountryId";
import { useTranslation } from "react-i18next";
interface IFilterProps {
@@ -46,6 +47,7 @@ function FilterModal({
}: IFilterProps) {
const { t } = useTranslation("common");
const { request } = useAxios();
const defaultCountryId = useDefaultCountryId();
const [allStates, setAllStates] = useState<IProvince[] | null>(null);
const [cities, setCities] = useState<ICity[] | null>(null);
const [categories, setCategories] = useState<ICategory[] | null>(null);
@@ -54,12 +56,12 @@ function FilterModal({
const fetchStates = async () => {
const response = await request<{ provinces: IProvince[] }>(
"GET",
"/provinces"
defaultCountryId ? `/provinces?country_id=${defaultCountryId}` : "/provinces"
);
setAllStates(response?.provinces || null);
};
fetchStates();
}, [request]);
}, [request, defaultCountryId]);
useEffect(() => {
const fetchCategories = async () => {

View File

@@ -2,11 +2,12 @@
import React, { useState } from "react";
import Modal from "../../elements/Modal";
import Image from "next/image";
import useInfiniteScroll from "@/hooks/useInfiniteScroll";
import VerificationBadge from "@/components/main/VerificationBadge";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import RoundedInput from "@/components/elements/RoundedInput";
import BoldIcon from "@/components/ui/BoldIcon";
import CommentsSkeleton from "@/components/ui/CommentsSkeleton";
import useAxios from "@/hooks/useAxios";
import { useRequireAuth } from "@/lib/auth/useRequireAuth";
import { useTranslation } from "react-i18next";
@@ -45,7 +46,7 @@ function BillboardCommentsModal({
advertisingId,
}: ReportModalProps) {
const { t } = useTranslation("common");
const { data, containerRef, isFetchingNextPage } = useInfiniteScroll({
const { data, containerRef, isFetchingNextPage, isLoading, refetch } = useInfiniteScroll({
endpoint: "/advertising/comments",
queryKey: ["comments", advertisingId],
params: { advertisingId: advertisingId },
@@ -54,87 +55,91 @@ function BillboardCommentsModal({
const { request } = useAxios();
const requireAuth = useRequireAuth();
const comments: Comments[] =
data?.pages?.flatMap((page) => page?.comments || []) || [];
const sendMessageHandler = async () => {
if (!requireAuth()) return;
if (!requireAuth() || !newMessage.trim()) return;
try {
const data = {
await request("POST", "/advertising/comment", {
text: newMessage,
advertisingId: advertisingId,
};
await request("POST", "/advertising/comment", data);
});
setNewMessage("");
refetch?.();
} catch (error) {
console.log(error);
}
};
return (
<div
onClick={(e) => {
e.stopPropagation();
}}
>
<Modal isOpen={isOpen} onClose={onClose} height="540px">
<div
ref={containerRef}
className="overflow-y-auto max-h-[500px] p-4 pb-16"
>
{data?.pages.length === 0 ||
(data?.pages[0]?.comments?.length === 0 && !isFetchingNextPage) ? (
<p className="text-center text-gray-500">{t("billboards.comments.empty")}</p>
<Modal isOpen={isOpen} onClose={onClose} height="min(82vh, 520px)" elevated>
<div className="flex h-full flex-col">
<div className="flex shrink-0 items-center justify-center border-b border-neutral-200/60 px-4 py-3 dark:border-neutral-700">
<h3 className="font-bold">{t("posts.comments")}</h3>
</div>
<div ref={containerRef} className="min-h-0 flex-1 overflow-y-auto px-4 py-3">
{isLoading ? (
<CommentsSkeleton />
) : comments.length === 0 && !isFetchingNextPage ? (
<p className="py-8 text-center text-gray-500">{t("billboards.comments.empty")}</p>
) : (
data?.pages?.map((page, pageIndex) => (
<React.Fragment key={pageIndex}>
{page?.comments?.map((item: Comments) => (
<div key={item?._id} className="mb-4">
<div className="flex">
<ProfileAvatar
src={item?.userId?.profile_image}
alt={item?.userId?.user_name || t("common.user")}
size="chat"
rounded="full"
className="ml-2"
userId={item?.userId?._id}
verifyBadge={item?.userId?.verify_badge}
/>
<div className="text-sm">
<div className="flex items-center gap-1 mt-1 font-semibold">
<span className="inline-flex items-center gap-1">
{item?.userId?.user_name}
<VerificationBadge verifyBadge={item?.userId?.verify_badge} />
</span>
</div>
<p>{item.text}</p>
</div>
</div>
comments.map((item) => (
<div
key={item._id}
className="mb-3 flex gap-2 border-b border-neutral-100 pb-2 dark:border-neutral-800"
>
<ProfileAvatar
src={item?.userId?.profile_image}
alt={item?.userId?.user_name || t("common.user")}
size="chat"
rounded="full"
className="shrink-0"
userId={item?.userId?._id}
verifyBadge={item?.userId?.verify_badge}
/>
<div className="min-w-0 flex-1 text-sm">
<div className="flex items-center gap-2">
<span className="inline-flex items-center gap-1 font-semibold">
{item?.userId?.user_name}
<VerificationBadge verifyBadge={item?.userId?.verify_badge} />
</span>
</div>
))}
</React.Fragment>
<p className="mt-0.5 break-words text-neutral-800 dark:text-neutral-100">
{item.text}
</p>
</div>
</div>
))
)}
{isFetchingNextPage && (
<p className="text-center text-gray-500">
<p className="py-4 text-center text-sm text-neutral-400">
{t("billboards.comments.loading")}
</p>
)}
<div className="flex items-center w-full p-4 rounded-full max-w-md fixed bottom-24 left-1/2 -translate-x-1/2 px-4 gap-2 ">
<button onClick={sendMessageHandler}>
<Image
width={24}
height={24}
alt={"verify icon"}
src={`/images/icons/send-msg.svg`}
className="dark:invert min-w-[24px] min-h-[24px]"
/>
</div>
<div className="shrink-0 border-t border-neutral-200/60 px-4 pt-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] dark:border-neutral-700">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => void sendMessageHandler()}
className="ig-dm-send-btn gentle-transition flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-white active:scale-90"
>
<BoldIcon name="send-2" size={18} tinted className="text-current" />
</button>
<RoundedInput
value={newMessage}
placeholder={t("posts.writeComment")}
onChange={(e) => setNewMessage(e.target.value)}
className="flex-1"
/>
</div>
</div>
</Modal>
</div>
</div>
</Modal>
);
}

View File

@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import BackButton from "@/components/ui/BackButton";
import Step1 from "./Step1";
import Step2 from "./Step2";
import Step3 from "./Step3";
@@ -12,6 +13,9 @@ const MultiStepForm = () => {
return (
<div className="p-4 flex flex-col items-center w-full">
<div className="flex w-full max-w-sm justify-start">
<BackButton onClick={() => (step > 1 ? setStep(step - 1) : window.history.back())} />
</div>
{step === 1 && <Step1 nextStep={() => setStep(2)} />}
{step === 2 && <Step2 nextStep={() => setStep(3)} />}
{step === 3 && <Step3 nextStep={() => setStep(4)} />}

View File

@@ -5,12 +5,14 @@ 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 { useEffect, useMemo, useRef, 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 { countryHasProvinces } from "@/components/ui/CountryProvinceCitySelect";
import { useDefaultCountryId } from "@/hooks/useDefaultCountryId";
import { useTranslation } from "react-i18next";
const Step1 = ({ nextStep }: { nextStep: () => void }) => {
@@ -18,12 +20,26 @@ const Step1 = ({ nextStep }: { nextStep: () => void }) => {
const { formData, updateForm } = useBillboardForm();
const [categories, setCategories] = useState<IAdvertisingCategory[]>([]);
const { request } = useAxios();
const defaultCountryId = useDefaultCountryId();
const seededDefault = useRef(false);
const validationSchema = useMemo(
() =>
Yup.object({
stateId: Yup.string().required(t("billboards.form.validation.provinceRequired")),
cityId: Yup.string().required(t("billboards.form.validation.cityRequired")),
stateId: Yup.string().when("countryId", {
is: (countryId: string) =>
countryHasProvinces(countryId ? Number(countryId) : null),
then: (schema) =>
schema.required(t("billboards.form.validation.provinceRequired")),
otherwise: (schema) => schema,
}),
cityId: Yup.string().when("countryId", {
is: (countryId: string) =>
countryHasProvinces(countryId ? Number(countryId) : null),
then: (schema) =>
schema.required(t("billboards.form.validation.cityRequired")),
otherwise: (schema) => schema,
}),
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")),
@@ -58,6 +74,7 @@ const Step1 = ({ nextStep }: { nextStep: () => void }) => {
const formik = useFormik({
initialValues: {
countryId: formData.countryId || "",
cityId: formData.cityId || "",
stateId: formData.stateId || "",
adsTitle: formData.adsTitle || "",
@@ -75,6 +92,13 @@ const Step1 = ({ nextStep }: { nextStep: () => void }) => {
},
});
useEffect(() => {
if (seededDefault.current || formik.values.countryId || !defaultCountryId) return;
seededDefault.current = true;
formik.setFieldValue("countryId", String(defaultCountryId));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [defaultCountryId]);
return (
<form
onSubmit={formik.handleSubmit}

View File

@@ -1,10 +1,10 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
"use client";
import { ICity, IProvince } from "@/types/types";
import SelectBox from "@/components/elements/SelectBox";
import CountryProvinceCitySelect, {
type GeoSelection,
} from "@/components/ui/CountryProvinceCitySelect";
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";
@@ -20,61 +20,33 @@ 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 geoSelection: GeoSelection = {
countryId: formik.values.countryId ? Number(formik.values.countryId) : null,
provinceId: formik.values.stateId ? Number(formik.values.stateId) : null,
cityId: formik.values.cityId ? Number(formik.values.cityId) : null,
};
const fetchCities = async (provinceId: string) => {
try {
const response = await request<{ cities: ICity[] }>(
"GET",
`/cities/${provinceId}`
);
setCities(response?.cities || []);
} catch (err) {
console.log(err);
}
const handleGeoChange = (next: GeoSelection) => {
formik.setFieldValue("countryId", next.countryId ? String(next.countryId) : "");
formik.setFieldValue("stateId", next.provinceId ? String(next.provinceId) : "");
formik.setFieldValue("cityId", next.cityId ? String(next.cityId) : "");
};
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]),
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [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({
@@ -86,48 +58,16 @@ const LocationSelector: React.FC<LocationSelectorProps> = ({ formik }) => {
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>
<CountryProvinceCitySelect
value={geoSelection}
onChange={handleGeoChange}
className="w-full max-w-full"
/>
{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}

View File

@@ -25,6 +25,7 @@ export async function submitBillboard(
adsTitle,
categoryId,
description,
countryId,
stateId,
cityId,
neighbourhood,
@@ -47,6 +48,7 @@ export async function submitBillboard(
if (description) {
data.append("description", description);
}
data.append("country", countryId ? countryId : "");
data.append("province", stateId ? stateId : "");
data.append("city", cityId ? cityId : "");
if (neighbourhood) {

View File

@@ -185,6 +185,7 @@ function PreviewOverlay({
src={preview}
controls
autoPlay
muted
playsInline
className="max-h-[88vh] rounded-2xl shadow-2xl"
/>
@@ -263,6 +264,34 @@ const ChatMessageCard = ({
} | null>(null);
const [viewOnceVoiceUrl, setViewOnceVoiceUrl] = useState<string | null>(null);
const { openViewOnce, completeViewOnce } = useViewOnceMedia();
const finishViewOnce = useCallback(async () => {
setViewOncePreview(null);
setViewOnceVoiceUrl(null);
if (message._id.startsWith("temp")) return;
try {
await completeViewOnce(message._id);
} catch {
toast.error(t("chats.toast.viewOnceDeleteFailed"));
}
}, [completeViewOnce, message._id, t]);
const handleViewOnceOpen = useCallback(async () => {
if (message._id.startsWith("temp")) return;
try {
const { url, fileType: openedType } = await openViewOnce(message._id);
if (openedType === "voice") {
setViewOnceVoiceUrl(url);
} else if (openedType === "video") {
setViewOncePreview({ url, type: "video" });
} else {
setViewOncePreview({ url, type: "image" });
}
} catch {
toast.error(t("chats.toast.viewOnceOpenFailed"));
}
}, [message._id, openViewOnce, t]);
const canSelectForDelete =
deleteMode &&
isSender &&
@@ -291,6 +320,7 @@ const ChatMessageCard = ({
swipeEnabled && "onDragStart" in dragProps ? dragProps.onDragStart : undefined;
const restDragProps = swipeEnabled
? (() => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { onDragStart: _, ...rest } = dragProps;
return rest;
})()
@@ -380,33 +410,6 @@ const ChatMessageCard = ({
(message.viewOnceLocked || message.viewOnceExpired) &&
!viewOnceVoiceUrl;
const finishViewOnce = useCallback(async () => {
setViewOncePreview(null);
setViewOnceVoiceUrl(null);
if (message._id.startsWith("temp")) return;
try {
await completeViewOnce(message._id);
} catch {
toast.error(t("chats.toast.viewOnceDeleteFailed"));
}
}, [completeViewOnce, message._id]);
const handleViewOnceOpen = useCallback(async () => {
if (message._id.startsWith("temp")) return;
try {
const { url, fileType: openedType } = await openViewOnce(message._id);
if (openedType === "voice") {
setViewOnceVoiceUrl(url);
} else if (openedType === "video") {
setViewOncePreview({ url, type: "video" });
} else {
setViewOncePreview({ url, type: "image" });
}
} catch {
toast.error(t("chats.toast.viewOnceOpenFailed"));
}
}, [message._id, openViewOnce]);
const TimeInline = () => (
<span className="message-time-inline">
{timeLabel}
@@ -839,6 +842,8 @@ const ChatMessageCard = ({
src={viewOncePreview.url}
controls
autoPlay
muted
playsInline
className="max-h-[88vh] rounded-lg"
/>
) : (

View File

@@ -7,6 +7,7 @@ import RoundedButton from "@/components/elements/RoundedButton";
import RoundedInput from "@/components/elements/RoundedInput";
import SelectBox from "@/components/elements/SelectBox";
import useAxios from "@/hooks/useAxios";
import { useDefaultCountryId } from "@/hooks/useDefaultCountryId";
import { ICity, IExpertise, IProvince } from "@/types/types";
import toast from "react-hot-toast";
import BoldIcon from "@/components/ui/BoldIcon";
@@ -30,6 +31,7 @@ type RoomVisibility = "public" | "private";
export default function CreateChatRoomModal({ open, onClose, onCreated }: Props) {
const { t } = useTranslation("common");
const { request } = useAxios();
const defaultCountryId = useDefaultCountryId();
const fileRef = useRef<HTMLInputElement>(null);
const [title, setTitle] = useState("");
const [visibility, setVisibility] = useState<RoomVisibility>("public");
@@ -57,14 +59,17 @@ export default function CreateChatRoomModal({ open, onClose, onCreated }: Props)
setImageFile(null);
setImagePreview(null);
void request<{ provinces: IProvince[] }>("GET", "/provinces", null, {
noToast: true,
}).then((res) => setProvinces(res?.provinces ?? []));
void request<{ provinces: IProvince[] }>(
"GET",
defaultCountryId ? `/provinces?country_id=${defaultCountryId}` : "/provinces",
null,
{ noToast: true }
).then((res) => setProvinces(res?.provinces ?? []));
void request<{ expertises: IExpertise[] }>("GET", "/expertise", null, {
noToast: true,
}).then((res) => setExpertiseList(res?.expertises ?? []));
}, [open, request]);
}, [open, request, defaultCountryId]);
useEffect(() => {
if (!provinceId) {

View File

@@ -3,10 +3,10 @@
import { Course } from "@/types/types";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Edit, Trash2, Video, Clock, DollarSign, Highlighter, ChevronLeft, ChevronRight } from "lucide-react";
import Image from "next/image";
import { IMAGE_BASE_URL } from "../main/BaseUrl";
import DiscountBadge from "@/components/ui/DiscountBadge";
import { buildStorageUrl } from "../main/BaseUrl";
import PageLoader from "@/components/ui/PageLoader";
import { useTranslation } from "react-i18next";
@@ -60,7 +60,7 @@ export function CourseList({
<Card key={course._id || index} className={`overflow-hidden hover:shadow-lg transition-shadow ${course.type === "pro" ? "shadow-lg shadow-[#FF107D] border-2 border-[#FF107D]" : ""}`}>
<div className="relative h-48 w-full">
<Image
src={IMAGE_BASE_URL + course.course_image}
src={buildStorageUrl(course.course_image)}
alt={course.cuorse_name}
fill
className="object-cover"
@@ -69,9 +69,10 @@ export function CourseList({
}}
/>
{course.offer && parseInt(course.offer) > 0 && (
<Badge className="absolute top-2 right-2 bg-red-500">
{t("academyCourse.discountBadge", { percent: course.offer })}
</Badge>
<DiscountBadge
percent={parseInt(course.offer)}
className="absolute right-2 top-2"
/>
)}
</div>

View File

@@ -17,6 +17,7 @@ import { Course } from "@/types/types";
import { toast } from "react-hot-toast";
import useAxios from "@/hooks/useAxios";
import SubscriptionCard from "./SubscriptionCard";
import BackButton from "@/components/ui/BackButton";
import Cookies from "js-cookie";
import { useTranslation } from "react-i18next";
function appendCourseFormFields(formData: FormData, data: any) {
@@ -454,6 +455,7 @@ export function CourseManager() {
return (
<div className="container mx-auto px-4 py-6 lg:px-6">
<BackButton className="mb-4" />
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold">{t("academyCourse.manageTitle")}</h1>
<Button

View File

@@ -22,6 +22,7 @@ import { Post } from "@/types/types";
import { MutedProvider } from "@/components/models/ModelPage/MainModelCardPost";
import ReelsPostCard from "@/components/posts/ReelsPostCard";
import ReelsAcademyCard from "@/components/explore/ReelsAcademyCard";
import BackButton from "@/components/ui/BackButton";
import PageLoader from "@/components/ui/PageLoader";
import { ExploreFilterId } from "@/constants/exploreFilters";
import { buildExplorePostFilters } from "@/lib/explore/buildPostFilters";
@@ -385,6 +386,11 @@ export default function ExploreReelsView({
<ExploreReelsUiProvider>
<MutedProvider>
<div className="relative min-h-[100dvh] bg-black">
<BackButton
tinted
iconSize={22}
className="fixed right-4 top-[calc(1rem+env(safe-area-inset-top))] z-[100] bg-black/50 text-white"
/>
<div className={REELS_SHELL_CLASS}>
<div
ref={scrollRef}

View File

@@ -4,6 +4,7 @@ import { Service } from "@/types/types";
import Image from "next/image";
import React from "react";
import { IMAGE_BASE_URL } from "../BaseUrl";
import DiscountBadge from "@/components/ui/DiscountBadge";
import { useTranslation } from "react-i18next";
interface ServiceItemProps {
@@ -66,8 +67,8 @@ const ServiceItem: React.FC<ServiceItemProps> = ({ service, onDelete }) => {
<div className="font-semibold text-xs md:text-sm mr-4 min-w-16">
{Number(discountPrice).toLocaleString()} {t("settings.toman")}
</div>
<div className="text-xs md:text-sm bg-red-600 text-white w-10 h-7 flex items-center justify-center rounded-full pt-1 mr-2">
{discountPercentage}%
<div className="mr-2">
<DiscountBadge percent={discountPercentage} />
</div>
</>
) : (

View File

@@ -15,6 +15,7 @@ import AppearanceColorSelect from "./AppearanceColorSelect";
import { isModelExpertise } from "@/lib/isModelExpertise";
import RoundedButton from "../elements/RoundedButton";
import { filterChipClass } from "@/lib/ui/buttonStyles";
import { useDefaultCountryId } from "@/hooks/useDefaultCountryId";
interface IFilterProps {
setShowFilterModal: (e: boolean) => void;
@@ -133,6 +134,7 @@ function FilterModal({
}: IFilterProps) {
const { t } = useTranslation("common");
const { request } = useAxios();
const defaultCountryId = useDefaultCountryId();
const [allStates, setAllStates] = useState<IProvince[] | null>(null);
const [cities, setCities] = useState<ICity[] | null>(null);
const [expertiseList, setExpertiseList] = useState<IExpertise[]>([]);
@@ -142,12 +144,12 @@ function FilterModal({
const fetchStates = async () => {
const response = await request<{ provinces: IProvince[] }>(
"GET",
"/provinces"
defaultCountryId ? `/provinces?country_id=${defaultCountryId}` : "/provinces"
);
setAllStates(response?.provinces || null);
};
fetchStates();
}, []);
}, [defaultCountryId]);
useEffect(() => {
if (!showFilterModal) return;

View File

@@ -18,7 +18,7 @@ export default function CommentsModal({
postId,
}: CommentsModalProps) {
return (
<Modal isOpen={isOpen} onClose={onClose} height="min(82vh, 520px)">
<Modal isOpen={isOpen} onClose={onClose} height="min(82vh, 520px)" elevated>
<PostCommentsContent
userId={userId}
postId={postId}

View File

@@ -434,7 +434,7 @@ function MainModelCard({
/>
</div>
)}
<div className="pointer-events-none absolute left-3 top-3 z-50 rounded-full bg-black/50 p-2 backdrop-blur-md">
<div className="pointer-events-none absolute bottom-3 left-3 z-50 rounded-full bg-black/50 p-2 backdrop-blur-md">
{videoMuted ? (
<BoldIcon name="volume-slash" size={20} tinted className="text-white" aria-label={t("models.mute")} />
) : (

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