This commit is contained in:
payacom
2026-09-13 21:32:32 +03:30
parent 901ce94443
commit 9d48c0534e
21 changed files with 2326 additions and 336 deletions

View File

@@ -1,13 +1,13 @@
"use client";
import { useEffect, useState } from "react";
import Container from "@/components/elements/Container";
import RoundedDiv from "@/components/elements/RoundedDiv";
import EditPageHeader from "@/components/settings/EditPageHeader";
import Link from "next/link";
import { useTranslation } from "react-i18next";
import { isAndroidDevice } from "@/lib/pwa/install";
import { buildStorageUrl } from "@/components/main/BaseUrl";
import { ANDROID_APP_VERSION } from "@/lib/pwaConfig";
import { buildStorageUrl, getApiBaseUrl } from "@/components/main/BaseUrl";
// عین PwaInstallPrompt.tsx: مسیر مستقیمِ فایل APK که بک‌اند بدون روتِ جدید سرو می‌کنه
const ANDROID_APK_URL = buildStorageUrl("/downloads/modstagram.apk");
@@ -16,6 +16,21 @@ function AboutUs() {
const { t } = useTranslation("common");
// فقط اندروید — طبق خواسته‌ی صریح کاربر توی iOS این دکمه نمایش داده نشه
const isAndroid = isAndroidDevice();
// قبلاً ANDROID_APP_VERSION توی pwaConfig.ts هاردکد بود و باید با هر ریلیزِ اندروید
// دستی آپدیت می‌شد (که یادمون می‌رفت و اینجا نسخه‌ی قدیمی می‌موند). حالا همون
// GET /version که پنل ادمین (Version.jsx) مقداردهی می‌کنه رو مستقیم می‌خونه — دقیقاً
// همون منبعِ واحدی که AppUpdateGate.tsx توی اندروید هم برای چک نسخه ازش استفاده می‌کنه
const [androidVersion, setAndroidVersion] = useState<string | null>(null);
useEffect(() => {
fetch(`${getApiBaseUrl()}/version`)
.then((res) => (res.ok ? res.json() : null))
.then((data: { version?: string } | null) => {
if (data?.version) setAndroidVersion(data.version);
})
.catch(() => {
/* اگه چک نسخه شکست بخوره، همون خطِ نسخه نمایش داده نمی‌شه — چیزِ دیگه‌ای رو نمی‌شکنه */
});
}, []);
return (
<Container>
@@ -46,9 +61,9 @@ function AboutUs() {
</a>
) : null}
</div>
{isAndroid ? (
{isAndroid && androidVersion ? (
<span className="text-xs text-neutral-500">
{t("aboutPage.appVersion", { version: ANDROID_APP_VERSION })}
{t("aboutPage.appVersion", { version: androidVersion })}
</span>
) : null}
</div>

View File

@@ -104,7 +104,7 @@ function filtersToParams(filters: PartnerFilters): Record<string, string> {
put("values", joinFilterValues(filters.values));
put("relationship_view", filters.relationshipView);
put("geo_preference", filters.geoPreference);
put("children_status", filters.childrenStatus);
put("children_status", joinFilterValues(filters.childrenStatus));
put("smoking_status", filters.smokingStatus);
if (filters.geo.countryId) params.country = String(filters.geo.countryId);
if (filters.geo.provinceId) params.province = String(filters.geo.provinceId);

View File

@@ -32,9 +32,7 @@ function EditGenderPage() {
return;
}
try {
const mobile =
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
await request("POST", "/verify/gender", { mobile, gender });
await request("PATCH", "/verify/gender", { gender });
toast.success(t("settings.edit.personalDetails.saveSuccess"));
router.push("/settings/edit/personal-details");
} catch (err: any) {

View File

@@ -28,14 +28,9 @@ import {
buildGeoPreferenceOptions,
buildChildrenStatusOptions,
buildSmokingStatusOptions,
RELATIONSHIP_GOAL_MAX,
PERSONALITY_TRAITS_MAX,
LIFESTYLE_MAX,
INTERESTS_MAX,
VALUES_MAX,
buildPersonalStyleOptions,
type RelationshipView,
type GeoPreference,
type ChildrenStatus,
type SmokingStatus,
} from "@/lib/personalDetailsFieldOptions";
@@ -48,7 +43,6 @@ type MaritalStatus =
| null;
type ResidenceType = "owner" | "tenant" | null;
type VehicleType = "none" | "car" | "motorcycle" | null;
type PersonalStyle = "formal" | "sporty" | null;
type IncomeRange =
| "under_40m"
| "40m_50m"
@@ -115,10 +109,9 @@ function PersonalDetailsPage() {
const [residenceArea, setResidenceArea] = useState("");
const [vehicleType, setVehicleType] = useState<VehicleType>(null);
const [vehicleDetails, setVehicleDetails] = useState("");
const [personalStyle, setPersonalStyle] = useState<PersonalStyle>(null);
const [personalStyle, setPersonalStyle] = useState("");
const [job, setJob] = useState("");
const [incomeRange, setIncomeRange] = useState<IncomeRange>(null);
const [moralTraits, setMoralTraits] = useState("");
const [relationshipGoal, setRelationshipGoal] = useState<string[]>([]);
const [personalityTraits, setPersonalityTraits] = useState<string[]>([]);
@@ -127,11 +120,12 @@ function PersonalDetailsPage() {
const [values, setValues] = useState<string[]>([]);
const [relationshipView, setRelationshipView] = useState<RelationshipView | "">("");
const [geoPreference, setGeoPreference] = useState<GeoPreference | "">("");
const [childrenStatus, setChildrenStatus] = useState<ChildrenStatus | "">("");
const [childrenStatus, setChildrenStatus] = useState<string[]>([]);
const [smokingStatus, setSmokingStatus] = useState<SmokingStatus | "">("");
const maritalStatusNewOptions = buildMaritalStatusNewOptions(t);
const incomeRangeOptions = buildIncomeRangeOptions(t);
const personalStyleOptions = buildPersonalStyleOptions(t);
const relationshipGoalOptions = buildRelationshipGoalOptions(t);
const personalityTraitsOptions = buildPersonalityTraitsOptions(t);
const lifestyleOptions = buildLifestyleOptions(t);
@@ -151,10 +145,9 @@ function PersonalDetailsPage() {
setResidenceArea(user.residence_area ?? "");
setVehicleType(user.vehicle_type ?? null);
setVehicleDetails(user.vehicle_details ?? "");
setPersonalStyle(user.personal_style ?? null);
setPersonalStyle(user.personal_style ?? "");
setJob(user.job ?? "");
setIncomeRange(user.income_range ?? null);
setMoralTraits(user.moral_traits ?? "");
setRelationshipGoal(user.relationship_goal ?? []);
setPersonalityTraits(user.personality_traits ?? []);
setLifestyle(user.lifestyle ?? []);
@@ -162,7 +155,7 @@ function PersonalDetailsPage() {
setValues(user.values ?? []);
setRelationshipView(user.relationship_view ?? "");
setGeoPreference(user.geo_preference ?? "");
setChildrenStatus(user.children_status ?? "");
setChildrenStatus(user.children_status ?? []);
setSmokingStatus(user.smoking_status ?? "");
}, [user]);
@@ -174,10 +167,9 @@ function PersonalDetailsPage() {
residence_area: residenceArea || null,
vehicle_type: vehicleType,
vehicle_details: vehicleType && vehicleType !== "none" ? vehicleDetails || null : null,
personal_style: personalStyle,
personal_style: personalStyle || null,
job: job || null,
income_range: incomeRange,
moral_traits: moralTraits || null,
relationship_goal: relationshipGoal.length ? relationshipGoal : null,
personality_traits: personalityTraits.length ? personalityTraits : null,
lifestyle: lifestyle.length ? lifestyle : null,
@@ -185,7 +177,7 @@ function PersonalDetailsPage() {
values: values.length ? values : null,
relationship_view: relationshipView || null,
geo_preference: geoPreference || null,
children_status: childrenStatus || null,
children_status: childrenStatus.length ? childrenStatus : null,
smoking_status: smokingStatus || null,
});
toast.success(t("settings.edit.personalDetails.saveSuccess"));
@@ -245,27 +237,17 @@ function PersonalDetailsPage() {
</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>
<RoundedSelect
label={t("settings.edit.personalDetails.residenceType")}
placeholder={t("settings.edit.personalDetails.selectPlaceholder")}
value={residenceType ?? ""}
options={[
{ value: "owner", label: t("settings.edit.personalDetails.residenceTypeOwner") },
{ value: "tenant", label: t("settings.edit.personalDetails.residenceTypeTenant") },
]}
onChange={(v) => setResidenceType((v || null) as ResidenceType)}
className="mx-auto"
/>
{/* محل سکونت */}
<div>
@@ -282,32 +264,18 @@ function PersonalDetailsPage() {
{/* وسیله نقلیه */}
<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>
<RoundedSelect
label={t("settings.edit.personalDetails.vehicleType")}
placeholder={t("settings.edit.personalDetails.selectPlaceholder")}
value={vehicleType ?? ""}
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") },
]}
onChange={(v) => setVehicleType((v || null) as VehicleType)}
className="mx-auto"
/>
{vehicleType && vehicleType !== "none" && (
<RoundedInput
className="mt-2"
@@ -320,27 +288,14 @@ function PersonalDetailsPage() {
</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>
<RoundedSelect
label={t("settings.edit.personalDetails.personalStyle")}
placeholder={t("settings.edit.personalDetails.selectPlaceholder")}
value={personalStyle}
options={personalStyleOptions}
onChange={setPersonalStyle}
className="mx-auto"
/>
{/* شغل */}
<div>
@@ -365,21 +320,6 @@ function PersonalDetailsPage() {
className="mx-auto"
/>
{/* خصوصیات اخلاقی */}
<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 className="border-t border-neutral-200 pt-6 dark:border-neutral-700" />
{/* هدف از رابطه */}
@@ -389,10 +329,6 @@ function PersonalDetailsPage() {
value={relationshipGoal}
options={relationshipGoalOptions}
onChange={setRelationshipGoal}
max={RELATIONSHIP_GOAL_MAX}
maxHint={t("settings.edit.personalDetails.multiSelectMaxHint", {
max: RELATIONSHIP_GOAL_MAX,
})}
formatSelectedSummary={selectedCountLabel}
className="mx-auto"
/>
@@ -430,10 +366,6 @@ function PersonalDetailsPage() {
value={personalityTraits}
options={personalityTraitsOptions}
onChange={setPersonalityTraits}
max={PERSONALITY_TRAITS_MAX}
maxHint={t("settings.edit.personalDetails.multiSelectMaxHint", {
max: PERSONALITY_TRAITS_MAX,
})}
formatSelectedSummary={selectedCountLabel}
className="mx-auto"
/>
@@ -445,10 +377,6 @@ function PersonalDetailsPage() {
value={lifestyle}
options={lifestyleOptions}
onChange={setLifestyle}
max={LIFESTYLE_MAX}
maxHint={t("settings.edit.personalDetails.multiSelectMaxHint", {
max: LIFESTYLE_MAX,
})}
formatSelectedSummary={selectedCountLabel}
className="mx-auto"
/>
@@ -460,10 +388,6 @@ function PersonalDetailsPage() {
value={interests}
options={interestsOptions}
onChange={setInterests}
max={INTERESTS_MAX}
maxHint={t("settings.edit.personalDetails.multiSelectMaxHint", {
max: INTERESTS_MAX,
})}
formatSelectedSummary={selectedCountLabel}
className="mx-auto"
/>
@@ -475,10 +399,6 @@ function PersonalDetailsPage() {
value={values}
options={valuesOptions}
onChange={setValues}
max={VALUES_MAX}
maxHint={t("settings.edit.personalDetails.multiSelectMaxHint", {
max: VALUES_MAX,
})}
formatSelectedSummary={selectedCountLabel}
className="mx-auto"
/>
@@ -504,12 +424,13 @@ function PersonalDetailsPage() {
/>
{/* وضعیت فرزند */}
<RoundedSelect
<MultiSelectDropdown
label={t("settings.edit.personalDetails.childrenStatus")}
placeholder={t("settings.edit.personalDetails.selectPlaceholder")}
value={childrenStatus}
options={childrenStatusOptions}
onChange={(v) => setChildrenStatus(v as ChildrenStatus)}
onChange={setChildrenStatus}
formatSelectedSummary={selectedCountLabel}
className="mx-auto"
/>

View File

@@ -56,6 +56,20 @@ export default function TabNavigation({ currentPage }: TabNavigationProps) {
<Link
key={tab.href}
href={tab.href}
onClick={(e) => {
// وقتی تبی که از قبل توش هستیم دوباره زده می‌شه، Next.js چون مسیر عوض
// نمی‌شه هیچ اسکرول/رفرشی انجام نمی‌ده — طبق درخواست کاربر، مثل اینستاگرام
// باید صفحه بره بالا و (برای تبِ خانه) جدیدترین پست‌ها هم لود بشن
if (isActive) {
e.preventDefault();
window.dispatchEvent(
new CustomEvent("app:tab-reselect", {
detail: { href: tab.href },
})
);
window.scrollTo({ top: 0, behavior: "smooth" });
}
}}
className="gentle-transition flex flex-col items-center gap-0.5 px-2 py-1 active:scale-95"
>
<BoldIcon

View File

@@ -14,17 +14,19 @@ import {
type GateState = { kind: "idle" } | { kind: "recovery"; hasBackup: boolean };
/**
* موقتاً کلاً غیرفعال — چون رمزنگاریِ خودِ پیام‌ها (encryptDmContent/encryptRoomContent)
* موقتاً خاموشه (به‌درخواستِ کاربر، چون بعضی وقت‌ها هنوز «پیام رمزنگاری‌شده» گیر
* می‌کرد)، دیگه نیازی نیست کاربر رو با کارتِ بازیابیِ هویت معطل کنیم. برای
* فعال‌سازیِ دوباره، فقط همین return null رو بردار.
*
* توی ChatSocketProvider (سطحِ لایوت /settings/chats) mount می‌شه — جایی که
* ensureIdentityKeys برای اولین‌بار صدا زده می‌شه.
*
* بک‌آپِ کلید حالا کاملاً خودکاره (با رمزِ per-account که سرور خودش می‌سازه، نه PIN
* کاربر) — پس دیگه هیچ پیشنهادِ «رمز بازیابی بساز»ی لازم نیست، و لاگین دوباره/دستگاهِ
* جدید هم خودش را قبل از رسیدن به اینجا با همون رمزِ خودکار بازیابی می‌کنه
* (lib/e2ee/keys.ts: tryAutoRecoverIdentity). این کارت فقط برای حالتِ نادری ظاهر
* می‌شه که حتی اون بازیابیِ خودکار هم شکست بخوره (مثلاً بک‌آپِ سرور با یه PIN دستیِ
* قدیمی — ازقبل‌ازاین‌تغییر — رمزنگاری شده، یا واقعاً هیچ بک‌آپی نیست)
*/
export default function E2eeIdentityGate({ userId }: { userId: string }) {
export default function E2eeIdentityGate({ userId: _userId }: { userId: string }) {
return null;
}
function DisabledE2eeIdentityGate({ userId }: { userId: string }) {
const { t } = useTranslation("common");
const [state, setState] = useState<GateState>({ kind: "idle" });
const [pin, setPin] = useState("");

View File

@@ -24,11 +24,15 @@ import {
buildChildrenStatusOptions,
buildSmokingStatusOptions,
buildIncomeRangeOptions,
RELATIONSHIP_GOAL_MAX,
PERSONALITY_TRAITS_MAX,
LIFESTYLE_MAX,
INTERESTS_MAX,
VALUES_MAX,
buildPersonalStyleOptions,
buildRangeBucketOptions,
buildSizeBucketOptions,
HEIGHT_BUCKETS,
WEIGHT_BUCKETS,
AGE_BUCKETS,
SIZE_BUCKETS_WITH_EXTRAS,
type RangeBucket,
type LabeledOption,
} from "@/lib/personalDetailsFieldOptions";
export type PartnerFilters = {
@@ -59,7 +63,7 @@ export type PartnerFilters = {
values: string[];
relationshipView: string;
geoPreference: string;
childrenStatus: string;
childrenStatus: string[];
smokingStatus: string;
};
@@ -91,48 +95,88 @@ export const EMPTY_PARTNER_FILTERS: PartnerFilters = {
values: [],
relationshipView: "",
geoPreference: "",
childrenStatus: "",
childrenStatus: [],
smokingStatus: "",
};
function RangeField({
/** دراپ‌داونِ بازه‌بندی‌شده برای قد/وزن/سایز/سن: به‌جای دو ورودی عددی خام، یک
* لیست از بازه‌های آماده + «برام مهم نیست» + «بازه انتخابی» نشان می‌دهد؛ فقط
* وقتی «بازه انتخابی» انتخاب شود دو ورودی حداقل/حداکثر دستی (همون الگوی قبلیِ
* RangeField) ظاهر می‌شوند. مقدار واقعیِ فیلتر همچنان min/max عددی است — همون
* چیزی که سرچ سرور می‌فهمد — این کامپوننت فقط یک لایه‌ی UI روی آن است. */
function RangeBucketSelect({
label,
buckets,
options,
min,
max,
onMinChange,
onMaxChange,
placeholder,
minPlaceholder,
maxPlaceholder,
}: {
label: string;
buckets: RangeBucket[];
options: LabeledOption[];
min: string;
max: string;
onMinChange: (v: string) => void;
onMaxChange: (v: string) => void;
placeholder: string;
minPlaceholder: string;
maxPlaceholder: string;
}) {
const realBuckets = buckets.filter((b) => b.value !== "not_important" && b.value !== "custom");
const matchedBucket = realBuckets.find(
(b) => String(b.min ?? "") === (min || "") && String(b.max ?? "") === (max || "")
);
const hasAnyValue = Boolean(min || max);
const selected = matchedBucket ? matchedBucket.value : hasAnyValue ? "custom" : "";
const handleSelect = (value: string) => {
if (value === "" || value === "not_important") {
onMinChange("");
onMaxChange("");
return;
}
if (value === "custom") {
return;
}
const bucket = realBuckets.find((b) => b.value === value);
onMinChange(bucket?.min !== undefined ? String(bucket.min) : "");
onMaxChange(bucket?.max !== undefined ? String(bucket.max) : "");
};
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>
<RoundedSelect
label={label}
placeholder={placeholder}
value={selected}
options={options}
onChange={handleSelect}
/>
{selected === "custom" && (
<div className="mt-2 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>
);
}
@@ -202,6 +246,11 @@ export default function FindPartnerFilterModal({
const childrenStatusOptions = buildChildrenStatusOptions(t);
const smokingStatusOptions = buildSmokingStatusOptions(t);
const incomeRangeOptions = buildIncomeRangeOptions(t);
const personalStyleOptions = buildPersonalStyleOptions(t);
const ageBucketOptions = buildRangeBucketOptions(t, AGE_BUCKETS);
const heightBucketOptions = buildRangeBucketOptions(t, HEIGHT_BUCKETS);
const weightBucketOptions = buildRangeBucketOptions(t, WEIGHT_BUCKETS);
const sizeBucketOptions = buildSizeBucketOptions(t);
const selectedCountLabel = (count: number) =>
t("settings.edit.personalDetails.multiSelectSelectedCount", { count });
@@ -229,42 +278,54 @@ export default function FindPartnerFilterModal({
]}
/>
<RangeField
<RangeBucketSelect
label={t("filters.age")}
buckets={AGE_BUCKETS}
options={ageBucketOptions}
min={filters.ageMin}
max={filters.ageMax}
onMinChange={(v) => set("ageMin", v)}
onMaxChange={(v) => set("ageMax", v)}
placeholder={t("settings.edit.personalDetails.selectPlaceholder")}
minPlaceholder={t("filters.min")}
maxPlaceholder={t("filters.max")}
/>
<RangeField
<RangeBucketSelect
label={t("filters.heightCm")}
buckets={HEIGHT_BUCKETS}
options={heightBucketOptions}
min={filters.heightMin}
max={filters.heightMax}
onMinChange={(v) => set("heightMin", v)}
onMaxChange={(v) => set("heightMax", v)}
placeholder={t("settings.edit.personalDetails.selectPlaceholder")}
minPlaceholder={t("filters.min")}
maxPlaceholder={t("filters.max")}
/>
<RangeField
<RangeBucketSelect
label={t("filters.weightKg")}
buckets={WEIGHT_BUCKETS}
options={weightBucketOptions}
min={filters.weightMin}
max={filters.weightMax}
onMinChange={(v) => set("weightMin", v)}
onMaxChange={(v) => set("weightMax", v)}
placeholder={t("settings.edit.personalDetails.selectPlaceholder")}
minPlaceholder={t("filters.min")}
maxPlaceholder={t("filters.max")}
/>
<RangeField
<RangeBucketSelect
label={t("filters.size")}
buckets={SIZE_BUCKETS_WITH_EXTRAS}
options={sizeBucketOptions}
min={filters.sizeMin}
max={filters.sizeMax}
onMinChange={(v) => set("sizeMin", v)}
onMaxChange={(v) => set("sizeMax", v)}
placeholder={t("settings.edit.personalDetails.selectPlaceholder")}
minPlaceholder={t("filters.min")}
maxPlaceholder={t("filters.max")}
/>
@@ -296,35 +357,35 @@ export default function FindPartnerFilterModal({
]}
/>
<ToggleRow
<RoundedSelect
label={t("settings.edit.personalDetails.residenceType")}
placeholder={t("settings.edit.personalDetails.selectPlaceholder")}
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") },
]}
onChange={(v) => set("residenceType", v)}
/>
<ToggleRow
<RoundedSelect
label={t("settings.edit.personalDetails.vehicleType")}
placeholder={t("settings.edit.personalDetails.selectPlaceholder")}
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") },
]}
onChange={(v) => set("vehicleType", v)}
/>
<ToggleRow
<RoundedSelect
label={t("settings.edit.personalDetails.personalStyle")}
placeholder={t("settings.edit.personalDetails.selectPlaceholder")}
value={filters.personalStyle}
options={personalStyleOptions}
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">
@@ -355,10 +416,6 @@ export default function FindPartnerFilterModal({
value={filters.relationshipGoal}
options={relationshipGoalOptions}
onChange={(v) => set("relationshipGoal", v)}
max={RELATIONSHIP_GOAL_MAX}
maxHint={t("settings.edit.personalDetails.multiSelectMaxHint", {
max: RELATIONSHIP_GOAL_MAX,
})}
formatSelectedSummary={selectedCountLabel}
/>
</div>
@@ -385,10 +442,6 @@ export default function FindPartnerFilterModal({
value={filters.personalityTraits}
options={personalityTraitsOptions}
onChange={(v) => set("personalityTraits", v)}
max={PERSONALITY_TRAITS_MAX}
maxHint={t("settings.edit.personalDetails.multiSelectMaxHint", {
max: PERSONALITY_TRAITS_MAX,
})}
formatSelectedSummary={selectedCountLabel}
/>
@@ -398,10 +451,6 @@ export default function FindPartnerFilterModal({
value={filters.lifestyle}
options={lifestyleOptions}
onChange={(v) => set("lifestyle", v)}
max={LIFESTYLE_MAX}
maxHint={t("settings.edit.personalDetails.multiSelectMaxHint", {
max: LIFESTYLE_MAX,
})}
formatSelectedSummary={selectedCountLabel}
/>
@@ -411,10 +460,6 @@ export default function FindPartnerFilterModal({
value={filters.interests}
options={interestsOptions}
onChange={(v) => set("interests", v)}
max={INTERESTS_MAX}
maxHint={t("settings.edit.personalDetails.multiSelectMaxHint", {
max: INTERESTS_MAX,
})}
formatSelectedSummary={selectedCountLabel}
/>
@@ -424,10 +469,6 @@ export default function FindPartnerFilterModal({
value={filters.values}
options={valuesOptions}
onChange={(v) => set("values", v)}
max={VALUES_MAX}
maxHint={t("settings.edit.personalDetails.multiSelectMaxHint", {
max: VALUES_MAX,
})}
formatSelectedSummary={selectedCountLabel}
/>
@@ -447,12 +488,13 @@ export default function FindPartnerFilterModal({
onChange={(v) => set("geoPreference", v)}
/>
<RoundedSelect
<MultiSelectDropdown
label={t("settings.edit.personalDetails.childrenStatus")}
placeholder={t("settings.edit.personalDetails.selectPlaceholder")}
value={filters.childrenStatus}
options={childrenStatusOptions}
onChange={(v) => set("childrenStatus", v)}
formatSelectedSummary={selectedCountLabel}
/>
<RoundedSelect

View File

@@ -4,7 +4,7 @@ import React, { useEffect, useCallback, useMemo } from "react";
import { navigateWithSavedScroll } from "@/lib/listScrollRestoration";
import { useListScrollRestoration } from "@/hooks/useListScrollRestoration";
import { useRouter } from "next/navigation";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
import MainModelCard from "@/components/models/MainModelCard/MainModelCard";
import { fetchPosts } from "@/api/fetchPosts";
import { Post } from "@/types/types";
@@ -48,12 +48,18 @@ export default function InfinitePosts({
}: InfinitePostsProps) {
const { t } = useTranslation("common");
const router = useRouter();
const queryClient = useQueryClient();
const filterKey = useMemo(
() => JSON.stringify({ ...filters, type: typeFilter }),
[filters, typeFilter]
);
const queryKey = useMemo(
() => ["posts", { ...filters, type: typeFilter }],
[filters, typeFilter]
);
const {
data,
fetchNextPage,
@@ -63,7 +69,7 @@ export default function InfinitePosts({
isError,
error,
} = useInfiniteQuery<PageData, Error>({
queryKey: ["posts", { ...filters, type: typeFilter }],
queryKey,
queryFn: ({ pageParam = 1 }) =>
fetchPosts(
pageParam as number,
@@ -77,6 +83,54 @@ export default function InfinitePosts({
refetchOnWindowFocus: false,
});
// طبق درخواست کاربر: با زدنِ دوباره‌ی آیکونِ خانه توی TabNavigation (وقتی از قبل
// توی همین صفحه‌ست)، به‌جای فقط اسکرول، جدیدترین پست‌ها هم از سرور چک و به بالای
// فید اضافه می‌شن — عین همون رفتاری که توی اپ اندروید (HomeScreen.tsx) پیاده شده
useEffect(() => {
const handleTabReselect = async (e: Event) => {
const detail = (e as CustomEvent<{ href: string }>).detail;
if (detail?.href !== "/") return;
try {
const peek = await fetchPosts(
1,
4,
{ ...filters, feedMode: "grid" },
token
);
const existingIds = new Set(
(data?.pages?.[0]?.posts ?? []).map((p) => p._id)
);
const fresh = ((peek.posts ?? []) as Post[]).filter(
(p) => p?._id && !existingIds.has(p._id)
);
if (fresh.length > 0) {
queryClient.setQueryData<{
pages: PageData[];
pageParams: unknown[];
}>(
queryKey,
(old) =>
old && {
...old,
pages: [
{
...old.pages[0],
posts: [...fresh, ...(old.pages[0]?.posts ?? [])],
},
...old.pages.slice(1),
],
}
);
}
} catch {
// بی‌صدا نادیده گرفته می‌شه — کاربر همچنان به بالای همون فیدِ موجود برمی‌گرده
}
};
window.addEventListener("app:tab-reselect", handleTabReselect);
return () =>
window.removeEventListener("app:tab-reselect", handleTabReselect);
}, [filters, token, data, queryClient, queryKey]);
const scrollKey = useListScrollRestoration(
"posts",
{ f: filterKey },

View File

@@ -8,6 +8,7 @@ import ModelContentPosts from "./ModelContentPosts";
import ModelContentProjects from "./ModelContentProjects";
import ModelDetailModal from "../ModelDetailModal";
import ModelServiceModal from "../ModelServiceModal";
import ProfilePersonalDetailsModal from "../ProfilePersonalDetailsModal";
import Link from "next/link";
import Cookies from "js-cookie";
import { useRouter } from "next/navigation";
@@ -51,6 +52,7 @@ function ModelContent({ user, searchParams }: ModelContentProps) {
const { t } = useTranslation("common");
const [showProjects] = useState<boolean>(false);
const [showModelDetailModal, setShowModelDetailModal] = useState<boolean>(false);
const [showPersonalDetails, setShowPersonalDetails] = useState(false);
const [showLocationModal, setShowLocationModal] = useState<boolean>(false);
const [showShareProfile, setShowShareProfile] = useState(false);
const [usertype, setUsertype] = useState<string | null>(null);
@@ -293,25 +295,37 @@ function ModelContent({ user, searchParams }: ModelContentProps) {
displayName={formatFullName(user?.first_name, user?.last_name)}
profileImage={user?.profile_image}
/>
<ProfilePersonalDetailsModal
isOpen={showPersonalDetails}
onClose={() => setShowPersonalDetails(false)}
maritalStatus={user?.marital_status}
residenceType={user?.residence_type}
residenceArea={user?.residence_area}
vehicleType={user?.vehicle_type}
vehicleDetails={user?.vehicle_details}
personalStyle={user?.personal_style}
job={user?.job}
incomeRange={user?.income_range}
relationshipGoal={user?.relationship_goal}
personalityTraits={user?.personality_traits}
lifestyle={user?.lifestyle}
interests={user?.interests}
values={user?.values}
relationshipView={user?.relationship_view}
geoPreference={user?.geo_preference}
childrenStatus={user?.children_status}
smokingStatus={user?.smoking_status}
/>
{user?.user_type == "user" ? (
<div className="grid grid-cols-4 gap-1 md:gap-4">
<div className={cn("grid gap-1 md:gap-4", isOwnProfile ? "grid-cols-3" : "grid-cols-4")}>
{isOwnProfile ? (
<>
<button
type="button"
onClick={() => setUserListKind("following")}
className={profileActionBtnClass}
>
{t("models.actions.followingCount", { count: user?.followingCount ?? 0 })}
</button>
<button
type="button"
onClick={() => setUserListKind("blocked")}
className={profileActionBtnClass}
>
{t("models.actions.blockedCount", { count: user?.blockedCount ?? 0 })}
</button>
</>
<button
type="button"
onClick={() => setUserListKind("following")}
className={profileActionBtnClass}
>
{t("models.actions.followingCount", { count: user?.followingCount ?? 0 })}
</button>
) : (
<>
<button
@@ -368,11 +382,17 @@ function ModelContent({ user, searchParams }: ModelContentProps) {
<div
className={cn(
"mt-2 grid gap-1 md:mt-4 md:gap-4",
[user?.show_location, user?.has_active_booking].filter(Boolean).length === 2
? "grid-cols-4"
: user?.show_location || user?.has_active_booking
? "grid-cols-3"
: "grid-cols-2"
[user?.show_location, user?.has_active_booking, user?.personal_details_public].filter(
Boolean
).length === 3
? "grid-cols-3"
: [user?.show_location, user?.has_active_booking, user?.personal_details_public].filter(
Boolean
).length === 2
? "grid-cols-4"
: user?.show_location || user?.has_active_booking || user?.personal_details_public
? "grid-cols-3"
: "grid-cols-2"
)}
>
<Link href="/settings/profile/info" className={profileActionBtnClass}>
@@ -400,12 +420,25 @@ function ModelContent({ user, searchParams }: ModelContentProps) {
>
{t("models.actions.location")}
</button>
{user?.personal_details_public && (
<button
type="button"
onClick={() => setShowPersonalDetails(true)}
className={profileActionBtnClass}
>
{t("models.actions.personalDetails")}
</button>
)}
</div>
) : user?.user_type == "user" ? (
<div
className={cn(
"mt-2 grid gap-1 md:mt-4 md:gap-4",
user?.show_location ? "grid-cols-2" : "grid-cols-3"
[user?.show_location, user?.personal_details_public].filter(Boolean).length === 2
? "grid-cols-3"
: user?.show_location || user?.personal_details_public
? "grid-cols-2"
: "grid-cols-3"
)}
>
<button
@@ -431,6 +464,15 @@ function ModelContent({ user, searchParams }: ModelContentProps) {
>
{t("models.actions.shareProfile")}
</button>
{user?.personal_details_public && (
<button
type="button"
onClick={() => setShowPersonalDetails(true)}
className={profileActionBtnClass}
>
{t("models.actions.personalDetails")}
</button>
)}
<button
type="button"
onClick={openLocationModal}
@@ -444,7 +486,12 @@ function ModelContent({ user, searchParams }: ModelContentProps) {
</button>
</div>
) : (
<div className="mx-auto mt-2 grid max-w-xl grid-cols-3 justify-center gap-1 md:gap-2">
<div
className={cn(
"mx-auto mt-2 grid max-w-xl justify-center gap-1 md:gap-2",
user?.personal_details_public ? "grid-cols-2" : "grid-cols-3"
)}
>
<button
type="button"
onClick={handleCollaborationOrBooking}
@@ -468,6 +515,15 @@ function ModelContent({ user, searchParams }: ModelContentProps) {
>
{t("models.actions.shareProfile")}
</button>
{user?.personal_details_public && (
<button
type="button"
onClick={() => setShowPersonalDetails(true)}
className={profileActionBtnClass}
>
{t("models.actions.personalDetails")}
</button>
)}
</div>
)}

View File

@@ -0,0 +1,215 @@
"use client";
import React from "react";
import { useTranslation } from "react-i18next";
import Modal from "../elements/Modal";
import {
buildChildrenStatusOptions,
buildGeoPreferenceOptions,
buildIncomeRangeOptions,
buildInterestsOptions,
buildLifestyleOptions,
buildPersonalStyleOptions,
buildPersonalityTraitsOptions,
buildRelationshipGoalOptions,
buildRelationshipViewOptions,
buildSmokingStatusOptions,
buildValuesOptions,
type LabeledOption,
} from "@/lib/personalDetailsFieldOptions";
type StrArrNullUnd = string[] | null | undefined;
type StrNullUnd = string | null | undefined;
type Props = {
isOpen: boolean;
onClose: () => void;
maritalStatus: StrNullUnd;
residenceType: StrNullUnd;
residenceArea: StrNullUnd;
vehicleType: StrNullUnd;
vehicleDetails: StrNullUnd;
personalStyle: StrNullUnd;
job: StrNullUnd;
incomeRange: StrNullUnd;
relationshipGoal: StrArrNullUnd;
personalityTraits: StrArrNullUnd;
lifestyle: StrArrNullUnd;
interests: StrArrNullUnd;
values: StrArrNullUnd;
relationshipView: StrNullUnd;
geoPreference: StrNullUnd;
childrenStatus: StrArrNullUnd;
smokingStatus: StrNullUnd;
};
function findLabel(options: LabeledOption[], value: StrNullUnd): string | null {
if (!value) return null;
return options.find((o) => o.value === value)?.label ?? null;
}
function joinLabels(options: LabeledOption[], values: StrArrNullUnd, isRtl: boolean): string | null {
if (!values || values.length === 0) return null;
const labels = values
.map((v) => options.find((o) => o.value === v)?.label)
.filter((l): l is string => Boolean(l));
if (labels.length === 0) return null;
return labels.join(isRtl ? "، " : ", ");
}
// residence/vehicle توی personalDetailsFieldOptions.ts به‌عنوان OPTIONS مشترک export نشدن
// (توی فرم ویرایش به‌صورت دکمه‌ی هاردکد رندر می‌شن، نه دراپ‌داون) — این‌جا محلی نگه داشته می‌شن
function buildResidenceTypeOptions(t: (key: string) => string): LabeledOption[] {
return [
{ value: "owner", label: t("settings.edit.personalDetails.residenceTypeOwner") },
{ value: "tenant", label: t("settings.edit.personalDetails.residenceTypeTenant") },
];
}
function buildVehicleTypeOptions(t: (key: string) => string): LabeledOption[] {
return [
{ value: "none", label: t("settings.edit.personalDetails.vehicleTypeNone") },
{ value: "car", label: t("settings.edit.personalDetails.vehicleTypeCar") },
{ value: "motorcycle", label: t("settings.edit.personalDetails.vehicleTypeMotorcycle") },
];
}
// single/divorced جدا از buildMaritalStatusNewOptions هستن (اون فقط ۳ مقدار تازه رو
// می‌سازه — نگاه کن settings/edit/personal-details/page.tsx)
function buildMaritalStatusAllOptions(t: (key: string) => string): LabeledOption[] {
return [
{ value: "single", label: t("settings.edit.personalDetails.maritalStatusSingle") },
{ value: "divorced", label: t("settings.edit.personalDetails.maritalStatusDivorced") },
{ value: "widowed", label: t("settings.edit.personalDetails.maritalStatusOptions.widowed") },
{
value: "not_in_relationship",
label: t("settings.edit.personalDetails.maritalStatusOptions.not_in_relationship"),
},
{
value: "prefer_not_to_say",
label: t("settings.edit.personalDetails.maritalStatusOptions.prefer_not_to_say"),
},
];
}
function ProfilePersonalDetailsModal({
isOpen,
onClose,
maritalStatus,
residenceType,
residenceArea,
vehicleType,
vehicleDetails,
personalStyle,
job,
incomeRange,
relationshipGoal,
personalityTraits,
lifestyle,
interests,
values,
relationshipView,
geoPreference,
childrenStatus,
smokingStatus,
}: Props) {
const { t, i18n } = useTranslation("common");
const isRtl = i18n.dir() === "rtl";
const rows: { label: string; value: string }[] = [];
const push = (label: string, value: string | null) => {
if (value) rows.push({ label, value });
};
push(
t("settings.edit.personalDetails.maritalStatus"),
findLabel(buildMaritalStatusAllOptions(t), maritalStatus),
);
push(
t("settings.edit.personalDetails.residenceType"),
findLabel(buildResidenceTypeOptions(t), residenceType),
);
push(t("settings.edit.personalDetails.residenceArea"), residenceArea || null);
if (vehicleType) {
const vLabel = findLabel(buildVehicleTypeOptions(t), vehicleType);
if (vLabel) {
rows.push({
label: t("settings.edit.personalDetails.vehicleType"),
value: vehicleType !== "none" && vehicleDetails ? `${vLabel} (${vehicleDetails})` : vLabel,
});
}
}
push(
t("settings.edit.personalDetails.personalStyle"),
findLabel(buildPersonalStyleOptions(t), personalStyle),
);
push(t("settings.edit.personalDetails.job"), job || null);
push(
t("settings.edit.personalDetails.incomeRange"),
findLabel(buildIncomeRangeOptions(t), incomeRange),
);
push(
t("settings.edit.personalDetails.relationshipGoal"),
joinLabels(buildRelationshipGoalOptions(t), relationshipGoal, isRtl),
);
push(
t("settings.edit.personalDetails.personalityTraits"),
joinLabels(buildPersonalityTraitsOptions(t), personalityTraits, isRtl),
);
push(
t("settings.edit.personalDetails.lifestyle"),
joinLabels(buildLifestyleOptions(t), lifestyle, isRtl),
);
push(
t("settings.edit.personalDetails.interests"),
joinLabels(buildInterestsOptions(t), interests, isRtl),
);
push(t("settings.edit.personalDetails.values"), joinLabels(buildValuesOptions(t), values, isRtl));
push(
t("settings.edit.personalDetails.relationshipView"),
findLabel(buildRelationshipViewOptions(t), relationshipView),
);
push(
t("settings.edit.personalDetails.geoPreference"),
findLabel(buildGeoPreferenceOptions(t), geoPreference),
);
push(
t("settings.edit.personalDetails.childrenStatus"),
joinLabels(buildChildrenStatusOptions(t), childrenStatus, isRtl),
);
push(
t("settings.edit.personalDetails.smokingStatus"),
findLabel(buildSmokingStatusOptions(t), smokingStatus),
);
return (
<Modal
isOpen={isOpen}
onClose={onClose}
height="auto"
panelClassName="max-h-[85vh] overflow-y-auto overscroll-y-contain touch-pan-y [-webkit-overflow-scrolling:touch]"
>
<div className="flex flex-col items-center p-4">
<span className="font-bold">{t("settings.personalDetailsTitle")}</span>
<div className="mt-6 flex w-full max-w-md flex-col gap-4">
{rows.length > 0 ? (
// عین ReadOnlyValueRow توی settings/edit/personal-details/page.tsx: عنوان
// بالا، مقدار داخل یه کادر پایینش — به‌جای ردیفِ افقیِ قبلی
rows.map((row) => (
<div key={row.label}>
<p className="mb-2 text-sm font-semibold">{row.label}</p>
<div className="flex min-h-[42px] w-full items-center rounded-2xl border border-[#C3C3C3] bg-white/80 px-4 py-2.5 text-sm dark:border-neutral-600 dark:bg-neutral-900/80">
{row.value}
</div>
</div>
))
) : (
<p className="text-center text-sm text-neutral-500 dark:text-neutral-400">
{t("profile.personalDetailsEmpty")}
</p>
)}
</div>
</div>
</Modal>
);
}
export default ProfilePersonalDetailsModal;

View File

@@ -78,14 +78,13 @@ export default function StoryRing({
? resolvedAvatarSize + (hasStory ? STORY_RING_PX * 2 : 0)
: undefined;
const gradient =
"conic-gradient(from 210deg, #feda75, #fa7e1e, #d62976, #962fbf, #4f5bd5, #feda75)";
// رنگِ اصلیِ برند اپ (همون رنگِ آیکون قلبِ لایک‌شده در همه‌جای اپ) — قبلاً گرادیانِ
// رنگین‌کمانیِ اینستاگرام بود، طبق درخواست جایگزین شد
const live = hasUnviewed || forceGradient;
const ringBg = tierColor
? TIER_RING_COLORS[tierColor]
: live
? gradient
? "#FF107D"
: "#a3a3a3";
// Always render the same wrapper structure (same total size, same rounding)

View File

@@ -784,6 +784,18 @@ export default function StoryViewer({
>
<BoldIcon name="send-2" size={20} tinted className="text-white" />
</button>
<button
type="button"
onClick={() => {
setPaused(true);
if (videoRef.current) videoRef.current.pause();
setSendOpen(true);
}}
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-black/35 backdrop-blur-md"
aria-label={t("stories.sendStory")}
>
<BoldIcon name="share" size={22} tinted className="text-white" />
</button>
<button
type="button"
onClick={() => void handleLike()}

View File

@@ -1,7 +1,6 @@
import {
decryptText,
deriveDmAesKey,
encryptText,
importPublicKeySpkiB64,
} from "./crypto";
import { E2EE_LOCKED_PREVIEW, isE2eeEnvelope } from "./envelope";
@@ -78,24 +77,19 @@ export function clearDmKeyCache(): void {
dmKeyCache.clear();
}
// دوباره فعال شده — حالا که بک‌آپِ کلید با PIN (keys.ts: saveKeyBackup/
// recoverIdentityFromBackup) و چکِ ایمنِ «هویتِ قبلی روی سرور هست ولی این دستگاه
// کلیدِ محلی نداره» (keys.ts: IdentityRecoveryNeededError) قبل از این وجود دارن، یه
// کلیدِ محلی هیچ‌وقت بی‌صدا جایگزینِ یه هویتِ قدیمی نمی‌شه. اگه کلیدِ طرفِ مقابل در
// دسترس نباشه (fail شدنِ getDmConversationKey)، متنِ ساده ارسال می‌شه — بهتر از
// شکستِ کاملِ ارسالِ پیام، ولی این حالت باید نادر باشه
// موقتاً غیرفعال شده به‌درخواستِ کاربر — با این‌که زیرساختِ بازیابیِ خودکار (رمزِ
// per-account، keys.ts: tryAutoRecoverIdentity) طبق طراحی دقیقاً همون رفتارِ
// «بدونِ رمزِ دستی مثلِ واتساپ» رو داشت، هنوز موردهایی بود که طرفِ مقابل همچنان
// «پیام رمزنگاری‌شده» می‌دید (هم توی وب هم اندروید). تا زمانی که با لاگِ واقعی
// ریشه‌یابی و رفع بشه، پیام‌های جدید ساده (بدونِ envelope) ارسال می‌شن —
// decryptDmContent برای پیام‌های قدیمیِ رمزنگاری‌شده دست‌نخورده مونده. کلِ زیرساختِ
// کلید/بک‌آپ دست‌نخورده مونده تا بعداً دوباره همینجا فعال بشه (فقط همین return رو بردار)
export async function encryptDmContent(
myUserId: string,
peerUserId: string,
plaintext: string
): Promise<string | null> {
const key = await getDmConversationKey(myUserId, peerUserId);
if (!key) return plaintext;
try {
return encryptText(key, plaintext);
} catch {
return plaintext;
}
return plaintext;
}
export async function decryptDmContent(

View File

@@ -2,7 +2,6 @@ import Cookies from "js-cookie";
import { getApiBaseUrl } from "@/components/main/BaseUrl";
import {
decryptText,
encryptText,
generateRoomKeyRaw,
importPublicKeySpkiB64,
importRoomAesKey,
@@ -224,16 +223,15 @@ export async function ensureRoomAesKey(
return task;
}
// موقتاً غیرفعال شده به‌درخواستِ کاربر — عینِ همون دلیلِ encryptDmContent (dm.ts).
// پیام‌های جدیدِ روم ساده ارسال می‌شن؛ decryptRoomContent برای پیام‌های قدیمیِ
// رمزنگاری‌شده دست‌نخورده می‌مونه. برای فعال‌سازیِ دوباره، همین return رو بردار.
export async function encryptRoomContent(
myUserId: string,
roomId: string,
plaintext: string,
): Promise<string | null> {
if (!plaintext) return plaintext;
if (isE2eeEnvelope(plaintext)) return plaintext;
const key = await ensureRoomAesKey(myUserId, roomId);
if (!key) return null;
return encryptText(key, plaintext);
return plaintext;
}
export async function decryptRoomContent(

View File

@@ -13,7 +13,6 @@ export const RELATIONSHIP_GOAL_VALUES = [
"not_sure",
] as const;
export type RelationshipGoal = (typeof RELATIONSHIP_GOAL_VALUES)[number];
export const RELATIONSHIP_GOAL_MAX = 2;
export const MARITAL_STATUS_NEW_VALUES = [
"widowed",
@@ -69,7 +68,6 @@ export const PERSONALITY_TRAIT_VALUES = [
"extrovert",
] as const;
export type PersonalityTrait = (typeof PERSONALITY_TRAIT_VALUES)[number];
export const PERSONALITY_TRAITS_MAX = 8;
export const LIFESTYLE_VALUES = [
"active_healthy",
@@ -83,7 +81,6 @@ export const LIFESTYLE_VALUES = [
"luxury_lover",
] as const;
export type Lifestyle = (typeof LIFESTYLE_VALUES)[number];
export const LIFESTYLE_MAX = 5;
export const INTEREST_VALUES = [
"movies_series",
@@ -102,7 +99,6 @@ export const INTEREST_VALUES = [
"language_learning",
] as const;
export type Interest = (typeof INTEREST_VALUES)[number];
export const INTERESTS_MAX = 8;
export const VALUE_VALUES = [
"family",
@@ -115,7 +111,6 @@ export const VALUE_VALUES = [
"excitement_new_experiences",
] as const;
export type ValueItem = (typeof VALUE_VALUES)[number];
export const VALUES_MAX = 5;
export const RELATIONSHIP_VIEW_VALUES = [
"traditional",
@@ -146,6 +141,16 @@ export const CHILDREN_STATUS_VALUES = [
] as const;
export type ChildrenStatus = (typeof CHILDREN_STATUS_VALUES)[number];
export const PERSONAL_STYLE_VALUES = [
"casual",
"classic",
"minimal",
"formal",
"street",
"romantic",
] as const;
export type PersonalStyle = (typeof PERSONAL_STYLE_VALUES)[number];
export const SMOKING_STATUS_VALUES = [
"none",
"sometimes",
@@ -213,12 +218,109 @@ export const buildGeoPreferenceOptions = (t: TranslateFn) =>
export const buildChildrenStatusOptions = (t: TranslateFn) =>
buildOptions(t, "childrenStatusOptions", CHILDREN_STATUS_VALUES);
export const buildPersonalStyleOptions = (t: TranslateFn) =>
buildOptions(t, "personalStyleOptions", PERSONAL_STYLE_VALUES);
export const buildSmokingStatusOptions = (t: TranslateFn) =>
buildOptions(t, "smokingStatusOptions", SMOKING_STATUS_VALUES);
export const buildIncomeRangeOptions = (t: TranslateFn) =>
buildOptions(t, "incomeRangeOptions", INCOME_RANGE_VALUES);
/** یک دسته‌ی بازه‌ی عددی برای دراپ‌داون‌های قد/وزن/سن در فیلتر «یافتن پارتنر»:
* value منحصربه‌فرد برای <select>، min/max برای اِعمال روی فیلتر واقعی (خالی
* یعنی نامحدود از آن سمت). گزینه‌ی ویژه‌ی "custom" (بازه‌ی انتخابی) در پایان هر
* لیست باعث می‌شود دو ورودی حداقل/حداکثر دستی نمایش داده شوند. */
export type RangeBucket = { value: string; min?: number; max?: number };
function buildRangeBucketLabel(t: TranslateFn, bucket: RangeBucket): string {
if (bucket.value === "not_important") {
return t("filters.notImportant");
}
if (bucket.value === "custom") {
return t("filters.customRange");
}
if (bucket.min !== undefined && bucket.max !== undefined) {
return t("filters.rangeValue", { min: bucket.min, max: bucket.max });
}
if (bucket.max !== undefined) {
return t("filters.underValue", { value: bucket.max + 1 });
}
if (bucket.min !== undefined) {
return t("filters.overValue", { value: bucket.min - 1 });
}
return bucket.value;
}
export function buildRangeBucketOptions(t: TranslateFn, buckets: RangeBucket[]): LabeledOption[] {
return buckets.map((bucket) => ({ value: bucket.value, label: buildRangeBucketLabel(t, bucket) }));
}
export const HEIGHT_BUCKETS: RangeBucket[] = [
{ value: "under_160", max: 159 },
{ value: "160_165", min: 160, max: 165 },
{ value: "165_170", min: 165, max: 170 },
{ value: "170_175", min: 170, max: 175 },
{ value: "175_180", min: 175, max: 180 },
{ value: "180_185", min: 180, max: 185 },
{ value: "185_190", min: 185, max: 190 },
{ value: "190_195", min: 190, max: 195 },
{ value: "over_195", min: 196 },
{ value: "not_important" },
{ value: "custom" },
];
export const WEIGHT_BUCKETS: RangeBucket[] = [
{ value: "40_45", min: 40, max: 45 },
{ value: "45_50", min: 45, max: 50 },
{ value: "50_55", min: 50, max: 55 },
{ value: "55_60", min: 55, max: 60 },
{ value: "60_65", min: 60, max: 65 },
{ value: "65_70", min: 65, max: 70 },
{ value: "70_75", min: 70, max: 75 },
{ value: "75_80", min: 75, max: 80 },
{ value: "80_85", min: 80, max: 85 },
{ value: "85_90", min: 85, max: 90 },
{ value: "90_95", min: 90, max: 95 },
{ value: "over_95", min: 96 },
{ value: "not_important" },
{ value: "custom" },
];
export const AGE_BUCKETS: RangeBucket[] = [
{ value: "18_24", min: 18, max: 24 },
{ value: "25_30", min: 25, max: 30 },
{ value: "31_35", min: 31, max: 35 },
{ value: "36_40", min: 36, max: 40 },
{ value: "41_50", min: 41, max: 50 },
{ value: "over_50", min: 51 },
{ value: "not_important" },
{ value: "custom" },
];
/** سایز، برخلاف قد/وزن/سن، بازه نیست — هر گزینه یک مقدار دقیق است (min=max)،
* عیناً سایزبندی لباس/کفشِ استفاده‌شده در این اپ. */
export const SIZE_BUCKETS: RangeBucket[] = [
34, 36, 38, 40, 42, 44, 46, 48, 50, 52, 54, 56, 58, 60, 62, 64, 66, 68, 70,
].map((n) => ({ value: String(n), min: n, max: n }));
export const SIZE_BUCKETS_WITH_EXTRAS: RangeBucket[] = [
...SIZE_BUCKETS,
{ value: "not_important" },
{ value: "custom" },
];
export function buildSizeBucketOptions(t: TranslateFn): LabeledOption[] {
return SIZE_BUCKETS_WITH_EXTRAS.map((bucket) => ({
value: bucket.value,
label:
bucket.value === "not_important"
? t("filters.notImportant")
: bucket.value === "custom"
? t("filters.customRange")
: bucket.value,
}));
}
/** برای درج n آیتم آرایه‌ای در URLSearchParams طبق قرارداد بک‌اند (مقدار جدا شده با کاما). */
export function joinFilterValues(values: string[] | undefined | null): string | undefined {
if (!values || values.length === 0) return undefined;

View File

@@ -8,10 +8,6 @@ export const PWA_THEME_COLOR = "#FF107D";
export const PWA_BACKGROUND_COLOR = "#FF107D";
export const PWA_ID = "modstagram-pwa";
export const PWA_VERSION = "12";
// نسخه‌ی فعلیِ APK اندروید — با هر انتشار نسخه‌ی جدید (و آپلود APK جدید توی
// storage/downloads/modstagram.apk) این مقدار هم دستی به‌روز بشه؛ همون versionName
// توی android/app/build.gradle
export const ANDROID_APP_VERSION = "2.1.2";
export { resolvePwaLang, toPwaLang } from "@/lib/i18n/pwaLang";

View File

@@ -320,7 +320,15 @@
"offer": "طلب تعاون",
"advertising": "إعلانات",
"shop_order": "شراء من المتجر",
"other": "أخرى"
"other": "أخرى",
"advertising-republish": "إعادة نشر الإعلان",
"create": "إنشاء مشروع",
"installment": "قسط المشروع",
"gift": "هدية",
"booking_deposit": "عربون الحجز",
"wallet_withdrawal": "سحب من المحفظة",
"pro": "شراء دورة Pro",
"course": "شراء دورة"
},
"statuses": {
"successful": "مدفوع",
@@ -416,7 +424,8 @@
"archive": "الأرشيف",
"activity": "نشاطك",
"bio": "النبذة التعريفية",
"verificationGuide": "الحصول على علامة التوثيق"
"verificationGuide": "الحصول على علامة التوثيق",
"personalDetails": "المعلومات الشخصية"
},
"bio": {
"aboutYou": "أخبرنا عن نفسك",
@@ -513,7 +522,8 @@
"title": "المنطقة",
"hint": "اختر دولتك ومحافظتك ومدينتك",
"selectedLabel": "الدولة المختارة: {{country}}",
"saveSuccess": "تم حفظ المنطقة"
"saveSuccess": "تم حفظ المنطقة",
"missingHint": "لتحديد المحافظة والمدينة، يُرجى تحديد بلدك أولًا من إعدادات المستخدم"
},
"analyticsHub": {
"comingSoon": "قريبًا",
@@ -679,6 +689,179 @@
"uploadHint": "ارفع ترخيصك للحصول على الشارة الذهبية",
"submitContinue": "إرسال ومتابعة",
"reviewTime": "تستغرق مراجعة المستندات من 5 دقائق إلى ساعة خلال أوقات العمل، أو من 3 إلى 8 ساعات خارج أوقات العمل"
},
"personalDetails": {
"title": "المعلومات الشخصية",
"hint": "تُعرض هذه المعلومات للآخرين فقط ضمن نتائج «العثور على شريك».",
"age": "العمر",
"ageYears": "{{age}} سنة",
"maritalStatus": "الحالة الاجتماعية",
"maritalStatusSingle": "أعزب",
"maritalStatusDivorced": "مطلّق",
"residenceType": "نوع السكن",
"residenceTypeOwner": "مالك",
"residenceTypeTenant": "مستأجر",
"residenceArea": "منطقة السكن",
"residenceAreaPlaceholder": "اسم الحي",
"vehicleType": "وسيلة النقل",
"vehicleTypeNone": "لا أملك",
"vehicleTypeCar": "سيارة",
"vehicleTypeMotorcycle": "دراجة نارية",
"vehicleDetailsPlaceholder": "النوع والموديل",
"personalStyle": "الأسلوب الشخصي",
"personalStyleFormal": "رسمي",
"personalStyleSporty": "رياضي",
"job": "الوظيفة",
"jobPlaceholder": "اكتب وظيفتك",
"incomeRange": "الدخل",
"incomeUnder5m": "أقل من 5 مليون تومان",
"income5m10m": "5 إلى 10 مليون تومان",
"income10m20m": "10 إلى 20 مليون تومان",
"incomeOver20m": "أكثر من 20 مليون تومان",
"incomeRangeOptions": {
"under_40m": "أقل من 40 مليون تومان",
"40m_50m": "40 إلى 50 مليون تومان",
"50m_60m": "50 إلى 60 مليون تومان",
"60m_70m": "60 إلى 70 مليون تومان",
"70m_80m": "70 إلى 80 مليون تومان",
"80m_90m": "80 إلى 90 مليون تومان",
"90m_100m": "90 إلى 100 مليون تومان",
"100m_150m": "100 إلى 150 مليون تومان",
"150m_200m": "150 إلى 200 مليون تومان",
"over_200m": "أكثر من 200 مليون تومان"
},
"moralTraits": "الصفات الأخلاقية",
"moralTraitsPlaceholder": "اكتب صفاتك الأخلاقية",
"maritalStatusOptions": {
"widowed": "أرمل",
"not_in_relationship": "لست في علاقة",
"prefer_not_to_say": "أفضّل عدم القول"
},
"relationshipGoal": "الهدف من العلاقة",
"relationshipGoalOptions": {
"friendship": "صداقة",
"serious_partner": "بحث جاد عن شريك",
"marriage_intro": "تعارف بهدف الزواج",
"emotional_relationship": "علاقة عاطفية",
"travel_event_companion": "رفيق سفر / مناسبات",
"social_networking": "التواصل الاجتماعي والمعاشرة",
"not_sure": "لم أقرر بعد"
},
"seekingGender": "أبحث عن",
"seekingGenderOptions": {
"female": "امرأة",
"male": "رجل",
"both": "كلاهما",
"not_important": "غير مهم"
},
"desiredAgeRange": "الفئة العمرية المطلوبة",
"desiredAgeRangeOptions": {
"18_24": "18–24",
"25_30": "25–30",
"31_35": "31–35",
"36_40": "36–40",
"41_50": "41–50",
"over_50": "أكثر من 50",
"not_important": "غير مهم"
},
"personalityTraits": "السمات الشخصية",
"personalityTraitsOptions": {
"kind": "طيب",
"loyal": "مخلص",
"funny": "مرح",
"calm": "هادئ",
"energetic": "نشيط",
"responsible": "مسؤول",
"ambitious": "طموح",
"emotional": "عاطفي",
"logical": "منطقي",
"patient": "صبور",
"honest": "صادق",
"romantic": "رومانسي",
"independent": "مستقل",
"family_oriented": "محب للعائلة",
"adventurous": "مغامر",
"introvert": "انطوائي",
"extrovert": "انبساطي"
},
"lifestyle": "أسلوب الحياة",
"lifestyleOptions": {
"active_healthy": "نشيط وصحي",
"homebody_calm": "هادئ ومحب للمنزل",
"social_outgoing": "اجتماعي ومنفتح",
"busy_career": "مهني ومشغول",
"traveler": "محب للسفر",
"night_owl": "ساهر ليلًا",
"early_riser": "يستيقظ باكرًا",
"minimalist": "بسيط (مينيمال)",
"luxury_lover": "محب للفخامة"
},
"interests": "الاهتمامات",
"interestsOptions": {
"movies_series": "أفلام ومسلسلات",
"music": "موسيقى",
"books_reading": "كتب وقراءة",
"sports": "رياضة",
"cooking": "طبخ",
"photography": "تصوير فوتوغرافي",
"travel": "سفر",
"nature": "استكشاف الطبيعة",
"gaming": "ألعاب فيديو",
"art_painting": "فن ورسم",
"fashion": "موضة وأزياء",
"pets": "حيوانات أليفة",
"dancing": "رقص",
"language_learning": "تعلّم اللغات"
},
"values": "القيم",
"valuesOptions": {
"family": "العائلة",
"career_growth": "التقدم المهني",
"religion_spirituality": "الدين والروحانية",
"personal_freedom": "الحرية الشخصية",
"financial_stability": "الاستقرار المالي",
"health_fitness": "الصحة واللياقة",
"personal_growth": "النمو الشخصي",
"excitement_new_experiences": "الإثارة والتجارب الجديدة"
},
"relationshipView": "النظرة إلى العلاقة",
"relationshipViewOptions": {
"traditional": "علاقة تقليدية",
"modern": "علاقة عصرية",
"long_term": "علاقة طويلة الأمد",
"friendship_first": "صداقة أولًا ثم القرار",
"marriage_soon": "زواج في المستقبل القريب",
"friendship_only_for_now": "صداقة فقط حاليًا"
},
"geoPreference": "النطاق الجغرافي المفضّل",
"geoPreferenceOptions": {
"same_city_only": "مدينتي فقط",
"nearby_cities": "المدن القريبة",
"whole_province": "المحافظة بأكملها",
"whole_iran": "جميع أنحاء إيران",
"even_abroad": "حتى خارج البلاد",
"online_is_fine": "التواصل عبر الإنترنت مناسب أيضًا"
},
"childrenStatus": "وضع الأطفال",
"childrenStatusOptions": {
"no_children": "لا أطفال لدي",
"has_children": "لدي أطفال",
"open_to_children": "منفتح على إنجاب أطفال",
"not_now": "لا أريد أطفالًا حاليًا",
"prefer_not_to_say": "أفضّل عدم القول"
},
"smokingStatus": "التدخين",
"smokingStatusOptions": {
"none": "لا أدخن",
"sometimes": "أحيانًا",
"yes": "أدخن",
"doesnt_matter": "لا يهمني إن كان الطرف الآخر يدخن",
"prefer_not_to_say": "أفضّل عدم القول"
},
"selectPlaceholder": "اختر",
"multiSelectSelectedCount": "تم اختيار {{count}}",
"multiSelectMaxHint": "يمكنك اختيار ما يصل إلى {{max}}",
"saveSuccess": "تم حفظ المعلومات الشخصية بنجاح"
}
},
"academy": {
@@ -939,7 +1122,8 @@
"addService": "إضافة",
"minOneService": "الرجاء إضافة خدمة واحدة على الأقل.",
"servicesSavedSuccess": "تم حفظ الخدمات بنجاح!",
"servicesSaveError": "تعذر حفظ الخدمات!"
"servicesSaveError": "تعذر حفظ الخدمات!",
"selectPrimarySubExpertise": "ما هو تخصصك الرئيسي؟"
},
"posts": {
"loadError": "حدث خطأ ما",
@@ -1127,7 +1311,9 @@
"pickFromGallery": "اختر من المعرض",
"captionPlaceholder": "اكتب وصفًا (اختياري)…",
"storyExpiryHint": "ستكون قصتك مرئية لمدة 24 ساعة، ثم تُحذف تلقائيًا.",
"bookmarkError": "تعذر حفظ المنشور"
"bookmarkError": "تعذر حفظ المنشور",
"suggestedCreatorsSwipeHint": "يمكنك التمرير للتنقل بين المستخدمين",
"aspectRatioTooExtreme": "هذه الصورة ممدودة جدًا (أكثر من نسبة 1:4) — اختر صورة أخرى"
},
"stories": {
"yourStory": "قصتك",
@@ -1180,7 +1366,28 @@
"confirm": "تأكيد",
"timeNow": "الآن",
"timeHours": "{{hours}} س",
"timeYesterday": "أمس"
"timeYesterday": "أمس",
"fontFamily": "الخط",
"mentionSomeone": "الإشارة إلى شخص",
"addHashtag": "إضافة هاشتاغ",
"addLocation": "إضافة الموقع",
"hashtagPlaceholder": "بدون #، مثلاً Modstagram",
"locationPlaceholder": "اسم المكان...",
"mention": "إشارة",
"hashtag": "هاشتاغ",
"location": "الموقع",
"sticker": "ملصق",
"addSticker": "إضافة ملصق",
"camera": "الكاميرا",
"cameraAccessDenied": "تم رفض الوصول إلى الكاميرا",
"capturePhoto": "التقاط صورة",
"fontFamilyOptions": {
"light": "رفيع",
"regular": "عادي",
"medium": "متوسط",
"bold": "عريض",
"black": "عريض جدًا"
}
},
"searchPage": {
"placeholder": "بحث",
@@ -1246,7 +1453,9 @@
"title": "تواصل مع Modstagram",
"emailLabel": "البريد الإلكتروني:",
"phoneLabel": "الهاتف:",
"sendTicket": "إرسال تذكرة"
"sendTicket": "إرسال تذكرة",
"downloadApp": "تحميل التطبيق",
"appVersion": "الإصدار {{version}}"
},
"offerPage": {
"paymentTitle": "الدفع",
@@ -1385,7 +1594,8 @@
"academy": "الأكاديمية",
"billboard": "لوحة إعلانية",
"ticket": "تذكرة",
"notification": "إشعار"
"notification": "إشعار",
"partnerMatch": "شريك مقترح"
}
},
"academySidebar": {
@@ -1554,7 +1764,8 @@
"best_month": "الأفضل هذا الشهر",
"near_me": "بالقرب مني",
"girls": "بناتي",
"boys": "ولادي"
"boys": "ولادي",
"top_hashtags": "أفضل الهاشتاغات هذا الشهر"
},
"empty": {
"default": "لا يوجد شيء لعرضه بعد.",
@@ -1565,7 +1776,8 @@
"predict_trends": "لم يُعثر على منشورات صاعدة بعد.",
"best_month": "لم يُعثر على أفضل المنشورات هذا الشهر.",
"hashtag": "لا توجد منشورات بوسم #{{tag}}.",
"query": "لا توجد نتائج لـ «{{query}}»."
"query": "لا توجد نتائج لـ «{{query}}».",
"top_hashtags": "لا توجد هاشتاغات رائجة بعد"
}
},
"reels": {
@@ -1603,7 +1815,10 @@
"refresh": "تحديث",
"distanceMeters": "على بُعد نحو {{count}} م",
"distanceKm": "على بُعد نحو {{count}} كم",
"you": "أنت"
"you": "أنت",
"radiusHintValue": "عرض المستخدمين ضمن نطاق {{km}} كم",
"showOnMap": "إظهار على الخريطة",
"mapPrivacyHint": "لأسباب تتعلق بالخصوصية، تكون مواقع المستخدمين الآخرين على الخريطة تقريبية وليست دقيقة"
},
"chats": {
"title": "الرسائل",
@@ -1789,7 +2004,11 @@
"photosSelected": "تم اختيار {{count}} صورة",
"videoPreview": "معاينة الفيديو",
"filePreview": "معاينة الملف",
"fileSizeMb": "{{size}} ميغابايت"
"fileSizeMb": "{{size}} ميغابايت",
"membersTitle": "أعضاء المجموعة",
"addMemberRow": "إضافة عضو",
"noMembers": "لم يُعثر على أعضاء",
"memberFallbackName": "مستخدم"
},
"location": {
"title": "الموقع",
@@ -1838,7 +2057,23 @@
"copySuccess": "تم نسخ نص الرسالة",
"copyFailed": "تعذر نسخ نص الرسالة",
"copyEmpty": "لا يوجد نص لنسخه في هذه الرسالة"
}
},
"e2ee": {
"recoveryTitle": "استعادة تشفير المحادثات",
"recoveryHasBackupDesc": "لم تنجح الاستعادة التلقائية لمفتاح التشفير السابق على هذا الجهاز. حاول مرة أخرى، أو أدخل رمز الاستعادة اليدوي إذا كنت قد أعددت واحدًا من قبل.",
"recoveryNoBackupDesc": "لا يملك هذا الجهاز إمكانية الوصول إلى مفتاح التشفير السابق، ولم تُحفظ أي نسخة احتياطية. يمكنك ربط هذا الجهاز بجهاز آخر مسجَّل دخوله بالفعل، أو المتابعة بهوية جديدة (ستصبح الرسائل المشفَّرة القديمة غير قابلة للقراءة نهائيًا).",
"retryButton": "إعادة المحاولة",
"pinPlaceholder": "رمز الاستعادة اليدوي (إن وُجد)",
"recoverButton": "استعادة بالرمز اليدوي",
"wrongPin": "الرمز غير صحيح أو فشلت الاستعادة",
"startFreshLink": "لا أملك نسخة احتياطية، إنشاء هوية جديدة",
"confirmFreshWarning": "سيؤدي هذا إلى جعل رسائلك المشفَّرة القديمة غير قابلة للاستعادة نهائيًا. هل أنت متأكد؟"
},
"findPartner": {
"title": "العثور على شريك",
"desc": "البحث عن المستخدمين بحسب معلوماتهم الشخصية"
},
"groupStatusLine": "محادثة جماعية · {{count}} متصل"
},
"filters": {
"title": "الترتيب والتصفية",
@@ -1888,7 +2123,8 @@
"shop": "المتجر",
"newProject": "مشروع جديد",
"search": "بحث"
}
},
"country": "الدولة"
},
"projects": {
"empty": "لا توجد مشاريع لعرضها.",
@@ -2049,6 +2285,12 @@
"workHoursRequired": "ساعات العمل مطلوبة",
"collaborationRequired": "نوع التعاون مطلوب"
}
},
"specialistCarousel": {
"title": "متخصصون مقترحون",
"description": "تعرّف على أفضل المواهب المطابقة لمرشّحاتك",
"action": "متابعة",
"actionDone": "متابَع"
}
},
"billboards": {
@@ -2839,5 +3081,25 @@
"chargeBonusHint": "عند شحن محفظتك، تحصل على هدية إضافية بنسبة 5%",
"chargeAmountPlaceholder": "مبلغ الشحن (تومان)",
"chargeWallet": "شحن المحفظة"
},
"findPartner": {
"title": "العثور على شريك",
"desc": "البحث عن المستخدمين بحسب معلوماتهم الشخصية",
"hint": "يساعدك هذا القسم على إيجاد شخص يطابق المواصفات التي تبحث عنها.",
"filterButton": "التصفية",
"modalTitle": "تصفية نتائج العثور على شريك",
"resultsFound": "تم العثور على {{count}} شخص",
"loading": "جارٍ البحث…",
"loadError": "تعذر تحميل النتائج.",
"empty": "لم يُعثر على أحد بهذه المواصفات بعد.",
"emptyHint": "يمكنك حفظ هذا البحث وسنُعلمك فور العثور على شخص مطابق.",
"saveSearch": "حفظ البحث وتفعيل الإشعارات",
"updateSearch": "تحديث البحث المحفوظ",
"cancelSavedSearch": "إلغاء الإشعارات",
"savedSearchActive": "نُعلمك حاليًا بنتائج هذا البحث المحفوظ",
"searchSaved": "تم حفظ بحثك",
"searchCancelled": "تم إلغاء الإشعارات",
"viewProfile": "عرض الملف الشخصي",
"ageYears": "{{count}} سنة"
}
}

View File

@@ -678,8 +678,6 @@
"vehicleTypeMotorcycle": "Motorcycle",
"vehicleDetailsPlaceholder": "Make and model",
"personalStyle": "Style",
"personalStyleFormal": "Formal",
"personalStyleSporty": "Sporty",
"job": "Job",
"jobPlaceholder": "Describe your job",
"incomeRange": "Income",
@@ -699,9 +697,144 @@
"150m_200m": "150M–200M Toman",
"over_200m": "Over 200M Toman"
},
"moralTraits": "Personal qualities",
"moralTraitsPlaceholder": "Describe your personal qualities",
"saveSuccess": "Personal details saved successfully"
"maritalStatusOptions": {
"widowed": "Widowed",
"not_in_relationship": "Not in a relationship",
"prefer_not_to_say": "Prefer not to say"
},
"relationshipGoal": "Relationship goal",
"relationshipGoalOptions": {
"friendship": "Friendship",
"serious_partner": "Serious partner search",
"marriage_intro": "Introduction for marriage",
"emotional_relationship": "Emotional relationship",
"travel_event_companion": "Travel/event companion",
"social_networking": "Social networking",
"not_sure": "Not sure yet"
},
"seekingGender": "Looking for",
"seekingGenderOptions": {
"female": "Female",
"male": "Male",
"both": "Both",
"not_important": "Doesn't matter"
},
"desiredAgeRange": "Desired age range",
"desiredAgeRangeOptions": {
"18_24": "18–24",
"25_30": "25–30",
"31_35": "31–35",
"36_40": "36–40",
"41_50": "41–50",
"over_50": "Over 50",
"not_important": "Doesn't matter"
},
"personalityTraits": "Personality traits",
"personalityTraitsOptions": {
"kind": "Kind",
"loyal": "Loyal",
"funny": "Funny",
"calm": "Calm",
"energetic": "Energetic",
"responsible": "Responsible",
"ambitious": "Ambitious",
"emotional": "Emotional",
"logical": "Logical",
"patient": "Patient",
"honest": "Honest",
"romantic": "Romantic",
"independent": "Independent",
"family_oriented": "Family-oriented",
"adventurous": "Adventurous",
"introvert": "Introvert",
"extrovert": "Extrovert"
},
"lifestyle": "Lifestyle",
"lifestyleOptions": {
"active_healthy": "Active & healthy",
"homebody_calm": "Homebody & calm",
"social_outgoing": "Social & outgoing",
"busy_career": "Career-focused & busy",
"traveler": "Traveler",
"night_owl": "Night owl",
"early_riser": "Early riser",
"minimalist": "Minimalist",
"luxury_lover": "Loves luxury"
},
"interests": "Interests",
"interestsOptions": {
"movies_series": "Movies & series",
"music": "Music",
"books_reading": "Books & reading",
"sports": "Sports",
"cooking": "Cooking",
"photography": "Photography",
"travel": "Travel",
"nature": "Nature",
"gaming": "Gaming",
"art_painting": "Art & painting",
"fashion": "Fashion",
"pets": "Pets",
"dancing": "Dancing",
"language_learning": "Language learning"
},
"values": "Values",
"valuesOptions": {
"family": "Family",
"career_growth": "Career growth",
"religion_spirituality": "Religion & spirituality",
"personal_freedom": "Personal freedom",
"financial_stability": "Financial stability",
"health_fitness": "Health & fitness",
"personal_growth": "Personal growth",
"excitement_new_experiences": "Excitement & new experiences"
},
"relationshipView": "View on relationships",
"relationshipViewOptions": {
"traditional": "Traditional relationship",
"modern": "Modern relationship",
"long_term": "Long-term relationship",
"friendship_first": "Friendship first, then decide",
"marriage_soon": "Marriage in the near future",
"friendship_only_for_now": "Friendship only for now"
},
"geoPreference": "Preferred geographic range",
"geoPreferenceOptions": {
"same_city_only": "My city only",
"nearby_cities": "Nearby cities",
"whole_province": "Entire province",
"whole_iran": "Anywhere in Iran",
"even_abroad": "Even abroad",
"online_is_fine": "Online is fine too"
},
"childrenStatus": "Children status",
"childrenStatusOptions": {
"no_children": "No children",
"has_children": "Has children",
"open_to_children": "Open to having children",
"not_now": "Not right now",
"prefer_not_to_say": "Prefer not to say"
},
"smokingStatus": "Smoking status",
"smokingStatusOptions": {
"none": "Don't smoke",
"sometimes": "Sometimes",
"yes": "Smoke",
"doesnt_matter": "Doesn't matter to me",
"prefer_not_to_say": "Prefer not to say"
},
"selectPlaceholder": "Select",
"multiSelectSelectedCount": "{{count}} selected",
"multiSelectMaxHint": "You can select up to {{max}}",
"saveSuccess": "Personal details saved successfully",
"personalStyleOptions": {
"casual": "Casual",
"classic": "Classic",
"minimal": "Minimal",
"formal": "Formal",
"street": "Street",
"romantic": "Romantic"
}
},
"avatar": {
"required": "Please select a profile photo",
@@ -1076,11 +1209,11 @@
"other": "Other"
},
"reportReasonValues": {
"inappropriate": "محتوای نامناسب",
"spam": "اسپم",
"fraud": "کلاهبرداری",
"harassment": "آزار",
"other": "سایر"
"inappropriate": "Inappropriate content",
"spam": "Spam",
"fraud": "Fraud",
"harassment": "Harassment",
"other": "Other"
},
"sharedToStory": "Post added to your story",
"shareToStoryError": "Could not share to story",
@@ -1691,11 +1824,11 @@
"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.",
"title": "Find a Partner",
"desc": "Search users by personal details — for friendship, dating, or collaboration",
"hint": "Use this to find someone matching what you're looking for — friendship and dating, or collaboration.",
"filterButton": "Filters",
"modalTitle": "Find-a-partner filters",
"modalTitle": "Find a Partner filters",
"resultsFound": "{{count}} people found",
"loading": "Searching…",
"loadError": "Could not load results.",
@@ -2014,7 +2147,12 @@
"shop": "Shop",
"newProject": "New project",
"search": "Search"
}
},
"notImportant": "Doesn't matter",
"customRange": "Custom range",
"rangeValue": "{{min}}–{{max}}",
"underValue": "Under {{value}}",
"overValue": "Over {{value}}"
},
"projects": {
"empty": "No projects to show.",
@@ -2044,6 +2182,12 @@
"resultsHint": "Users matching your filters",
"editFilter": "Edit filters"
},
"specialistCarousel": {
"title": "Suggested specialists",
"description": "Meet top talent matching your filters",
"action": "Follow",
"actionDone": "Following"
},
"portfolio": {
"uploadTitle": "Portfolio",
"required": "At least one portfolio file is required",
@@ -2179,7 +2323,12 @@
},
"billboards": {
"search": "Search",
"filter": "Filter",
"filter": {
"sortTitle": "Sort & filter",
"mostDiscount": "Highest discount",
"apply": "Apply filter",
"clear": "Clear"
},
"createAria": "Create billboard",
"loading": "Loading billboards…",
"notFound": "Billboard not found!",
@@ -2231,12 +2380,6 @@
"registeredMessage": "Your ad has been submitted to the billboard. It will be published after review.",
"myBillboards": "My billboards"
},
"filter": {
"sortTitle": "Sort & filter",
"mostDiscount": "Highest discount",
"apply": "Apply filter",
"clear": "Clear"
},
"details": {
"servicesList": "Services & products",
"features": "Other amenities",
@@ -2532,7 +2675,8 @@
"commentAria": "Comment",
"likeAria": "Like",
"favoriteRemoveAria": "Remove from saved",
"favoriteAria": "Save"
"favoriteAria": "Save",
"personalDetails": "Personal Details"
},
"shareProfile": {
"title": "Share profile",
@@ -2547,7 +2691,8 @@
"profile": {
"userNotFound": "User not found!",
"unavailable": "This profile is unavailable",
"blockedByUser": "You have been blocked by this user and cannot view their profile or posts."
"blockedByUser": "You have been blocked by this user and cannot view their profile or posts.",
"personalDetailsEmpty": "This user hasn't added any personal details yet."
},
"home": {
"Classification": "Category",
@@ -2904,7 +3049,6 @@
"orderCancelled": "Order cancelled and the amount was refunded to your wallet",
"shopNotFoundTitle": "Shop not found",
"productNotFoundTitle": "Product not found",
"inPerson": "In person",
"online": "Online",
"receiptConfirmed": "Receipt confirmed",
"rateShop": "Rate & review",

View File

@@ -678,8 +678,6 @@
"vehicleTypeMotorcycle": "موتور سیکلت",
"vehicleDetailsPlaceholder": "نوع و مدل",
"personalStyle": "استایل",
"personalStyleFormal": "رسمی",
"personalStyleSporty": "اسپرت",
"job": "شغل",
"jobPlaceholder": "شغل خود را بنویسید",
"incomeRange": "درآمد",
@@ -699,8 +697,6 @@
"150m_200m": "۱۵۰ تا ۲۰۰ میلیون تومان",
"over_200m": "بیشتر از ۲۰۰ میلیون تومان"
},
"moralTraits": "خصوصیات اخلاقی",
"moralTraitsPlaceholder": "خصوصیات اخلاقی خود را بنویسید",
"maritalStatusOptions": {
"widowed": "بیوه",
"not_in_relationship": "در رابطه نیستم",
@@ -830,7 +826,15 @@
"selectPlaceholder": "انتخاب کنید",
"multiSelectSelectedCount": "{{count}} مورد انتخاب شده",
"multiSelectMaxHint": "حداکثر {{max}} مورد قابل انتخاب است",
"saveSuccess": "مشخصات فردی با موفقیت ذخیره شد"
"saveSuccess": "مشخصات فردی با موفقیت ذخیره شد",
"personalStyleOptions": {
"casual": "کژوال",
"classic": "کلاسیک",
"minimal": "مینیمال",
"formal": "رسمی",
"street": "خیابانی",
"romantic": "رمانتیک"
}
},
"avatar": {
"required": "لطفا تصویر پروفایل را انتخاب کنید",
@@ -1820,11 +1824,11 @@
"you": "شما"
},
"findPartner": {
"title": "یافتن همکار",
"desc": "جستجوی کاربران بر اساس مشخصات فردی",
"hint": "این بخش برای یافتن فردی با مشخصات مورد نظر شماست.",
"title": "یافتن پارتنر",
"desc": "جستجوی کاربران بر اساس مشخصات فردی برای دوستی، رابطه یا همکاری",
"hint": "این بخش برای یافتن فردی با مشخصات مورد نظر شماست؛ چه برای دوستی و رابطه، چه برای همکاری.",
"filterButton": "فیلترها",
"modalTitle": "فیلتر یافتن همکار",
"modalTitle": "فیلتر یافتن پارتنر",
"resultsFound": "{{count}} نفر پیدا شد",
"loading": "در حال جستجو…",
"loadError": "بارگذاری نتایج انجام نشد.",
@@ -2142,7 +2146,12 @@
"shop": "فروشگاه",
"newProject": "ثبت پروژه",
"search": "جستجو"
}
},
"notImportant": "برام مهم نیست",
"customRange": "بازه انتخابی",
"rangeValue": "{{min}} تا {{max}}",
"underValue": "زیر {{value}}",
"overValue": "بالای {{value}}"
},
"projects": {
"empty": "پروژه‌ای برای نمایش وجود ندارد.",
@@ -2313,7 +2322,12 @@
},
"billboards": {
"search": "جستجو",
"filter": "فیلتر",
"filter": {
"sortTitle": "مرتب سازی",
"mostDiscount": "بیشترین تخفیف",
"apply": "اعمال فیلتر",
"clear": "پاک کردن"
},
"createAria": "ثبت بیلبورد",
"loading": "در حال بارگذاری بیلبوردها…",
"notFound": "بیلبورد یافت نشد!",
@@ -2365,12 +2379,6 @@
"registeredMessage": "آگهی شما در بیلبورد ثبت شد. پس از بررسی آگهی شما در بیلبورد منتشر خواهد شد.",
"myBillboards": "بیلبورد من"
},
"filter": {
"sortTitle": "مرتب سازی",
"mostDiscount": "بیشترین تخفیف",
"apply": "اعمال فیلتر",
"clear": "پاک کردن"
},
"details": {
"servicesList": "لیست خدمات و کالا",
"features": "سایر امکانات",
@@ -2666,7 +2674,8 @@
"commentAria": "کامنت",
"likeAria": "لایک",
"favoriteRemoveAria": "حذف از علاقمندی‌ها",
"favoriteAria": "علاقمندی"
"favoriteAria": "علاقمندی",
"personalDetails": "مشخصات فردی"
},
"shareProfile": {
"title": "اشتراک نمایه",
@@ -2681,7 +2690,8 @@
"profile": {
"userNotFound": "کاربر یافت نشد!",
"unavailable": "این پروفایل در دسترس نیست",
"blockedByUser": "شما توسط این کاربر مسدود شده‌اید و امکان مشاهده پروفایل و پست‌های او وجود ندارد."
"blockedByUser": "شما توسط این کاربر مسدود شده‌اید و امکان مشاهده پروفایل و پست‌های او وجود ندارد.",
"personalDetailsEmpty": "کاربر هنوز مشخصات فردی‌ای ثبت نکرده است."
},
"home": {
"Classification": "دسته‌بندی",
@@ -3038,7 +3048,6 @@
"orderCancelled": "سفارش کنسل شد و مبلغ به کیف پول شما بازگشت داده شد",
"shopNotFoundTitle": "فروشگاه یافت نشد",
"productNotFoundTitle": "کالا یافت نشد",
"inPerson": "حضوری",
"online": "اینترنتی",
"receiptConfirmed": "دریافت کالا ثبت شد",
"rateShop": "ثبت نظر و امتیاز",

File diff suppressed because it is too large Load Diff

View File

@@ -298,7 +298,10 @@ export interface User {
residence_area?: string | null;
vehicle_type?: "none" | "car" | "motorcycle" | null;
vehicle_details?: string | null;
personal_style?: "formal" | "sporty" | null;
personal_style?: "casual" | "classic" | "minimal" | "formal" | "street" | "romantic" | null;
// فقط وقتی خودِ کاربر تیک «نمایش مشخصات من برای عموم» رو زده باشه، فیلدهای مشخصاتِ
// فردیِ بالا/پایین برای بازدیدکننده‌های دیگه از بک‌اند پر می‌شن (وگرنه undefined می‌مونن)
personal_details_public?: boolean;
job?: string | null;
income_range?:
| "under_40m"
@@ -312,7 +315,6 @@ export interface User {
| "150m_200m"
| "over_200m"
| null;
moral_traits?: string | null;
relationship_goal?: string[] | null;
seeking_gender?: "female" | "male" | "both" | "not_important" | null;
desired_age_range?:
@@ -345,11 +347,7 @@ export interface User {
| "online_is_fine"
| null;
children_status?:
| "no_children"
| "has_children"
| "open_to_children"
| "not_now"
| "prefer_not_to_say"
| ("no_children" | "has_children" | "open_to_children" | "not_now" | "prefer_not_to_say")[]
| null;
smoking_status?:
| "none"