project
This commit is contained in:
@@ -10,6 +10,7 @@ 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 SpecialistCarousel from "@/components/projects/SpecialistCarousel";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -468,6 +469,18 @@ export default function SpecialistsPage() {
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{phase === "results" && !showFilter && users.length > 0 ? (
|
||||
<SpecialistCarousel
|
||||
items={users}
|
||||
title={t("projects.specialistCarousel.title", "متخصصین پیشنهادی")}
|
||||
description={t(
|
||||
"projects.specialistCarousel.description",
|
||||
"با استعدادهای برتر مطابق فیلترتان آشنا شوید"
|
||||
)}
|
||||
className="mt-8"
|
||||
/>
|
||||
) : null}
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
|
||||
@@ -99,7 +99,7 @@ export default async function RootLayout({
|
||||
<SiteJsonLd />
|
||||
<RegisterSW />
|
||||
<ClientErrorBoundary>
|
||||
<Layout>{children}</Layout>
|
||||
<Layout initialLanguage={lang}>{children}</Layout>
|
||||
</ClientErrorBoundary>
|
||||
{/* ====================== گوگل آنالیتیکس ====================== */}
|
||||
<Script
|
||||
|
||||
288
src/app/settings/chats/find-partner/page.tsx
Normal file
288
src/app/settings/chats/find-partner/page.tsx
Normal file
@@ -0,0 +1,288 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import Container from "@/components/elements/Container";
|
||||
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 { usePageTitle } from "@/hooks/usePageTitle";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatFullName } from "@/lib/formatFullName";
|
||||
import toast from "react-hot-toast";
|
||||
import FindPartnerFilterModal, {
|
||||
EMPTY_PARTNER_FILTERS,
|
||||
PartnerFilters,
|
||||
} from "@/components/chats/FindPartnerFilterModal";
|
||||
|
||||
type PartnerUser = {
|
||||
_id: string;
|
||||
user_name: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
profile_image?: string;
|
||||
is_verified?: string;
|
||||
verify_badge?: string;
|
||||
marital_status?: string | null;
|
||||
vehicle_type?: string | null;
|
||||
job?: string | null;
|
||||
birthday?: string | null;
|
||||
province?: { name?: string } | null;
|
||||
city?: { name?: string } | null;
|
||||
};
|
||||
|
||||
type PartnerAlert = { _id: string } | null;
|
||||
|
||||
function ageFromBirthday(birthday?: string | null): number | null {
|
||||
if (!birthday) return null;
|
||||
const diffMs = Date.now() - new Date(birthday).getTime();
|
||||
if (Number.isNaN(diffMs)) return null;
|
||||
return Math.floor(diffMs / (365.25 * 24 * 60 * 60 * 1000));
|
||||
}
|
||||
|
||||
function filtersToParams(filters: PartnerFilters): Record<string, string> {
|
||||
const params: Record<string, string> = {};
|
||||
const put = (key: string, value?: string) => {
|
||||
if (value) params[key] = value;
|
||||
};
|
||||
put("gender", filters.gender);
|
||||
put("ageMin", filters.ageMin);
|
||||
put("ageMax", filters.ageMax);
|
||||
put("heightMin", filters.heightMin);
|
||||
put("heightMax", filters.heightMax);
|
||||
put("weightMin", filters.weightMin);
|
||||
put("weightMax", filters.weightMax);
|
||||
put("sizeMin", filters.sizeMin);
|
||||
put("sizeMax", filters.sizeMax);
|
||||
put("hair_color", filters.hairColor);
|
||||
put("eye_color", filters.eyeColor);
|
||||
put("marital_status", filters.maritalStatus);
|
||||
put("residence_type", filters.residenceType);
|
||||
put("vehicle_type", filters.vehicleType);
|
||||
put("personal_style", filters.personalStyle);
|
||||
put("income_range", filters.incomeRange);
|
||||
put("job", filters.job);
|
||||
if (filters.geo.countryId) params.country = String(filters.geo.countryId);
|
||||
if (filters.geo.provinceId) params.province = String(filters.geo.provinceId);
|
||||
if (filters.geo.cityId) params.city = String(filters.geo.cityId);
|
||||
return params;
|
||||
}
|
||||
|
||||
export default function FindPartnerPage() {
|
||||
const { t } = useTranslation("common");
|
||||
usePageTitle(t("findPartner.title"));
|
||||
const { request } = useAxios();
|
||||
|
||||
const [filters, setFilters] = useState<PartnerFilters>(EMPTY_PARTNER_FILTERS);
|
||||
const [showFilterModal, setShowFilterModal] = useState(false);
|
||||
const [users, setUsers] = useState<PartnerUser[]>([]);
|
||||
const [status, setStatus] = useState<"loading" | "ready" | "error">("loading");
|
||||
const [alert, setAlert] = useState<PartnerAlert>(null);
|
||||
const [savingAlert, setSavingAlert] = useState(false);
|
||||
|
||||
const runSearch = useCallback(
|
||||
async (activeFilters: PartnerFilters) => {
|
||||
setStatus("loading");
|
||||
try {
|
||||
const params = filtersToParams(activeFilters);
|
||||
const query = new URLSearchParams(params).toString();
|
||||
const res = await request<{ users?: PartnerUser[] }>(
|
||||
"GET",
|
||||
`/users/partner-search${query ? `?${query}` : ""}`,
|
||||
null,
|
||||
{ noToast: true }
|
||||
);
|
||||
setUsers(res?.users ?? []);
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
setStatus("error");
|
||||
}
|
||||
},
|
||||
[request]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void runSearch(EMPTY_PARTNER_FILTERS);
|
||||
request<{ alert: PartnerAlert }>("GET", "/users/partner-search/alerts", null, {
|
||||
noToast: true,
|
||||
})
|
||||
.then((res) => setAlert(res?.alert ?? null))
|
||||
.catch(() => {});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleApplyFilters = () => {
|
||||
void runSearch(filters);
|
||||
};
|
||||
|
||||
const handleClearFilters = () => {
|
||||
setFilters(EMPTY_PARTNER_FILTERS);
|
||||
void runSearch(EMPTY_PARTNER_FILTERS);
|
||||
setShowFilterModal(false);
|
||||
};
|
||||
|
||||
const handleSaveSearch = async () => {
|
||||
setSavingAlert(true);
|
||||
try {
|
||||
const res = await request<{ alert: PartnerAlert }>(
|
||||
"POST",
|
||||
"/users/partner-search/alerts",
|
||||
filtersToParams(filters)
|
||||
);
|
||||
setAlert(res?.alert ?? null);
|
||||
toast.success(t("findPartner.searchSaved"));
|
||||
} catch {
|
||||
// خطا از طریق interceptor نمایش داده میشود
|
||||
} finally {
|
||||
setSavingAlert(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelSearch = async () => {
|
||||
if (!alert?._id) return;
|
||||
setSavingAlert(true);
|
||||
try {
|
||||
await request("DELETE", `/users/partner-search/alerts/${alert._id}`);
|
||||
setAlert(null);
|
||||
toast.success(t("findPartner.searchCancelled"));
|
||||
} catch {
|
||||
// خطا از طریق interceptor نمایش داده میشود
|
||||
} finally {
|
||||
setSavingAlert(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<LocalePageShell>
|
||||
<Container className="pb-28">
|
||||
<div className="mb-4 flex items-center gap-3 pt-2">
|
||||
<BackButton href="/settings/chats" className="bg-neutral-100 dark:bg-neutral-800" />
|
||||
<h1 className="text-base font-bold md:text-lg">{t("findPartner.title")}</h1>
|
||||
</div>
|
||||
|
||||
<p className="mb-4 text-center text-xs text-neutral-500">{t("findPartner.hint")}</p>
|
||||
|
||||
<RoundedButton
|
||||
type="button"
|
||||
onClick={() => setShowFilterModal(true)}
|
||||
className="mx-auto mb-5 h-9 w-full max-w-none !bg-neutral-100 text-neutral-600 dark:!bg-neutral-800 dark:text-neutral-300"
|
||||
>
|
||||
{t("findPartner.filterButton")}
|
||||
</RoundedButton>
|
||||
|
||||
{status === "loading" ? (
|
||||
<div className="flex flex-col items-center gap-3 py-10">
|
||||
<IOSSpinner />
|
||||
<p className="text-sm text-neutral-500">{t("findPartner.loading")}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{status === "error" ? (
|
||||
<div className="mx-auto max-w-sm rounded-2xl border border-red-200 p-5 text-center dark:border-red-900">
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{t("findPartner.loadError")}</p>
|
||||
<RoundedButton
|
||||
className="!border-transparent mt-4 h-9 w-full max-w-none !bg-sky-100 text-sky-600"
|
||||
onClick={() => runSearch(filters)}
|
||||
>
|
||||
{t("nearbyFriends.retryLocation")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{status === "ready" ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="mb-2 text-center text-[11px] text-neutral-400">
|
||||
{t("findPartner.resultsFound", { count: users.length })}
|
||||
</p>
|
||||
|
||||
{users.length === 0 ? (
|
||||
<div className="py-6 text-center">
|
||||
<p className="text-sm text-neutral-500">{t("findPartner.empty")}</p>
|
||||
<p className="mt-2 text-xs text-neutral-400">{t("findPartner.emptyHint")}</p>
|
||||
</div>
|
||||
) : (
|
||||
users.map((item) => {
|
||||
const age = ageFromBirthday(item.birthday);
|
||||
const location = item.city?.name || item.province?.name || "";
|
||||
return (
|
||||
<Link
|
||||
key={item._id}
|
||||
href={`/offer/${item._id}`}
|
||||
className="flex items-center gap-3 py-3"
|
||||
>
|
||||
<ProfileAvatar
|
||||
src={item.profile_image}
|
||||
alt={item.user_name}
|
||||
size="sm"
|
||||
rounded="full"
|
||||
className="!h-12 !w-12 shrink-0"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<span className="truncate font-semibold" dir="ltr">
|
||||
{item.user_name}
|
||||
</span>
|
||||
<VerificationBadge verifyBadge={item.verify_badge} />
|
||||
</div>
|
||||
{(item.first_name || item.last_name) && (
|
||||
<p className="truncate text-xs text-neutral-500">
|
||||
{formatFullName(item.first_name, item.last_name)}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-0.5 truncate text-[11px] text-neutral-400">
|
||||
{[age != null ? t("findPartner.ageYears", { count: age }) : null, location, item.job]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
<div className="mt-5 flex flex-col items-center gap-2 border-t border-neutral-200 pt-5 dark:border-neutral-700">
|
||||
{alert ? (
|
||||
<>
|
||||
<p className="text-center text-[11px] text-neutral-400">
|
||||
{t("findPartner.savedSearchActive")}
|
||||
</p>
|
||||
<RoundedButton
|
||||
type="button"
|
||||
disabled={savingAlert}
|
||||
onClick={handleCancelSearch}
|
||||
className="h-10 w-full max-w-[240px] text-sm"
|
||||
>
|
||||
{t("findPartner.cancelSavedSearch")}
|
||||
</RoundedButton>
|
||||
</>
|
||||
) : (
|
||||
<RoundedButton
|
||||
type="button"
|
||||
variant="primary"
|
||||
disabled={savingAlert}
|
||||
onClick={handleSaveSearch}
|
||||
className="h-10 w-full max-w-[240px] text-sm"
|
||||
>
|
||||
{t("findPartner.saveSearch")}
|
||||
</RoundedButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</Container>
|
||||
|
||||
<FindPartnerFilterModal
|
||||
isOpen={showFilterModal}
|
||||
onClose={() => setShowFilterModal(false)}
|
||||
filters={filters}
|
||||
onChange={setFilters}
|
||||
onApply={handleApplyFilters}
|
||||
onClear={handleClearFilters}
|
||||
/>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
@@ -179,6 +179,25 @@ function Chats() {
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/settings/chats/find-partner"
|
||||
className="flex items-center justify-between py-3"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-[#FC8EAC]/15">
|
||||
<BoldIcon name="heart-search" size={24} className="text-[#FC8EAC]" tinted />
|
||||
</div>
|
||||
<div className="min-w-0 flex flex-col gap-1">
|
||||
<span className="truncate font-semibold">
|
||||
{t("findPartner.title")}
|
||||
</span>
|
||||
<span className="truncate text-[11px] text-neutral-500">
|
||||
{t("findPartner.desc")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{isEmpty ? (
|
||||
<p className="py-8 text-center text-neutral-500">
|
||||
{t("chats.empty")}
|
||||
|
||||
@@ -22,6 +22,7 @@ const EDIT_NAV_KEY: Record<string, string> = {
|
||||
"/Authentication": "authentication",
|
||||
"/expertise": "expertise",
|
||||
"/colors": "colors",
|
||||
"/personal-details": "personalDetails",
|
||||
"/services": "services",
|
||||
"/sizes": "sizes",
|
||||
"/License": "license",
|
||||
|
||||
286
src/app/settings/edit/personal-details/page.tsx
Normal file
286
src/app/settings/edit/personal-details/page.tsx
Normal file
@@ -0,0 +1,286 @@
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import RoundedInput from "@/components/elements/RoundedInput";
|
||||
import AuthNextButton from "@/components/auth/AuthNextButton";
|
||||
import EditPageHeader from "@/components/settings/EditPageHeader";
|
||||
import { toggleBtnClass } from "@/lib/ui/buttonStyles";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useUser } from "@/hooks/useUser";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type MaritalStatus = "single" | "divorced" | null;
|
||||
type ResidenceType = "owner" | "tenant" | null;
|
||||
type VehicleType = "none" | "car" | "motorcycle" | null;
|
||||
type PersonalStyle = "formal" | "sporty" | null;
|
||||
type IncomeRange = "under_5m" | "5m_10m" | "10m_20m" | "over_20m" | null;
|
||||
|
||||
function PersonalDetailsPage() {
|
||||
const { t } = useTranslation("common");
|
||||
const router = useRouter();
|
||||
const { request, loading } = useAxios();
|
||||
const user = useUser();
|
||||
|
||||
const [maritalStatus, setMaritalStatus] = useState<MaritalStatus>(null);
|
||||
const [residenceType, setResidenceType] = useState<ResidenceType>(null);
|
||||
const [residenceArea, setResidenceArea] = useState("");
|
||||
const [vehicleType, setVehicleType] = useState<VehicleType>(null);
|
||||
const [vehicleDetails, setVehicleDetails] = useState("");
|
||||
const [personalStyle, setPersonalStyle] = useState<PersonalStyle>(null);
|
||||
const [job, setJob] = useState("");
|
||||
const [incomeRange, setIncomeRange] = useState<IncomeRange>(null);
|
||||
const [moralTraits, setMoralTraits] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
setMaritalStatus(user.marital_status ?? null);
|
||||
setResidenceType(user.residence_type ?? null);
|
||||
setResidenceArea(user.residence_area ?? "");
|
||||
setVehicleType(user.vehicle_type ?? null);
|
||||
setVehicleDetails(user.vehicle_details ?? "");
|
||||
setPersonalStyle(user.personal_style ?? null);
|
||||
setJob(user.job ?? "");
|
||||
setIncomeRange(user.income_range ?? null);
|
||||
setMoralTraits(user.moral_traits ?? "");
|
||||
}, [user]);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await request("PATCH", "/verify/personal_details", {
|
||||
marital_status: maritalStatus,
|
||||
residence_type: residenceType,
|
||||
residence_area: residenceArea || null,
|
||||
vehicle_type: vehicleType,
|
||||
vehicle_details: vehicleType && vehicleType !== "none" ? vehicleDetails || null : null,
|
||||
personal_style: personalStyle,
|
||||
job: job || null,
|
||||
income_range: incomeRange,
|
||||
moral_traits: moralTraits || null,
|
||||
});
|
||||
toast.success(t("settings.edit.personalDetails.saveSuccess"));
|
||||
router.push("/settings/edit");
|
||||
} catch (err: unknown) {
|
||||
toast.error(
|
||||
(err as { message?: string })?.message || t("settings.edit.unknownError")
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<div className="flex flex-col items-center gap-6 p-4 pb-24">
|
||||
<EditPageHeader title={t("settings.edit.personalDetails.title")} />
|
||||
<p className="max-w-sm text-center text-xs text-neutral-500">
|
||||
{t("settings.edit.personalDetails.hint")}
|
||||
</p>
|
||||
|
||||
<div className="flex w-full max-w-sm flex-col gap-6">
|
||||
{/* وضعیت تاهل */}
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-semibold">
|
||||
{t("settings.edit.personalDetails.maritalStatus")}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={toggleBtnClass(maritalStatus === "single", "h-10 flex-1")}
|
||||
onClick={() => setMaritalStatus("single")}
|
||||
>
|
||||
{t("settings.edit.personalDetails.maritalStatusSingle")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={toggleBtnClass(maritalStatus === "divorced", "h-10 flex-1")}
|
||||
onClick={() => setMaritalStatus("divorced")}
|
||||
>
|
||||
{t("settings.edit.personalDetails.maritalStatusDivorced")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* نوع سکونت */}
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-semibold">
|
||||
{t("settings.edit.personalDetails.residenceType")}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={toggleBtnClass(residenceType === "owner", "h-10 flex-1")}
|
||||
onClick={() => setResidenceType("owner")}
|
||||
>
|
||||
{t("settings.edit.personalDetails.residenceTypeOwner")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={toggleBtnClass(residenceType === "tenant", "h-10 flex-1")}
|
||||
onClick={() => setResidenceType("tenant")}
|
||||
>
|
||||
{t("settings.edit.personalDetails.residenceTypeTenant")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* محل سکونت */}
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-semibold">
|
||||
{t("settings.edit.personalDetails.residenceArea")}
|
||||
</p>
|
||||
<RoundedInput
|
||||
value={residenceArea}
|
||||
onChange={(e) => setResidenceArea(e.target.value)}
|
||||
placeholder={t("settings.edit.personalDetails.residenceAreaPlaceholder")}
|
||||
maxLength={255}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* وسیله نقلیه */}
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-semibold">
|
||||
{t("settings.edit.personalDetails.vehicleType")}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={toggleBtnClass(vehicleType === "none", "h-10 flex-1 text-xs")}
|
||||
onClick={() => setVehicleType("none")}
|
||||
>
|
||||
{t("settings.edit.personalDetails.vehicleTypeNone")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={toggleBtnClass(vehicleType === "car", "h-10 flex-1 text-xs")}
|
||||
onClick={() => setVehicleType("car")}
|
||||
>
|
||||
{t("settings.edit.personalDetails.vehicleTypeCar")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={toggleBtnClass(vehicleType === "motorcycle", "h-10 flex-1 text-xs")}
|
||||
onClick={() => setVehicleType("motorcycle")}
|
||||
>
|
||||
{t("settings.edit.personalDetails.vehicleTypeMotorcycle")}
|
||||
</button>
|
||||
</div>
|
||||
{vehicleType && vehicleType !== "none" && (
|
||||
<RoundedInput
|
||||
className="mt-2"
|
||||
value={vehicleDetails}
|
||||
onChange={(e) => setVehicleDetails(e.target.value)}
|
||||
placeholder={t("settings.edit.personalDetails.vehicleDetailsPlaceholder")}
|
||||
maxLength={255}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* استایل */}
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-semibold">
|
||||
{t("settings.edit.personalDetails.personalStyle")}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={toggleBtnClass(personalStyle === "formal", "h-10 flex-1")}
|
||||
onClick={() => setPersonalStyle("formal")}
|
||||
>
|
||||
{t("settings.edit.personalDetails.personalStyleFormal")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={toggleBtnClass(personalStyle === "sporty", "h-10 flex-1")}
|
||||
onClick={() => setPersonalStyle("sporty")}
|
||||
>
|
||||
{t("settings.edit.personalDetails.personalStyleSporty")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* شغل */}
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-semibold">
|
||||
{t("settings.edit.personalDetails.job")}
|
||||
</p>
|
||||
<RoundedInput
|
||||
value={job}
|
||||
onChange={(e) => setJob(e.target.value)}
|
||||
placeholder={t("settings.edit.personalDetails.jobPlaceholder")}
|
||||
maxLength={255}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* درآمد */}
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-semibold">
|
||||
{t("settings.edit.personalDetails.incomeRange")}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={toggleBtnClass(incomeRange === "under_5m", "h-10 text-xs")}
|
||||
onClick={() => setIncomeRange("under_5m")}
|
||||
>
|
||||
{t("settings.edit.personalDetails.incomeUnder5m")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={toggleBtnClass(incomeRange === "5m_10m", "h-10 text-xs")}
|
||||
onClick={() => setIncomeRange("5m_10m")}
|
||||
>
|
||||
{t("settings.edit.personalDetails.income5m10m")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={toggleBtnClass(incomeRange === "10m_20m", "h-10 text-xs")}
|
||||
onClick={() => setIncomeRange("10m_20m")}
|
||||
>
|
||||
{t("settings.edit.personalDetails.income10m20m")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={toggleBtnClass(incomeRange === "over_20m", "h-10 text-xs")}
|
||||
onClick={() => setIncomeRange("over_20m")}
|
||||
>
|
||||
{t("settings.edit.personalDetails.incomeOver20m")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* خصوصیات اخلاقی */}
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-semibold">
|
||||
{t("settings.edit.personalDetails.moralTraits")}
|
||||
</p>
|
||||
<textarea
|
||||
value={moralTraits}
|
||||
onChange={(e) => setMoralTraits(e.target.value)}
|
||||
placeholder={t("settings.edit.personalDetails.moralTraitsPlaceholder")}
|
||||
maxLength={1024}
|
||||
rows={4}
|
||||
className="w-full rounded-3xl border border-neutral-950 bg-white px-4 py-3 font-medium text-neutral-900 dark:border-neutral-400 dark:bg-neutral-950 dark:text-neutral-50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
className="mt-4 h-10 w-full max-w-[200px]"
|
||||
>
|
||||
{t("settings.edit.save")}
|
||||
</AuthNextButton>
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
export default PersonalDetailsPage;
|
||||
@@ -120,6 +120,7 @@ function Notifications() {
|
||||
end_project: workroomPath,
|
||||
"reject-project": workroomPath,
|
||||
invite: `/users/${item?.project_post_id}`,
|
||||
partner_match: `/offer/${item?.project_post_id}`,
|
||||
vitrine: `/settings/my-billboards/${item?.project_post_id}/b`,
|
||||
"vitrine-comment": `/settings/my-billboards/${item?.project_post_id}/b`,
|
||||
"ticket-message": `/settings/tickets/${encodeURIComponent(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import "@/lib/i18n";
|
||||
import { LanguageProvider } from "@/contexts/LanguageProvider";
|
||||
import type { AppLanguage } from "@/lib/i18n/registry";
|
||||
import { ThemeProvider } from "@/contexts/ThemeContext";
|
||||
import { ReactQueryProvider } from "@/providers/ReactQueryProvider";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
@@ -16,11 +16,12 @@ import NotificationBridge from "@/components/notifications/NotificationBridge";
|
||||
|
||||
interface ILayoutProps {
|
||||
children: React.ReactNode;
|
||||
initialLanguage: AppLanguage;
|
||||
}
|
||||
function Layout({ children }: ILayoutProps) {
|
||||
function Layout({ children, initialLanguage }: ILayoutProps) {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<LanguageProvider>
|
||||
<LanguageProvider initialLanguage={initialLanguage}>
|
||||
<ReactQueryProvider>
|
||||
<PwaHead />
|
||||
<PwaBootSplash />
|
||||
|
||||
@@ -19,8 +19,10 @@ function gridMaxWidth(count: number): number {
|
||||
return cols * 108;
|
||||
}
|
||||
|
||||
/** پایینِ تخصص اصلی همیشه ۳ ستونه است، صرفنظر از تعداد گزینهها */
|
||||
function primaryGridColsClass(count: number): string {
|
||||
/** زیرتخصصها و تخصص اصلی انتخابی همیشه حداکثر ۳ ستونهاند، صرفنظر از تعداد گزینهها —
|
||||
* قبلاً زیرتخصصها از gridColsClass استفاده میکردن که برای شمارشهای خاص (مثلاً دقیقاً ۵ گزینه)
|
||||
* ۵ ستونه میشد و با بقیه دستهها ناهماهنگ بود. */
|
||||
function fixedThreeColGridClass(count: number): string {
|
||||
if (count <= 1) return "grid-cols-1";
|
||||
if (count === 2) return "grid-cols-2";
|
||||
return "grid-cols-3";
|
||||
@@ -91,7 +93,7 @@ export default function ExpertisePicker({
|
||||
{t("auth.subExpertisePostHint")}
|
||||
</p>
|
||||
<div
|
||||
className={cn("grid w-full gap-2", gridColsClass(subCount))}
|
||||
className={cn("grid w-full gap-2", fixedThreeColGridClass(subCount))}
|
||||
style={{ maxWidth: gridMaxWidth(subCount) }}
|
||||
>
|
||||
{selectedExpertise.sub_expertise.map((item) => {
|
||||
@@ -120,7 +122,7 @@ export default function ExpertisePicker({
|
||||
<div
|
||||
className={cn(
|
||||
"grid w-full gap-2",
|
||||
primaryGridColsClass(subExpertise.length)
|
||||
fixedThreeColGridClass(subExpertise.length)
|
||||
)}
|
||||
style={{ maxWidth: gridMaxWidth(subCount) }}
|
||||
>
|
||||
|
||||
320
src/components/chats/FindPartnerFilterModal.tsx
Normal file
320
src/components/chats/FindPartnerFilterModal.tsx
Normal file
@@ -0,0 +1,320 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Modal from "../elements/Modal";
|
||||
import RoundedInput from "../elements/RoundedInput";
|
||||
import RoundedButton from "../elements/RoundedButton";
|
||||
import AppearanceColorSelect from "../models/AppearanceColorSelect";
|
||||
import CountryProvinceCitySelect, { GeoSelection } from "../ui/CountryProvinceCitySelect";
|
||||
import { EYE_COLOR_OPTIONS, HAIR_COLOR_OPTIONS } from "@/lib/appearanceOptions";
|
||||
import { toggleBtnClass } from "@/lib/ui/buttonStyles";
|
||||
|
||||
export type PartnerFilters = {
|
||||
gender: string;
|
||||
ageMin: string;
|
||||
ageMax: string;
|
||||
heightMin: string;
|
||||
heightMax: string;
|
||||
weightMin: string;
|
||||
weightMax: string;
|
||||
sizeMin: string;
|
||||
sizeMax: string;
|
||||
hairColor: string;
|
||||
eyeColor: string;
|
||||
maritalStatus: string;
|
||||
residenceType: string;
|
||||
vehicleType: string;
|
||||
personalStyle: string;
|
||||
job: string;
|
||||
incomeRange: string;
|
||||
geo: GeoSelection;
|
||||
};
|
||||
|
||||
export const EMPTY_PARTNER_FILTERS: PartnerFilters = {
|
||||
gender: "",
|
||||
ageMin: "",
|
||||
ageMax: "",
|
||||
heightMin: "",
|
||||
heightMax: "",
|
||||
weightMin: "",
|
||||
weightMax: "",
|
||||
sizeMin: "",
|
||||
sizeMax: "",
|
||||
hairColor: "",
|
||||
eyeColor: "",
|
||||
maritalStatus: "",
|
||||
residenceType: "",
|
||||
vehicleType: "",
|
||||
personalStyle: "",
|
||||
job: "",
|
||||
incomeRange: "",
|
||||
geo: { countryId: null, provinceId: null, cityId: null },
|
||||
};
|
||||
|
||||
function RangeField({
|
||||
label,
|
||||
min,
|
||||
max,
|
||||
onMinChange,
|
||||
onMaxChange,
|
||||
minPlaceholder,
|
||||
maxPlaceholder,
|
||||
}: {
|
||||
label: string;
|
||||
min: string;
|
||||
max: string;
|
||||
onMinChange: (v: string) => void;
|
||||
onMaxChange: (v: string) => void;
|
||||
minPlaceholder: string;
|
||||
maxPlaceholder: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="w-full max-w-sm">
|
||||
<p className="mb-1.5 text-center text-xs text-neutral-500">{label}</p>
|
||||
<div className="flex gap-2">
|
||||
<RoundedInput
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
placeholder={minPlaceholder}
|
||||
value={min}
|
||||
onChange={(e) => onMinChange(e.target.value)}
|
||||
className="text-center text-sm"
|
||||
/>
|
||||
<RoundedInput
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
placeholder={maxPlaceholder}
|
||||
value={max}
|
||||
onChange={(e) => onMaxChange(e.target.value)}
|
||||
className="text-center text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleRow({
|
||||
label,
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
options: { value: string; label: string }[];
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="w-full max-w-sm">
|
||||
<p className="mb-1.5 text-center text-xs text-neutral-500">{label}</p>
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
className={toggleBtnClass(value === opt.value, "h-9 text-xs")}
|
||||
onClick={() => onChange(value === opt.value ? "" : opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
filters: PartnerFilters;
|
||||
onChange: (filters: PartnerFilters) => void;
|
||||
onApply: () => void;
|
||||
onClear: () => void;
|
||||
};
|
||||
|
||||
export default function FindPartnerFilterModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
filters,
|
||||
onChange,
|
||||
onApply,
|
||||
onClear,
|
||||
}: Props) {
|
||||
const { t } = useTranslation("common");
|
||||
|
||||
const set = <K extends keyof PartnerFilters>(key: K, value: PartnerFilters[K]) =>
|
||||
onChange({ ...filters, [key]: value });
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
height="fit"
|
||||
elevated
|
||||
panelClassName="!p-0 max-h-[90dvh] w-full overflow-y-auto overscroll-y-contain touch-pan-y [-webkit-overflow-scrolling:touch]"
|
||||
>
|
||||
<div className="px-4 pt-5 pb-[calc(6rem+env(safe-area-inset-bottom,0px))]">
|
||||
<div className="mx-auto flex w-full max-w-sm flex-col items-center gap-4">
|
||||
<span className="mb-1 block text-center font-semibold">
|
||||
{t("findPartner.modalTitle")}
|
||||
</span>
|
||||
|
||||
<ToggleRow
|
||||
label={t("filters.gender")}
|
||||
value={filters.gender}
|
||||
onChange={(v) => set("gender", v)}
|
||||
options={[
|
||||
{ value: "male", label: t("auth.male") },
|
||||
{ value: "female", label: t("auth.female") },
|
||||
]}
|
||||
/>
|
||||
|
||||
<RangeField
|
||||
label={t("filters.age")}
|
||||
min={filters.ageMin}
|
||||
max={filters.ageMax}
|
||||
onMinChange={(v) => set("ageMin", v)}
|
||||
onMaxChange={(v) => set("ageMax", v)}
|
||||
minPlaceholder={t("filters.min")}
|
||||
maxPlaceholder={t("filters.max")}
|
||||
/>
|
||||
|
||||
<RangeField
|
||||
label={t("filters.heightCm")}
|
||||
min={filters.heightMin}
|
||||
max={filters.heightMax}
|
||||
onMinChange={(v) => set("heightMin", v)}
|
||||
onMaxChange={(v) => set("heightMax", v)}
|
||||
minPlaceholder={t("filters.min")}
|
||||
maxPlaceholder={t("filters.max")}
|
||||
/>
|
||||
|
||||
<RangeField
|
||||
label={t("filters.weightKg")}
|
||||
min={filters.weightMin}
|
||||
max={filters.weightMax}
|
||||
onMinChange={(v) => set("weightMin", v)}
|
||||
onMaxChange={(v) => set("weightMax", v)}
|
||||
minPlaceholder={t("filters.min")}
|
||||
maxPlaceholder={t("filters.max")}
|
||||
/>
|
||||
|
||||
<RangeField
|
||||
label={t("filters.size")}
|
||||
min={filters.sizeMin}
|
||||
max={filters.sizeMax}
|
||||
onMinChange={(v) => set("sizeMin", v)}
|
||||
onMaxChange={(v) => set("sizeMax", v)}
|
||||
minPlaceholder={t("filters.min")}
|
||||
maxPlaceholder={t("filters.max")}
|
||||
/>
|
||||
|
||||
<AppearanceColorSelect
|
||||
label={t("filters.hairColor")}
|
||||
placeholder={t("filters.selectHairColor")}
|
||||
value={filters.hairColor}
|
||||
options={HAIR_COLOR_OPTIONS}
|
||||
onChange={(v) => set("hairColor", v)}
|
||||
/>
|
||||
|
||||
<AppearanceColorSelect
|
||||
label={t("filters.eyeColor")}
|
||||
placeholder={t("filters.selectEyeColor")}
|
||||
value={filters.eyeColor}
|
||||
options={EYE_COLOR_OPTIONS}
|
||||
onChange={(v) => set("eyeColor", v)}
|
||||
/>
|
||||
|
||||
<ToggleRow
|
||||
label={t("settings.edit.personalDetails.maritalStatus")}
|
||||
value={filters.maritalStatus}
|
||||
onChange={(v) => set("maritalStatus", v)}
|
||||
options={[
|
||||
{ value: "single", label: t("settings.edit.personalDetails.maritalStatusSingle") },
|
||||
{ value: "divorced", label: t("settings.edit.personalDetails.maritalStatusDivorced") },
|
||||
]}
|
||||
/>
|
||||
|
||||
<ToggleRow
|
||||
label={t("settings.edit.personalDetails.residenceType")}
|
||||
value={filters.residenceType}
|
||||
onChange={(v) => set("residenceType", v)}
|
||||
options={[
|
||||
{ value: "owner", label: t("settings.edit.personalDetails.residenceTypeOwner") },
|
||||
{ value: "tenant", label: t("settings.edit.personalDetails.residenceTypeTenant") },
|
||||
]}
|
||||
/>
|
||||
|
||||
<ToggleRow
|
||||
label={t("settings.edit.personalDetails.vehicleType")}
|
||||
value={filters.vehicleType}
|
||||
onChange={(v) => set("vehicleType", v)}
|
||||
options={[
|
||||
{ value: "none", label: t("settings.edit.personalDetails.vehicleTypeNone") },
|
||||
{ value: "car", label: t("settings.edit.personalDetails.vehicleTypeCar") },
|
||||
{ value: "motorcycle", label: t("settings.edit.personalDetails.vehicleTypeMotorcycle") },
|
||||
]}
|
||||
/>
|
||||
|
||||
<ToggleRow
|
||||
label={t("settings.edit.personalDetails.personalStyle")}
|
||||
value={filters.personalStyle}
|
||||
onChange={(v) => set("personalStyle", v)}
|
||||
options={[
|
||||
{ value: "formal", label: t("settings.edit.personalDetails.personalStyleFormal") },
|
||||
{ value: "sporty", label: t("settings.edit.personalDetails.personalStyleSporty") },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="w-full max-w-sm">
|
||||
<p className="mb-1.5 text-center text-xs text-neutral-500">
|
||||
{t("settings.edit.personalDetails.job")}
|
||||
</p>
|
||||
<RoundedInput
|
||||
value={filters.job}
|
||||
onChange={(e) => set("job", e.target.value)}
|
||||
placeholder={t("settings.edit.personalDetails.jobPlaceholder")}
|
||||
maxLength={255}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ToggleRow
|
||||
label={t("settings.edit.personalDetails.incomeRange")}
|
||||
value={filters.incomeRange}
|
||||
onChange={(v) => set("incomeRange", v)}
|
||||
options={[
|
||||
{ value: "under_5m", label: t("settings.edit.personalDetails.incomeUnder5m") },
|
||||
{ value: "5m_10m", label: t("settings.edit.personalDetails.income5m10m") },
|
||||
{ value: "10m_20m", label: t("settings.edit.personalDetails.income10m20m") },
|
||||
{ value: "over_20m", label: t("settings.edit.personalDetails.incomeOver20m") },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="w-full max-w-sm border-t border-neutral-200 pt-4 dark:border-neutral-700">
|
||||
<CountryProvinceCitySelect
|
||||
value={filters.geo}
|
||||
onChange={(geo) => set("geo", geo)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex w-full flex-col items-center gap-3 border-t border-neutral-200 pt-5 dark:border-neutral-700">
|
||||
<RoundedButton
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
onApply();
|
||||
onClose();
|
||||
}}
|
||||
className="h-10 w-full max-w-[200px] text-sm"
|
||||
>
|
||||
{t("filters.apply")}
|
||||
</RoundedButton>
|
||||
<RoundedButton type="button" onClick={onClear} className="h-10 w-full max-w-[200px] text-sm">
|
||||
{t("filters.clear")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -50,6 +50,9 @@ export function staticIconUrl(path: string): string {
|
||||
export function buildStorageUrl(path: string | null | undefined): string {
|
||||
if (!path) return "";
|
||||
if (path.startsWith("http://") || path.startsWith("https://")) return path;
|
||||
// پیشنمایش محلی هنوز آپلودنشده (مثلاً عکس تازهانتخابشدهی خدمت در AddServiceModal)
|
||||
// یه data URI هست، نه مسیر سرور — نباید نرمالایز/پیشوندگذاری بشه
|
||||
if (path.startsWith("data:")) return path;
|
||||
|
||||
let normalized = String(path).replace(/\\/g, "/");
|
||||
normalized = normalized.replace("/root/modstagram-back/storage", "");
|
||||
|
||||
286
src/components/projects/SpecialistCarousel.tsx
Normal file
286
src/components/projects/SpecialistCarousel.tsx
Normal file
@@ -0,0 +1,286 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import Cookies from "js-cookie";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselNext,
|
||||
CarouselPrevious,
|
||||
type CarouselApi,
|
||||
} from "@/components/ui/carousel";
|
||||
import { buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { SuggestedFollowUser } from "@/components/posts/FollowingSuggestionsCarousel";
|
||||
|
||||
export type SpecialistCarouselItem = SuggestedFollowUser;
|
||||
|
||||
export interface SpecialistCarouselProps {
|
||||
/** لیست کاربران/متخصصین برای نمایش */
|
||||
items: SpecialistCarouselItem[];
|
||||
/** عنوان بخش — اگه ندی، عنوان نمایش داده نمیشه */
|
||||
title?: string;
|
||||
/** توضیح کوتاه زیر عنوان */
|
||||
description?: string;
|
||||
/** برچسب دکمهی عمل اصلی (پیشفرض «دنبال کردن»؛ میتونه «دعوت به همکاری» هم باشه) */
|
||||
actionLabel?: string;
|
||||
actionDoneLabel?: string;
|
||||
/**
|
||||
* اگه بدی، بهجای فراخوانی خودکار /posts/follow، همین تابع صدا زده میشه — برای
|
||||
* استفادهی دوبارهی کامپوننت با یک عمل دیگه (مثلاً «دعوت به همکاری»)
|
||||
*/
|
||||
onAction?: (userId: string) => Promise<void> | void;
|
||||
/** بعد از اجرای موفق عمل (فالو/دعوت) صدا زده میشه */
|
||||
onActionDone?: (userId: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function getDisplayName(user: SpecialistCarouselItem, fallback: string) {
|
||||
return (
|
||||
[user.first_name, user.last_name].filter(Boolean).join(" ") ||
|
||||
user.user_name ||
|
||||
fallback
|
||||
);
|
||||
}
|
||||
|
||||
function SpecialistCard({
|
||||
user,
|
||||
isActive,
|
||||
busy,
|
||||
done,
|
||||
actionLabel,
|
||||
actionDoneLabel,
|
||||
onAction,
|
||||
userFallback,
|
||||
}: {
|
||||
user: SpecialistCarouselItem;
|
||||
isActive: boolean;
|
||||
busy: boolean;
|
||||
done: boolean;
|
||||
actionLabel: string;
|
||||
actionDoneLabel: string;
|
||||
onAction: () => void;
|
||||
userFallback: string;
|
||||
}) {
|
||||
const displayName = getDisplayName(user, userFallback);
|
||||
const previewFile = user.previewPost?.files?.[0]?.path;
|
||||
const previewUrl = previewFile ? buildStorageUrl(previewFile) : null;
|
||||
const isVideo = user.previewPost?.type === "video";
|
||||
const profileUrl = user.profile_image ? buildStorageUrl(user.profile_image) : null;
|
||||
const profileHref = user.user_name ? `/users/${user.user_name}` : "#";
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={profileHref}
|
||||
aria-label={displayName}
|
||||
className={cn(
|
||||
"group relative block aspect-[3/4.4] w-full overflow-hidden rounded-3xl bg-neutral-900 shadow-lg outline-none transition-all duration-300 focus-visible:ring-2 focus-visible:ring-[#fe2c55]",
|
||||
isActive ? "scale-100 opacity-100" : "scale-[0.94] opacity-70"
|
||||
)}
|
||||
>
|
||||
{previewUrl ? (
|
||||
isVideo ? (
|
||||
<video
|
||||
src={previewUrl}
|
||||
className="h-full w-full object-cover"
|
||||
muted
|
||||
playsInline
|
||||
loop
|
||||
autoPlay={isActive}
|
||||
preload={isActive ? "auto" : "none"}
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
src={previewUrl}
|
||||
alt=""
|
||||
fill
|
||||
loading="lazy"
|
||||
className="object-cover"
|
||||
sizes="(min-width: 1024px) 220px, (min-width: 640px) 30vw, 78vw"
|
||||
unoptimized
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="h-full w-full bg-neutral-800" />
|
||||
)}
|
||||
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/25 to-black/10" />
|
||||
|
||||
<div className="absolute inset-x-0 bottom-0 flex flex-col items-center px-4 pb-4 pt-8">
|
||||
<div className="relative mb-2 h-14 w-14 overflow-hidden rounded-full ring-2 ring-white/90">
|
||||
{profileUrl ? (
|
||||
<Image
|
||||
src={profileUrl}
|
||||
alt=""
|
||||
fill
|
||||
loading="lazy"
|
||||
className="object-cover"
|
||||
sizes="56px"
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center bg-neutral-700 text-white">
|
||||
{displayName.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="line-clamp-1 text-center text-sm font-bold text-white">
|
||||
{displayName}
|
||||
</p>
|
||||
{user.user_name ? (
|
||||
<p className="text-xs text-white/70">@{user.user_name}</p>
|
||||
) : null}
|
||||
{user.expertise ? (
|
||||
<p className="mt-1 line-clamp-1 text-center text-xs text-white/60">
|
||||
{user.expertise}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || done}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onAction();
|
||||
}}
|
||||
className="mt-3 w-full max-w-[180px] rounded-full bg-[#fe2c55] py-2 text-sm font-bold text-white transition active:scale-95 disabled:opacity-60"
|
||||
>
|
||||
{done ? actionDoneLabel : actionLabel}
|
||||
</button>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// کاروسل افقی «متخصصین پیشنهادی» — عین کاروسل محتواسازان ترند تیکتاک: کارت وسط/فعال کمی
|
||||
// بزرگتر و کاملرنگ، کارتهای کناری کمی کوچیکتر و کمرنگتر؛ با سوایپ لمسی، درگ ماوس، یا
|
||||
// فلشکیبورد جابهجا میشه، با snap روان (نه پرش ناگهانی) — بر پایهی Embla (از قبل توی این
|
||||
// پروژه بهعنوان components/ui/carousel.tsx آمادهست)
|
||||
export default function SpecialistCarousel({
|
||||
items,
|
||||
title,
|
||||
description,
|
||||
actionLabel,
|
||||
actionDoneLabel,
|
||||
onAction,
|
||||
onActionDone,
|
||||
className,
|
||||
}: SpecialistCarouselProps) {
|
||||
const { t } = useTranslation("common");
|
||||
const { request } = useAxios();
|
||||
const [api, setApi] = useState<CarouselApi>();
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [doneIds, setDoneIds] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
if (!api) return;
|
||||
const onSelect = () => setActiveIndex(api.selectedScrollSnap());
|
||||
onSelect();
|
||||
api.on("select", onSelect);
|
||||
api.on("reInit", onSelect);
|
||||
return () => {
|
||||
api.off("select", onSelect);
|
||||
api.off("reInit", onSelect);
|
||||
};
|
||||
}, [api]);
|
||||
|
||||
const handleAction = useCallback(
|
||||
async (userId: string) => {
|
||||
if (busyId || doneIds.has(userId)) return;
|
||||
setBusyId(userId);
|
||||
try {
|
||||
if (onAction) {
|
||||
await onAction(userId);
|
||||
} else {
|
||||
const token = Cookies.get("token");
|
||||
if (!token) return;
|
||||
await request("POST", "/posts/follow", { userId }, { noToast: true });
|
||||
}
|
||||
setDoneIds((prev) => new Set(prev).add(userId));
|
||||
onActionDone?.(userId);
|
||||
} catch {
|
||||
/* ignore */
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
},
|
||||
[busyId, doneIds, onAction, onActionDone, request]
|
||||
);
|
||||
|
||||
if (!items.length) return null;
|
||||
|
||||
const showDots = items.length > 1 && items.length <= 10;
|
||||
|
||||
return (
|
||||
<section className={cn("w-full", className)} aria-label={title || t("projects.specialistCarousel.title", "متخصصین پیشنهادی")}>
|
||||
{title || description ? (
|
||||
<div className="mb-4 px-1 text-center">
|
||||
{title ? <h2 className="text-lg font-bold">{title}</h2> : null}
|
||||
{description ? (
|
||||
<p className="mt-1 text-sm text-foreground/60">{description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Carousel
|
||||
opts={{ direction: "rtl", align: "start", containScroll: "trimSnaps", dragFree: false }}
|
||||
setApi={setApi}
|
||||
className="relative"
|
||||
>
|
||||
<CarouselContent className="-ml-3 px-1">
|
||||
{items.map((user, idx) => (
|
||||
<CarouselItem
|
||||
key={user._id}
|
||||
className="basis-[72%] pl-3 sm:basis-[42%] md:basis-[30%] lg:basis-[22%]"
|
||||
>
|
||||
<SpecialistCard
|
||||
user={user}
|
||||
isActive={idx === activeIndex}
|
||||
busy={busyId === user._id}
|
||||
done={Boolean(user.is_following) || doneIds.has(user._id)}
|
||||
actionLabel={actionLabel ?? t("projects.specialistCarousel.action", "دنبال کردن")}
|
||||
actionDoneLabel={
|
||||
actionDoneLabel ?? t("projects.specialistCarousel.actionDone", "دنبالشده")
|
||||
}
|
||||
onAction={() => handleAction(user._id)}
|
||||
userFallback={t("common.user", "کاربر")}
|
||||
/>
|
||||
</CarouselItem>
|
||||
))}
|
||||
</CarouselContent>
|
||||
|
||||
{items.length > 1 ? (
|
||||
<>
|
||||
<CarouselPrevious className="hidden md:flex" />
|
||||
<CarouselNext className="hidden md:flex" />
|
||||
</>
|
||||
) : null}
|
||||
</Carousel>
|
||||
|
||||
{showDots ? (
|
||||
<div className="mt-3 flex items-center justify-center gap-1.5">
|
||||
{items.map((user, idx) => (
|
||||
<button
|
||||
key={user._id}
|
||||
type="button"
|
||||
aria-label={`${idx + 1}`}
|
||||
onClick={() => api?.scrollTo(idx)}
|
||||
className={cn(
|
||||
"h-1.5 rounded-full transition-all",
|
||||
idx === activeIndex ? "w-5 bg-[#fe2c55]" : "w-1.5 bg-foreground/20"
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -98,6 +98,11 @@ export const editUserNavLinks = [
|
||||
href: "/colors",
|
||||
icon: "shop-add.svg",
|
||||
},
|
||||
{
|
||||
labelKey: "settings.edit.nav.personalDetails",
|
||||
href: "/personal-details",
|
||||
icon: "card.svg",
|
||||
},
|
||||
{
|
||||
labelKey: "settings.edit.nav.services",
|
||||
href: "/services",
|
||||
|
||||
@@ -43,7 +43,7 @@ const COMMENT_TYPES = new Set([
|
||||
"vitrine-comment",
|
||||
]);
|
||||
|
||||
const FOLLOW_TYPES = new Set(["invite"]);
|
||||
const FOLLOW_TYPES = new Set(["invite", "partner_match"]);
|
||||
|
||||
const POST_TYPES = new Set([
|
||||
"post_tag",
|
||||
@@ -120,6 +120,9 @@ export function getNotificationActionMeta(type: string): {
|
||||
if (type === "invite") {
|
||||
return { icon: "user-add", tint: "blue", labelKey: "constants.notificationActions.invite" };
|
||||
}
|
||||
if (type === "partner_match") {
|
||||
return { icon: "heart-search", tint: "pink", labelKey: "constants.notificationActions.partnerMatch" };
|
||||
}
|
||||
if (type === "post_tag") {
|
||||
return { icon: "tag-user", tint: "blue", labelKey: "constants.notificationActions.tag" };
|
||||
}
|
||||
|
||||
@@ -8,18 +8,15 @@ import {
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import "@/lib/i18n";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
import { createI18nInstance } from "@/lib/i18n/createI18nInstance";
|
||||
import {
|
||||
DEFAULT_LANGUAGE,
|
||||
getDirection,
|
||||
type AppLanguage,
|
||||
} from "@/lib/i18n/registry";
|
||||
import {
|
||||
persistLanguagePreference,
|
||||
readStoredLanguagePreference,
|
||||
resolveBrowserLanguage,
|
||||
resolveServerRenderedLanguage,
|
||||
} from "@/lib/i18n/clientLanguage";
|
||||
import { syncDocumentLanguage } from "@/lib/i18n/syncDocumentLanguage";
|
||||
|
||||
@@ -33,43 +30,55 @@ type LanguageContextValue = {
|
||||
|
||||
const LanguageContext = createContext<LanguageContextValue | null>(null);
|
||||
|
||||
export function LanguageProvider({ children }: { children: React.ReactNode }) {
|
||||
const { i18n } = useTranslation();
|
||||
// Always start at the deterministic default on both server and the first client
|
||||
// render pass — reading cookies/localStorage/navigator here would differ between
|
||||
// SSR (no window) and hydration (window present), causing a hydration mismatch.
|
||||
// The real preference is resolved client-side in the effect below instead.
|
||||
const [language, setLanguageState] = useState<AppLanguage>(DEFAULT_LANGUAGE);
|
||||
const [ready, setReady] = useState(false);
|
||||
export function LanguageProvider({
|
||||
children,
|
||||
initialLanguage,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
initialLanguage: AppLanguage;
|
||||
}) {
|
||||
// One i18next instance per render of this component — since Next.js renders
|
||||
// "use client" components fresh on the server for each request (not from a
|
||||
// shared module-level singleton), this naturally gives every request its own
|
||||
// isolated instance instead of sharing one across the whole server process.
|
||||
// That's what actually fixes the hydration mismatch: the old shared singleton
|
||||
// was stuck on the server's default language for the life of the process,
|
||||
// so every visitor's page content server-rendered in that language and then
|
||||
// flipped to the real one after hydration.
|
||||
const [instance] = useState(() => createI18nInstance(initialLanguage));
|
||||
|
||||
// initialLanguage is resolved server-side (cookie → Accept-Language → default)
|
||||
// and passed down as a prop, so server and first client render agree from the
|
||||
// start — no more guessing a different value on the client than what the
|
||||
// server already committed to in the HTML.
|
||||
const [language, setLanguageState] = useState<AppLanguage>(initialLanguage);
|
||||
const [ready, setReady] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// A stored preference (from an earlier explicit choice) can still disagree
|
||||
// with initialLanguage — e.g. localStorage survived a cleared cookie. Only
|
||||
// switch in that case; otherwise there's nothing to reconcile since the
|
||||
// server already rendered the right language.
|
||||
const stored = readStoredLanguagePreference();
|
||||
// Prefer the language the server already rendered `<html lang>` with over an
|
||||
// independent client-side guess from navigator.language — those two signals
|
||||
// frequently disagree (see resolveServerRenderedLanguage), which was causing
|
||||
// a full-page hydration mismatch on first visits.
|
||||
const initial =
|
||||
stored ?? resolveServerRenderedLanguage() ?? resolveBrowserLanguage();
|
||||
|
||||
if (!stored) {
|
||||
persistLanguagePreference(initial);
|
||||
if (stored && stored !== initialLanguage) {
|
||||
void instance.changeLanguage(stored).then(() => {
|
||||
setLanguageState(stored);
|
||||
syncDocumentLanguage(stored);
|
||||
});
|
||||
} else if (!stored) {
|
||||
persistLanguagePreference(initialLanguage);
|
||||
}
|
||||
|
||||
void i18n.changeLanguage(initial).finally(() => {
|
||||
setLanguageState(initial);
|
||||
syncDocumentLanguage(initial);
|
||||
setReady(true);
|
||||
});
|
||||
}, [i18n]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const setLanguage = useCallback(
|
||||
async (lang: AppLanguage) => {
|
||||
await i18n.changeLanguage(lang);
|
||||
await instance.changeLanguage(lang);
|
||||
persistLanguagePreference(lang);
|
||||
setLanguageState(lang);
|
||||
syncDocumentLanguage(lang);
|
||||
},
|
||||
[i18n]
|
||||
[instance]
|
||||
);
|
||||
|
||||
const value = useMemo<LanguageContextValue>(
|
||||
@@ -84,7 +93,9 @@ export function LanguageProvider({ children }: { children: React.ReactNode }) {
|
||||
);
|
||||
|
||||
return (
|
||||
<LanguageContext.Provider value={value}>{children}</LanguageContext.Provider>
|
||||
<I18nextProvider i18n={instance}>
|
||||
<LanguageContext.Provider value={value}>{children}</LanguageContext.Provider>
|
||||
</I18nextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
31
src/lib/i18n/createI18nInstance.ts
Normal file
31
src/lib/i18n/createI18nInstance.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import i18next, { type i18n as I18nInstance } from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import { buildI18nResources, LOCALE_NAMESPACES } from "./resources";
|
||||
import { DEFAULT_LANGUAGE, ENABLED_LANGUAGES, type AppLanguage } from "./registry";
|
||||
|
||||
/**
|
||||
* Creates a fresh, synchronously-initialized i18next instance seeded with the
|
||||
* given language. Callers create one of these per render (see LanguageProvider),
|
||||
* which — unlike the shared singleton in `./index` — gives every request/session
|
||||
* its own isolated instance. This is what fixes the SSR hydration mismatch: the
|
||||
* old shared singleton's `lng` was set once per server process (always the
|
||||
* default language on the server, since it never re-runs per request), so every
|
||||
* visitor's page content server-rendered in that default language and then
|
||||
* flipped to the real language after hydration. All translations are already
|
||||
* bundled in memory (no network fetch), so init is synchronous.
|
||||
*/
|
||||
export function createI18nInstance(lang: AppLanguage): I18nInstance {
|
||||
const instance = i18next.createInstance();
|
||||
instance.use(initReactI18next).init({
|
||||
lng: lang,
|
||||
fallbackLng: DEFAULT_LANGUAGE,
|
||||
supportedLngs: [...ENABLED_LANGUAGES],
|
||||
defaultNS: "common",
|
||||
ns: [...LOCALE_NAMESPACES],
|
||||
resources: buildI18nResources(),
|
||||
interpolation: { escapeValue: false },
|
||||
react: { useSuspense: false },
|
||||
initImmediate: false,
|
||||
});
|
||||
return instance;
|
||||
}
|
||||
@@ -376,6 +376,7 @@
|
||||
"authentication": "Verification",
|
||||
"expertise": "Expertise",
|
||||
"colors": "Appearance",
|
||||
"personalDetails": "Personal Details",
|
||||
"services": "Services",
|
||||
"sizes": "Size",
|
||||
"license": "License",
|
||||
@@ -620,6 +621,36 @@
|
||||
"eyeRequired": "Eye color is required",
|
||||
"hairRequired": "Hair color is required"
|
||||
},
|
||||
"personalDetails": {
|
||||
"title": "Personal Details",
|
||||
"hint": "This information is only shown to others in \"Find a Partner\" search results.",
|
||||
"maritalStatus": "Marital status",
|
||||
"maritalStatusSingle": "Single",
|
||||
"maritalStatusDivorced": "Divorced",
|
||||
"residenceType": "Residence type",
|
||||
"residenceTypeOwner": "Owner",
|
||||
"residenceTypeTenant": "Tenant",
|
||||
"residenceArea": "Residence area",
|
||||
"residenceAreaPlaceholder": "Neighborhood name",
|
||||
"vehicleType": "Vehicle",
|
||||
"vehicleTypeNone": "None",
|
||||
"vehicleTypeCar": "Car",
|
||||
"vehicleTypeMotorcycle": "Motorcycle",
|
||||
"vehicleDetailsPlaceholder": "Make and model",
|
||||
"personalStyle": "Style",
|
||||
"personalStyleFormal": "Formal",
|
||||
"personalStyleSporty": "Sporty",
|
||||
"job": "Job",
|
||||
"jobPlaceholder": "Describe your job",
|
||||
"incomeRange": "Income",
|
||||
"incomeUnder5m": "Under 5M Toman",
|
||||
"income5m10m": "5M–10M Toman",
|
||||
"income10m20m": "10M–20M Toman",
|
||||
"incomeOver20m": "Over 20M Toman",
|
||||
"moralTraits": "Personal qualities",
|
||||
"moralTraitsPlaceholder": "Describe your personal qualities",
|
||||
"saveSuccess": "Personal details saved successfully"
|
||||
},
|
||||
"avatar": {
|
||||
"required": "Please select a profile photo",
|
||||
"editImage": "Edit photo"
|
||||
@@ -1349,6 +1380,7 @@
|
||||
"comment": "Comment",
|
||||
"rating": "Rating",
|
||||
"invite": "Invite",
|
||||
"partnerMatch": "Suggested partner",
|
||||
"tag": "Tag",
|
||||
"accept": "Accepted",
|
||||
"reject": "Declined",
|
||||
@@ -1575,6 +1607,26 @@
|
||||
"distanceKm": "About {{count}} km away",
|
||||
"you": "You"
|
||||
},
|
||||
"findPartner": {
|
||||
"title": "Find a partner",
|
||||
"desc": "Search users by personal details",
|
||||
"hint": "This section helps you find someone matching the details you're looking for.",
|
||||
"filterButton": "Filters",
|
||||
"modalTitle": "Find-a-partner filters",
|
||||
"resultsFound": "{{count}} people found",
|
||||
"loading": "Searching…",
|
||||
"loadError": "Could not load results.",
|
||||
"empty": "No one matches these details yet.",
|
||||
"emptyHint": "You can save this search and we'll notify you once a match is found.",
|
||||
"saveSearch": "Save search & notify me",
|
||||
"updateSearch": "Update saved search",
|
||||
"cancelSavedSearch": "Stop notifying me",
|
||||
"savedSearchActive": "We're notifying you for this saved search",
|
||||
"searchSaved": "Your search has been saved",
|
||||
"searchCancelled": "Notifications stopped",
|
||||
"viewProfile": "View profile",
|
||||
"ageYears": "{{count}} years old"
|
||||
},
|
||||
"chats": {
|
||||
"title": "Messages",
|
||||
"today": "Today",
|
||||
@@ -1606,6 +1658,10 @@
|
||||
"distanceKm": "About {{count}} km away",
|
||||
"you": "You"
|
||||
},
|
||||
"findPartner": {
|
||||
"title": "Find a partner",
|
||||
"desc": "Search users by personal details"
|
||||
},
|
||||
"empty": "No conversations yet.",
|
||||
"shopChatSubtitle": "Shop chat",
|
||||
"online": "Online",
|
||||
@@ -1828,6 +1884,7 @@
|
||||
"eyeColor": "Eye color",
|
||||
"selectHairColor": "Select hair color",
|
||||
"selectEyeColor": "Select eye color",
|
||||
"country": "Country",
|
||||
"province": "Province",
|
||||
"city": "City",
|
||||
"userLevel": "User level",
|
||||
|
||||
@@ -376,6 +376,7 @@
|
||||
"authentication": "احراز هویت",
|
||||
"expertise": "تخصص",
|
||||
"colors": "مشخصات ظاهری",
|
||||
"personalDetails": "مشخصات فردی",
|
||||
"services": "خدمات",
|
||||
"sizes": "سایز",
|
||||
"license": "مجوز",
|
||||
@@ -620,6 +621,36 @@
|
||||
"eyeRequired": "رنگ چشم الزامی است",
|
||||
"hairRequired": "رنگ مو الزامی است"
|
||||
},
|
||||
"personalDetails": {
|
||||
"title": "مشخصات فردی",
|
||||
"hint": "این اطلاعات فقط در نتایج «یافتن همکار» به دیگران نمایش داده میشود.",
|
||||
"maritalStatus": "وضعیت تاهل",
|
||||
"maritalStatusSingle": "مجرد",
|
||||
"maritalStatusDivorced": "مطلقه",
|
||||
"residenceType": "نوع سکونت",
|
||||
"residenceTypeOwner": "مالک",
|
||||
"residenceTypeTenant": "مستاجر",
|
||||
"residenceArea": "محل سکونت",
|
||||
"residenceAreaPlaceholder": "نام محله",
|
||||
"vehicleType": "وسیله نقلیه",
|
||||
"vehicleTypeNone": "ندارم",
|
||||
"vehicleTypeCar": "اتومبیل",
|
||||
"vehicleTypeMotorcycle": "موتور سیکلت",
|
||||
"vehicleDetailsPlaceholder": "نوع و مدل",
|
||||
"personalStyle": "استایل",
|
||||
"personalStyleFormal": "رسمی",
|
||||
"personalStyleSporty": "اسپرت",
|
||||
"job": "شغل",
|
||||
"jobPlaceholder": "شغل خود را بنویسید",
|
||||
"incomeRange": "درآمد",
|
||||
"incomeUnder5m": "کمتر از ۵ میلیون تومان",
|
||||
"income5m10m": "۵ تا ۱۰ میلیون تومان",
|
||||
"income10m20m": "۱۰ تا ۲۰ میلیون تومان",
|
||||
"incomeOver20m": "بیشتر از ۲۰ میلیون تومان",
|
||||
"moralTraits": "خصوصیات اخلاقی",
|
||||
"moralTraitsPlaceholder": "خصوصیات اخلاقی خود را بنویسید",
|
||||
"saveSuccess": "مشخصات فردی با موفقیت ذخیره شد"
|
||||
},
|
||||
"avatar": {
|
||||
"required": "لطفا تصویر پروفایل را انتخاب کنید",
|
||||
"editImage": "ویرایش تصویر"
|
||||
@@ -1349,6 +1380,7 @@
|
||||
"comment": "کامنت",
|
||||
"rating": "امتیاز",
|
||||
"invite": "دعوت",
|
||||
"partnerMatch": "همکار پیشنهادی",
|
||||
"tag": "تگ",
|
||||
"accept": "تأیید",
|
||||
"reject": "رد",
|
||||
@@ -1575,6 +1607,26 @@
|
||||
"distanceKm": "حدود {{count}} کیلومتر فاصله",
|
||||
"you": "شما"
|
||||
},
|
||||
"findPartner": {
|
||||
"title": "یافتن همکار",
|
||||
"desc": "جستجوی کاربران بر اساس مشخصات فردی",
|
||||
"hint": "این بخش برای یافتن فردی با مشخصات مورد نظر شماست.",
|
||||
"filterButton": "فیلترها",
|
||||
"modalTitle": "فیلتر یافتن همکار",
|
||||
"resultsFound": "{{count}} نفر پیدا شد",
|
||||
"loading": "در حال جستجو…",
|
||||
"loadError": "بارگذاری نتایج انجام نشد.",
|
||||
"empty": "کسی با این مشخصات پیدا نشد.",
|
||||
"emptyHint": "میتوانید این جستجو را ذخیره کنید تا بهمحض یافتن فردی مطابق، به شما اطلاع بدهیم.",
|
||||
"saveSearch": "ذخیره جستجو و اطلاعرسانی",
|
||||
"updateSearch": "بهروزرسانی جستجوی ذخیرهشده",
|
||||
"cancelSavedSearch": "لغو اطلاعرسانی",
|
||||
"savedSearchActive": "برای این جستجو در حال اطلاعرسانی به شما هستیم",
|
||||
"searchSaved": "جستجوی شما ذخیره شد",
|
||||
"searchCancelled": "اطلاعرسانی لغو شد",
|
||||
"viewProfile": "مشاهده پروفایل",
|
||||
"ageYears": "{{count}} سال"
|
||||
},
|
||||
"chats": {
|
||||
"title": "پیامها",
|
||||
"today": "امروز",
|
||||
@@ -1606,6 +1658,10 @@
|
||||
"distanceKm": "حدود {{count}} کیلومتر فاصله",
|
||||
"you": "شما"
|
||||
},
|
||||
"findPartner": {
|
||||
"title": "یافتن همکار",
|
||||
"desc": "جستجوی کاربران بر اساس مشخصات فردی"
|
||||
},
|
||||
"empty": "هنوز مکالمهای ندارید.",
|
||||
"shopChatSubtitle": "چت فروشگاه",
|
||||
"online": "آنلاین",
|
||||
@@ -1827,6 +1883,7 @@
|
||||
"eyeColor": "رنگ چشم",
|
||||
"selectHairColor": "انتخاب رنگ مو",
|
||||
"selectEyeColor": "انتخاب رنگ چشم",
|
||||
"country": "کشور",
|
||||
"province": "استان",
|
||||
"city": "شهر",
|
||||
"userLevel": "سطح کاربر",
|
||||
@@ -1887,6 +1944,12 @@
|
||||
"resultsHint": "کاربران نزدیک به فیلتر انتخابی شما",
|
||||
"editFilter": "ویرایش فیلتر"
|
||||
},
|
||||
"specialistCarousel": {
|
||||
"title": "متخصصین پیشنهادی",
|
||||
"description": "با استعدادهای برتر مطابق فیلترتان آشنا شوید",
|
||||
"action": "دنبال کردن",
|
||||
"actionDone": "دنبالشده"
|
||||
},
|
||||
"portfolio": {
|
||||
"uploadTitle": "نمونه کار",
|
||||
"required": "ارسال حداقل یک نمونه کار الزامی است",
|
||||
|
||||
@@ -283,6 +283,15 @@ export interface User {
|
||||
shaba?: string | null;
|
||||
national_code?: string | null;
|
||||
birthday?: string | null;
|
||||
marital_status?: "single" | "divorced" | null;
|
||||
residence_type?: "owner" | "tenant" | null;
|
||||
residence_area?: string | null;
|
||||
vehicle_type?: "none" | "car" | "motorcycle" | null;
|
||||
vehicle_details?: string | null;
|
||||
personal_style?: "formal" | "sporty" | null;
|
||||
job?: string | null;
|
||||
income_range?: "under_5m" | "5m_10m" | "10m_20m" | "over_20m" | null;
|
||||
moral_traits?: string | null;
|
||||
cooperation_abroad?: boolean | null | undefined;
|
||||
conversation_projects?: boolean | null | undefined;
|
||||
posts?: Post[];
|
||||
|
||||
Reference in New Issue
Block a user