This commit is contained in:
payacom
2026-08-18 20:22:07 +03:30
parent 0c6e1a7125
commit 1ba6646bf9
19 changed files with 1440 additions and 41 deletions

View File

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

View File

@@ -99,7 +99,7 @@ export default async function RootLayout({
<SiteJsonLd />
<RegisterSW />
<ClientErrorBoundary>
<Layout>{children}</Layout>
<Layout initialLanguage={lang}>{children}</Layout>
</ClientErrorBoundary>
{/* ====================== گوگل آنالیتیکس ====================== */}
<Script

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

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

View File

@@ -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": "5M10M Toman",
"income10m20m": "10M20M 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",

View File

@@ -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": "ارسال حداقل یک نمونه کار الزامی است",

View File

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