secret chat
This commit is contained in:
@@ -83,10 +83,6 @@ export function buildAcademyListSeo(
|
||||
) {
|
||||
const academyPage = getSeoPage("academy", lang);
|
||||
const templates = getListSeoTemplates(lang).academy as Record<string, string>;
|
||||
const sortLabels = getListSeoTemplates(lang).academy.sort as Record<
|
||||
string,
|
||||
string
|
||||
>;
|
||||
|
||||
const hasFilters = Boolean(
|
||||
filters.category ||
|
||||
|
||||
@@ -107,9 +107,20 @@ export async function decryptDmContent(
|
||||
if (!isE2eeEnvelope(raw)) return raw;
|
||||
|
||||
const key = await getDmConversationKey(myUserId, peerUserId);
|
||||
if (!key) return E2EE_LOCKED_PREVIEW;
|
||||
if (key) {
|
||||
const plain = await decryptText(key, raw);
|
||||
if (plain != null) return plain;
|
||||
}
|
||||
|
||||
const plain = await decryptText(key, raw);
|
||||
// شکستِ رمزگشایی با کلیدِ کششده (یا نبودِ کلید) میتونه یعنی طرفِ مقابل وسطِ سشن
|
||||
// هویتش رو عوض کرده (دستگاه جدید/بازیابی) و ما هنوز کلیدِ عمومیِ قدیمیشون رو کش
|
||||
// کرده بودیم — قبل از نمایشِ «پیام رمزنگاریشده»، یهبار با کلیدِ عمومیِ کاملاً
|
||||
// تازهواکشیشده دوباره امتحان کن (forceFresh)؛ این باعث میشه بعد از اینجور تغییرِ
|
||||
// کلید، چت خودش رو ترمیم کنه بهجای اینکه تا رفرشِ صفحه قفل بمونه
|
||||
const freshKey = await getDmConversationKey(myUserId, peerUserId, true);
|
||||
if (!freshKey) return E2EE_LOCKED_PREVIEW;
|
||||
|
||||
const plain = await decryptText(freshKey, raw);
|
||||
return plain ?? E2EE_LOCKED_PREVIEW;
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,64 @@ async function publishPublicKey(publicKeyB64: string): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* رمزِ خودکارِ per-account (نه PIN دستیِ کاربر) — با یه درخواستِ احرازهویتشده به همین
|
||||
* اکانت از هر دستگاهی قابلدریافته (سرور اولینبار خودش میسازتش). بهعنوانِ رمزِ
|
||||
* رمزنگاریِ بکآپِ کلید استفاده میشه تا بازیابیِ هویت روی دستگاهِ جدید کاملاً خودکار
|
||||
* انجام بشه — بدونِ اینکه کاربر چیزی وارد کنه. توجه: این یعنی end-to-end واقعی دیگه
|
||||
* برقرار نیست (سرور میتونه این رمز رو به بکآپِ خودش بده و کلید خصوصی رو دربیاره) —
|
||||
* این تصمیمِ آگاهانهایه که به نفعِ هیچوقتگمنشدنِ پیامها گرفته شده
|
||||
*/
|
||||
async function fetchAutoBackupSecret(): Promise<string | null> {
|
||||
try {
|
||||
const base = getApiBaseUrl();
|
||||
const res = await fetch(`${base}/account/e2ee/auto-backup-secret`, {
|
||||
headers: authHeaders(),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = (await res.json()) as { secret?: string | null };
|
||||
return data.secret || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** بکآپِ خودکار رو با رمزِ per-account (نه PIN) میسازه/بهروز میکنه — بیصدا و best-effort */
|
||||
async function saveAutoKeyBackup(privateJwk: JsonWebKey): Promise<void> {
|
||||
try {
|
||||
const autoSecret = await fetchAutoBackupSecret();
|
||||
if (!autoSecret) return;
|
||||
const payload = await encryptPrivateKeyForBackup(privateJwk, autoSecret);
|
||||
const base = getApiBaseUrl();
|
||||
await fetch(`${base}/account/e2ee/key-backup`, {
|
||||
method: "PUT",
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
} catch {
|
||||
/* best-effort — اگه الان شکست بخوره، دفعهی بعدی که ensureIdentityKeys صدا زده بشه دوباره امتحان میشه */
|
||||
}
|
||||
}
|
||||
|
||||
/** تلاش برای بازیابیِ خودکارِ هویت با رمزِ per-account — بدون هیچ ورودیِ کاربر. اگه بکآپ نبود/باز نشد، null */
|
||||
async function tryAutoRecoverIdentity(
|
||||
uid: string,
|
||||
publishedPublicKeyB64: string
|
||||
): Promise<{ privateKey: CryptoKey; publicKeyB64: string } | null> {
|
||||
try {
|
||||
const [autoSecret, backup] = await Promise.all([
|
||||
fetchAutoBackupSecret(),
|
||||
fetchMyKeyBackup(),
|
||||
]);
|
||||
if (!autoSecret || !backup) return null;
|
||||
const privateJwk = await decryptPrivateKeyFromBackup(backup, autoSecret);
|
||||
const privateKey = await importPrivateKeyJwk(privateJwk);
|
||||
return await storeAndAdopt(uid, privateKey, privateJwk, publishedPublicKeyB64);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* پرتاب میشه وقتی روی سرور از قبل یه کلیدِ عمومی برای این کاربر ثبته، ولی این
|
||||
* دستگاه هیچ کلیدِ محلیای نداره — یعنی یه هویتِ قبلی وجود داره که این دستگاه بهش
|
||||
@@ -102,6 +160,9 @@ async function loadOrCreateIdentityKeys(uid: string): Promise<{
|
||||
} catch {
|
||||
/* publish best-effort */
|
||||
}
|
||||
// بکآپِ خودکار رو تازه نگه میداره — اگه قبلاً (مثلاً روی نسخهی قدیمیتر) هیچ
|
||||
// بکآپی ساخته نشده بود، همینجا برای اولینبار ساخته میشه، بدون نیاز به PIN کاربر
|
||||
void saveAutoKeyBackup(stored.privateJwk);
|
||||
return { privateKey, publicKeyB64: stored.publicKeyB64 };
|
||||
}
|
||||
|
||||
@@ -109,6 +170,12 @@ async function loadOrCreateIdentityKeys(uid: string): Promise<{
|
||||
// کاربرِ تازهست، نه یه دستگاهِ جدید/دیتایپاکشده برای یه هویتِ از قبل موجود
|
||||
const existingPublicKey = await fetchPeerPublicKey(uid);
|
||||
if (existingPublicKey) {
|
||||
// قبل از throw کردن (که کاربر رو با کارتِ بازیابی مسدود میکنه)، یهبار خودکار
|
||||
// (با رمزِ per-account، نه PIN) امتحان کن — این دقیقاً همون چیزیه که باعث میشه
|
||||
// لاگین دوباره/دستگاهِ دوم هیچوقت نیازی به دخالتِ کاربر یا گمشدنِ پیام نداشته باشه
|
||||
const recovered = await tryAutoRecoverIdentity(uid, existingPublicKey);
|
||||
if (recovered) return recovered;
|
||||
|
||||
const backup = await fetchMyKeyBackup();
|
||||
throw new IdentityRecoveryNeededError(Boolean(backup));
|
||||
}
|
||||
@@ -122,6 +189,9 @@ async function loadOrCreateIdentityKeys(uid: string): Promise<{
|
||||
} catch {
|
||||
/* publish best-effort */
|
||||
}
|
||||
// هویتِ تازه رو فوراً با رمزِ خودکار بکآپ میگیره — دستگاهِ بعدی که با همین اکانت
|
||||
// وارد بشه، دیگه هیچوقت به این نقطه (existingPublicKey ولی بدون بکآپ) نمیرسه
|
||||
await saveAutoKeyBackup(privateJwk);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -215,6 +285,9 @@ export async function createFreshIdentity(userId: string): Promise<{
|
||||
} catch {
|
||||
/* publish best-effort */
|
||||
}
|
||||
// این هویتِ تازه رو هم فوراً با رمزِ خودکار بکآپ میگیره — وگرنه دستگاهِ بعدی که با
|
||||
// همین اکانت وارد بشه، دوباره به همین بنبست (بدون بکآپ) میرسید
|
||||
await saveAutoKeyBackup(privateJwk);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,54 +1,288 @@
|
||||
import { isE2eeEnvelope, E2EE_LOCKED_PREVIEW } from "./envelope";
|
||||
import Cookies from "js-cookie";
|
||||
import { getApiBaseUrl } from "@/components/main/BaseUrl";
|
||||
import {
|
||||
decryptText,
|
||||
encryptText,
|
||||
generateRoomKeyRaw,
|
||||
importPublicKeySpkiB64,
|
||||
importRoomAesKey,
|
||||
unwrapRoomKey,
|
||||
wrapRoomKeyForRecipient,
|
||||
} from "./crypto";
|
||||
import { E2EE_LOCKED_PREVIEW, isE2eeEnvelope } from "./envelope";
|
||||
import { ensureIdentityKeys } from "./keys";
|
||||
import { b64ToBuf, bufToB64, idbGet, idbSet } from "./storage";
|
||||
|
||||
// E2EE برای رومها هم موقتاً کاملاً غیرفعال شده. نکتهی مهم: ensureRoomAesKey
|
||||
// قبلاً اگه روم هنوز e2ee_enabled نبود، خودش یه کلیدِ تازه میساخت و با PUT به
|
||||
// سرور، رمزنگاری رو *خودکار* روی روم فعال میکرد — پس همینجا کاملاً no-op
|
||||
// شده تا هیچجایی (حتی موقعِ ساختنِ روم جدید) دیگه رمزنگاری رو روشن نکنه
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars -- امضا برای سازگاری با فراخوانهای موجود حفظ شده */
|
||||
export async function ensureRoomAesKey(
|
||||
myUserId: string,
|
||||
roomId: string
|
||||
): Promise<CryptoKey | null> {
|
||||
type RoomKeysResponse = {
|
||||
e2ee_enabled: boolean;
|
||||
myWrappedKey: string | null;
|
||||
memberPublicKeys: { userId: string; publicKey: string | null }[];
|
||||
missingWrapUserIds: string[];
|
||||
};
|
||||
|
||||
type PutRoomKeysConflict = { myWrappedKey: string | null } | null;
|
||||
|
||||
const roomAesCache = new Map<string, CryptoKey>();
|
||||
const roomRawCache = new Map<string, Uint8Array>();
|
||||
|
||||
// عین inFlight توی keys.ts: بدون این، چند فراخوانیِ همزمانِ همین تب (مثلاً mount شدنِ
|
||||
// صفحهی روم + رمزگشاییِ چند پیام همزمان) هرکدوم جداگانه ensureRoomAesKey رو صدا
|
||||
// میزدن؛ اگه روم هنوز کلید نداشت، هر کدوم یه کلیدِ تصادفیِ متفاوت میساختن. این فقط
|
||||
// رقابتِ داخلِ همین تب رو میبنده — رقابتِ بین چند عضوِ مختلف (چند دستگاه/تب) با
|
||||
// claimِ اتمیکِ سمتِ سرور (putRoomE2eeKeys) حل میشه
|
||||
const inFlight = new Map<string, Promise<CryptoKey | null>>();
|
||||
|
||||
function authHeaders(): HeadersInit {
|
||||
const token = Cookies.get("token");
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchRoomKeys(roomId: string): Promise<RoomKeysResponse | null> {
|
||||
const base = getApiBaseUrl();
|
||||
const res = await fetch(`${base}/chat-rooms/${roomId}/e2ee-keys`, {
|
||||
headers: authHeaders(),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return (await res.json()) as RoomKeysResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* اگه سرور ۴۰۹ (E2EE_ALREADY_ENABLED) برگردونه یعنی عضو دیگهای همزمان برندهی
|
||||
* فعالسازیِ اولیهی روم شده — کلیدی که خودمون تازه ساختیم دیگه معتبر نیست؛ باید
|
||||
* wrappedKeyِ واقعی (متعلق به کلیدِ برنده) که سرور برگردونده رو برگردونیم، نه که
|
||||
* موفقیت رو فرض کنیم (باگِ اصلیای که قبلاً E2EE رومها رو دائم میشکست)
|
||||
*/
|
||||
async function putRoomKeys(
|
||||
roomId: string,
|
||||
body: {
|
||||
e2ee_enabled?: boolean;
|
||||
wrapped_keys?: { userId: string; wrappedKey: string }[];
|
||||
},
|
||||
): Promise<PutRoomKeysConflict> {
|
||||
const base = getApiBaseUrl();
|
||||
const res = await fetch(`${base}/chat-rooms/${roomId}/e2ee-keys`, {
|
||||
method: "PUT",
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (res.status === 409) {
|
||||
try {
|
||||
const data = (await res.json()) as {
|
||||
code?: string;
|
||||
myWrappedKey?: string | null;
|
||||
};
|
||||
if (data?.code === "E2EE_ALREADY_ENABLED") {
|
||||
return { myWrappedKey: data.myWrappedKey ?? null };
|
||||
}
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
/* eslint-enable @typescript-eslint/no-unused-vars */
|
||||
|
||||
async function loadCachedRoomRaw(
|
||||
myUserId: string,
|
||||
roomId: string,
|
||||
): Promise<Uint8Array | null> {
|
||||
const mem = roomRawCache.get(roomId);
|
||||
if (mem) return mem;
|
||||
const b64 = await idbGet<string>(`roomkey:${myUserId}:${roomId}`);
|
||||
if (!b64) return null;
|
||||
const raw = new Uint8Array(b64ToBuf(b64));
|
||||
roomRawCache.set(roomId, raw);
|
||||
return raw;
|
||||
}
|
||||
|
||||
async function persistRoomRaw(
|
||||
myUserId: string,
|
||||
roomId: string,
|
||||
raw: Uint8Array,
|
||||
): Promise<CryptoKey> {
|
||||
roomRawCache.set(roomId, raw);
|
||||
await idbSet(`roomkey:${myUserId}:${roomId}`, bufToB64(raw));
|
||||
const aes = await importRoomAesKey(raw);
|
||||
roomAesCache.set(roomId, aes);
|
||||
return aes;
|
||||
}
|
||||
|
||||
async function doEnsureRoomAesKey(
|
||||
myUserId: string,
|
||||
roomId: string,
|
||||
forceFresh: boolean,
|
||||
): Promise<CryptoKey | null> {
|
||||
if (!forceFresh) {
|
||||
const cached = roomAesCache.get(roomId);
|
||||
if (cached) return cached;
|
||||
}
|
||||
|
||||
const { privateKey } = await ensureIdentityKeys(myUserId);
|
||||
const info = await fetchRoomKeys(roomId);
|
||||
if (!info) return null;
|
||||
|
||||
let raw = forceFresh ? null : await loadCachedRoomRaw(myUserId, roomId);
|
||||
|
||||
if (!raw && info.myWrappedKey) {
|
||||
try {
|
||||
raw = await unwrapRoomKey(info.myWrappedKey, privateKey);
|
||||
await persistRoomRaw(myUserId, roomId, raw);
|
||||
} catch {
|
||||
raw = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!raw && !info.e2ee_enabled) {
|
||||
raw = await generateRoomKeyRaw();
|
||||
const wraps: { userId: string; wrappedKey: string }[] = [];
|
||||
for (const m of info.memberPublicKeys) {
|
||||
if (!m.publicKey) continue;
|
||||
try {
|
||||
const pub = await importPublicKeySpkiB64(m.publicKey);
|
||||
wraps.push({
|
||||
userId: m.userId,
|
||||
wrappedKey: await wrapRoomKeyForRecipient(raw, pub),
|
||||
});
|
||||
} catch {
|
||||
/* skip */
|
||||
}
|
||||
}
|
||||
if (!wraps.some((w) => w.userId === myUserId)) {
|
||||
const myPub = info.memberPublicKeys.find((m) => m.userId === myUserId);
|
||||
if (myPub?.publicKey) {
|
||||
const pub = await importPublicKeySpkiB64(myPub.publicKey);
|
||||
wraps.push({
|
||||
userId: myUserId,
|
||||
wrappedKey: await wrapRoomKeyForRecipient(raw, pub),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const conflict = await putRoomKeys(roomId, {
|
||||
e2ee_enabled: true,
|
||||
wrapped_keys: wraps,
|
||||
});
|
||||
|
||||
if (conflict) {
|
||||
// عضو دیگهای همزمان برنده شد — کلیدِ خودمون رو دور میریزیم و کلیدِ واقعی
|
||||
// (متعلق به برنده) رو با wrappedKeyِ برگشتی از سرور باز میکنیم
|
||||
if (!conflict.myWrappedKey) return null;
|
||||
const winnerRaw = await unwrapRoomKey(conflict.myWrappedKey, privateKey);
|
||||
return persistRoomRaw(myUserId, roomId, winnerRaw);
|
||||
}
|
||||
|
||||
return persistRoomRaw(myUserId, roomId, raw);
|
||||
}
|
||||
|
||||
if (raw) {
|
||||
// Sync wraps for members missing keys
|
||||
if (info.missingWrapUserIds?.length) {
|
||||
const wraps: { userId: string; wrappedKey: string }[] = [];
|
||||
for (const uid of info.missingWrapUserIds) {
|
||||
const m = info.memberPublicKeys.find((x) => x.userId === uid);
|
||||
if (!m?.publicKey) continue;
|
||||
try {
|
||||
const pub = await importPublicKeySpkiB64(m.publicKey);
|
||||
wraps.push({
|
||||
userId: uid,
|
||||
wrappedKey: await wrapRoomKeyForRecipient(raw, pub),
|
||||
});
|
||||
} catch {
|
||||
/* skip */
|
||||
}
|
||||
}
|
||||
if (wraps.length) {
|
||||
await putRoomKeys(roomId, { wrapped_keys: wraps });
|
||||
}
|
||||
}
|
||||
return persistRoomRaw(myUserId, roomId, raw);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function ensureRoomAesKey(
|
||||
myUserId: string,
|
||||
roomId: string,
|
||||
/** Bypass the in-memory/IndexedDB cache and re-unwrap from the server's current wrapped key. */
|
||||
forceFresh = false,
|
||||
): Promise<CryptoKey | null> {
|
||||
if (!forceFresh) {
|
||||
const cached = roomAesCache.get(roomId);
|
||||
if (cached) return cached;
|
||||
|
||||
const existing = inFlight.get(roomId);
|
||||
if (existing) return existing;
|
||||
}
|
||||
|
||||
const task = doEnsureRoomAesKey(myUserId, roomId, forceFresh).finally(() => {
|
||||
inFlight.delete(roomId);
|
||||
});
|
||||
if (!forceFresh) inFlight.set(roomId, task);
|
||||
return task;
|
||||
}
|
||||
|
||||
export async function encryptRoomContent(
|
||||
myUserId: string,
|
||||
roomId: string,
|
||||
plaintext: string
|
||||
plaintext: string,
|
||||
): Promise<string | null> {
|
||||
return plaintext;
|
||||
if (!plaintext) return plaintext;
|
||||
if (isE2eeEnvelope(plaintext)) return plaintext;
|
||||
const key = await ensureRoomAesKey(myUserId, roomId);
|
||||
if (!key) return null;
|
||||
return encryptText(key, plaintext);
|
||||
}
|
||||
|
||||
export async function decryptRoomContent(
|
||||
myUserId: string,
|
||||
roomId: string,
|
||||
content: string
|
||||
content: string,
|
||||
): Promise<string> {
|
||||
// فقط پیامِ قدیمیِ رمزنگاریشده (از قبلِ غیرفعالسازیِ E2EE) به اینجا میرسه
|
||||
if (isE2eeEnvelope(content)) return E2EE_LOCKED_PREVIEW;
|
||||
return content;
|
||||
if (!isE2eeEnvelope(content)) return content;
|
||||
|
||||
const key = await ensureRoomAesKey(myUserId, roomId);
|
||||
if (key) {
|
||||
const pt = await decryptText(key, content);
|
||||
if (pt != null) return pt;
|
||||
}
|
||||
|
||||
// کلید کششده ممکن است دیگر با wrapped key فعلی سرور همخوان نباشد — یک بار تازه امتحان کن
|
||||
const freshKey = await ensureRoomAesKey(myUserId, roomId, true);
|
||||
if (freshKey) {
|
||||
const pt = await decryptText(freshKey, content);
|
||||
if (pt != null) return pt;
|
||||
}
|
||||
|
||||
return E2EE_LOCKED_PREVIEW;
|
||||
}
|
||||
|
||||
export async function decryptRoomMessageList<
|
||||
T extends {
|
||||
content?: string;
|
||||
replyTo?: { content?: string; [k: string]: unknown } | null;
|
||||
}
|
||||
>(
|
||||
myUserId: string,
|
||||
roomId: string,
|
||||
messages: T[]
|
||||
): Promise<T[]> {
|
||||
},
|
||||
>(myUserId: string, roomId: string, messages: T[]): Promise<T[]> {
|
||||
await ensureRoomAesKey(myUserId, roomId);
|
||||
const out: T[] = [];
|
||||
for (const msg of messages) {
|
||||
let next = msg;
|
||||
if (msg.content && isE2eeEnvelope(msg.content)) {
|
||||
next = { ...next, content: E2EE_LOCKED_PREVIEW };
|
||||
const decrypted = await decryptRoomContent(myUserId, roomId, msg.content);
|
||||
next = { ...next, content: decrypted };
|
||||
}
|
||||
if (next.replyTo?.content && isE2eeEnvelope(next.replyTo.content)) {
|
||||
next = { ...next, replyTo: { ...next.replyTo, content: E2EE_LOCKED_PREVIEW } };
|
||||
const replyContent = await decryptRoomContent(
|
||||
myUserId,
|
||||
roomId,
|
||||
next.replyTo.content,
|
||||
);
|
||||
next = {
|
||||
...next,
|
||||
replyTo: { ...next.replyTo, content: replyContent },
|
||||
};
|
||||
}
|
||||
out.push(next);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
DEFAULT_LANGUAGE,
|
||||
ENABLED_LANGUAGES,
|
||||
getLanguageDefinition,
|
||||
isAppLanguage,
|
||||
|
||||
226
src/lib/personalDetailsFieldOptions.ts
Normal file
226
src/lib/personalDetailsFieldOptions.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
// مقادیر و برچسبهای فیلدهای جدید «مشخصات فردی» (هدف از رابطه، ویژگیهای
|
||||
// شخصیتی، سبک زندگی، علاقهمندیها و ...) که هم در صفحهی ویرایش مشخصات فردی
|
||||
// و هم در فیلترهای «یافتن همکار» استفاده میشوند — یکجا نگهداشته میشوند تا
|
||||
// مقادیر/ترتیب در هر دو صفحه همیشه یکسان بماند.
|
||||
|
||||
export const RELATIONSHIP_GOAL_VALUES = [
|
||||
"friendship",
|
||||
"serious_partner",
|
||||
"marriage_intro",
|
||||
"emotional_relationship",
|
||||
"travel_event_companion",
|
||||
"social_networking",
|
||||
"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",
|
||||
"not_in_relationship",
|
||||
"prefer_not_to_say",
|
||||
] as const;
|
||||
|
||||
export const SEEKING_GENDER_VALUES = ["female", "male", "both", "not_important"] as const;
|
||||
export type SeekingGender = (typeof SEEKING_GENDER_VALUES)[number];
|
||||
|
||||
export const DESIRED_AGE_RANGE_VALUES = [
|
||||
"18_24",
|
||||
"25_30",
|
||||
"31_35",
|
||||
"36_40",
|
||||
"41_50",
|
||||
"over_50",
|
||||
"not_important",
|
||||
] as const;
|
||||
export type DesiredAgeRange = (typeof DESIRED_AGE_RANGE_VALUES)[number];
|
||||
|
||||
/** تبدیل بازهی سنی انتخابی (bucket) به ageMin/ageMax عددی که سرچ سرور انتظار دارد. */
|
||||
export const DESIRED_AGE_RANGE_TO_MIN_MAX: Record<
|
||||
DesiredAgeRange,
|
||||
{ min?: number; max?: number }
|
||||
> = {
|
||||
"18_24": { min: 18, max: 24 },
|
||||
"25_30": { min: 25, max: 30 },
|
||||
"31_35": { min: 31, max: 35 },
|
||||
"36_40": { min: 36, max: 40 },
|
||||
"41_50": { min: 41, max: 50 },
|
||||
over_50: { min: 51 },
|
||||
not_important: {},
|
||||
};
|
||||
|
||||
export const PERSONALITY_TRAIT_VALUES = [
|
||||
"kind",
|
||||
"loyal",
|
||||
"funny",
|
||||
"calm",
|
||||
"energetic",
|
||||
"responsible",
|
||||
"ambitious",
|
||||
"emotional",
|
||||
"logical",
|
||||
"patient",
|
||||
"honest",
|
||||
"romantic",
|
||||
"independent",
|
||||
"family_oriented",
|
||||
"adventurous",
|
||||
"introvert",
|
||||
"extrovert",
|
||||
] as const;
|
||||
export type PersonalityTrait = (typeof PERSONALITY_TRAIT_VALUES)[number];
|
||||
export const PERSONALITY_TRAITS_MAX = 8;
|
||||
|
||||
export const LIFESTYLE_VALUES = [
|
||||
"active_healthy",
|
||||
"homebody_calm",
|
||||
"social_outgoing",
|
||||
"busy_career",
|
||||
"traveler",
|
||||
"night_owl",
|
||||
"early_riser",
|
||||
"minimalist",
|
||||
"luxury_lover",
|
||||
] as const;
|
||||
export type Lifestyle = (typeof LIFESTYLE_VALUES)[number];
|
||||
export const LIFESTYLE_MAX = 5;
|
||||
|
||||
export const INTEREST_VALUES = [
|
||||
"movies_series",
|
||||
"music",
|
||||
"books_reading",
|
||||
"sports",
|
||||
"cooking",
|
||||
"photography",
|
||||
"travel",
|
||||
"nature",
|
||||
"gaming",
|
||||
"art_painting",
|
||||
"fashion",
|
||||
"pets",
|
||||
"dancing",
|
||||
"language_learning",
|
||||
] as const;
|
||||
export type Interest = (typeof INTEREST_VALUES)[number];
|
||||
export const INTERESTS_MAX = 8;
|
||||
|
||||
export const VALUE_VALUES = [
|
||||
"family",
|
||||
"career_growth",
|
||||
"religion_spirituality",
|
||||
"personal_freedom",
|
||||
"financial_stability",
|
||||
"health_fitness",
|
||||
"personal_growth",
|
||||
"excitement_new_experiences",
|
||||
] as const;
|
||||
export type ValueItem = (typeof VALUE_VALUES)[number];
|
||||
export const VALUES_MAX = 5;
|
||||
|
||||
export const RELATIONSHIP_VIEW_VALUES = [
|
||||
"traditional",
|
||||
"modern",
|
||||
"long_term",
|
||||
"friendship_first",
|
||||
"marriage_soon",
|
||||
"friendship_only_for_now",
|
||||
] as const;
|
||||
export type RelationshipView = (typeof RELATIONSHIP_VIEW_VALUES)[number];
|
||||
|
||||
export const GEO_PREFERENCE_VALUES = [
|
||||
"same_city_only",
|
||||
"nearby_cities",
|
||||
"whole_province",
|
||||
"whole_iran",
|
||||
"even_abroad",
|
||||
"online_is_fine",
|
||||
] as const;
|
||||
export type GeoPreference = (typeof GEO_PREFERENCE_VALUES)[number];
|
||||
|
||||
export const CHILDREN_STATUS_VALUES = [
|
||||
"no_children",
|
||||
"has_children",
|
||||
"open_to_children",
|
||||
"not_now",
|
||||
"prefer_not_to_say",
|
||||
] as const;
|
||||
export type ChildrenStatus = (typeof CHILDREN_STATUS_VALUES)[number];
|
||||
|
||||
export const SMOKING_STATUS_VALUES = [
|
||||
"none",
|
||||
"sometimes",
|
||||
"yes",
|
||||
"doesnt_matter",
|
||||
"prefer_not_to_say",
|
||||
] as const;
|
||||
export type SmokingStatus = (typeof SMOKING_STATUS_VALUES)[number];
|
||||
|
||||
export const INCOME_RANGE_VALUES = [
|
||||
"under_40m",
|
||||
"40m_50m",
|
||||
"50m_60m",
|
||||
"60m_70m",
|
||||
"70m_80m",
|
||||
"80m_90m",
|
||||
"90m_100m",
|
||||
"100m_150m",
|
||||
"150m_200m",
|
||||
"over_200m",
|
||||
] as const;
|
||||
export type IncomeRange = (typeof INCOME_RANGE_VALUES)[number];
|
||||
|
||||
export type LabeledOption = { value: string; label: string };
|
||||
|
||||
type TranslateFn = (key: string, options?: Record<string, unknown>) => string;
|
||||
|
||||
function buildOptions(t: TranslateFn, i18nKey: string, values: readonly string[]): LabeledOption[] {
|
||||
return values.map((value) => ({
|
||||
value,
|
||||
label: t(`settings.edit.personalDetails.${i18nKey}.${value}`),
|
||||
}));
|
||||
}
|
||||
|
||||
export const buildRelationshipGoalOptions = (t: TranslateFn) =>
|
||||
buildOptions(t, "relationshipGoalOptions", RELATIONSHIP_GOAL_VALUES);
|
||||
|
||||
export const buildMaritalStatusNewOptions = (t: TranslateFn) =>
|
||||
buildOptions(t, "maritalStatusOptions", MARITAL_STATUS_NEW_VALUES);
|
||||
|
||||
export const buildSeekingGenderOptions = (t: TranslateFn) =>
|
||||
buildOptions(t, "seekingGenderOptions", SEEKING_GENDER_VALUES);
|
||||
|
||||
export const buildDesiredAgeRangeOptions = (t: TranslateFn) =>
|
||||
buildOptions(t, "desiredAgeRangeOptions", DESIRED_AGE_RANGE_VALUES);
|
||||
|
||||
export const buildPersonalityTraitsOptions = (t: TranslateFn) =>
|
||||
buildOptions(t, "personalityTraitsOptions", PERSONALITY_TRAIT_VALUES);
|
||||
|
||||
export const buildLifestyleOptions = (t: TranslateFn) =>
|
||||
buildOptions(t, "lifestyleOptions", LIFESTYLE_VALUES);
|
||||
|
||||
export const buildInterestsOptions = (t: TranslateFn) =>
|
||||
buildOptions(t, "interestsOptions", INTEREST_VALUES);
|
||||
|
||||
export const buildValuesOptions = (t: TranslateFn) =>
|
||||
buildOptions(t, "valuesOptions", VALUE_VALUES);
|
||||
|
||||
export const buildRelationshipViewOptions = (t: TranslateFn) =>
|
||||
buildOptions(t, "relationshipViewOptions", RELATIONSHIP_VIEW_VALUES);
|
||||
|
||||
export const buildGeoPreferenceOptions = (t: TranslateFn) =>
|
||||
buildOptions(t, "geoPreferenceOptions", GEO_PREFERENCE_VALUES);
|
||||
|
||||
export const buildChildrenStatusOptions = (t: TranslateFn) =>
|
||||
buildOptions(t, "childrenStatusOptions", CHILDREN_STATUS_VALUES);
|
||||
|
||||
export const buildSmokingStatusOptions = (t: TranslateFn) =>
|
||||
buildOptions(t, "smokingStatusOptions", SMOKING_STATUS_VALUES);
|
||||
|
||||
export const buildIncomeRangeOptions = (t: TranslateFn) =>
|
||||
buildOptions(t, "incomeRangeOptions", INCOME_RANGE_VALUES);
|
||||
|
||||
/** برای درج n آیتم آرایهای در URLSearchParams طبق قرارداد بکاند (مقدار جدا شده با کاما). */
|
||||
export function joinFilterValues(values: string[] | undefined | null): string | undefined {
|
||||
if (!values || values.length === 0) return undefined;
|
||||
return values.join(",");
|
||||
}
|
||||
25
src/lib/storyFonts.ts
Normal file
25
src/lib/storyFonts.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import localFont from "next/font/local";
|
||||
import type { StoryFontFamily } from "@/api/fetchStories";
|
||||
|
||||
// همون خانوادهفونتِ IRANSansX که کل اپ استفاده میکنه (layout.tsx فقط وزن Regular رو لود
|
||||
// میکنه)، اینجا با همهی وزنهای لازم برای «تغییر فونت» استوری بار میشه — یه تعریفِ
|
||||
// next/font/local با چندین فایل وزن، نه چند فونت جدا
|
||||
export const storyFont = localFont({
|
||||
src: [
|
||||
{ path: "../../public/fonts/IRANSansX-Light.woff", weight: "300", style: "normal" },
|
||||
{ path: "../../public/fonts/IRANSansX-Regular.woff", weight: "400", style: "normal" },
|
||||
{ path: "../../public/fonts/IRANSansX-Medium.woff", weight: "500", style: "normal" },
|
||||
{ path: "../../public/fonts/IRANSansX-Bold.woff", weight: "700", style: "normal" },
|
||||
{ path: "../../public/fonts/IRANSansX-Black.woff", weight: "900", style: "normal" },
|
||||
],
|
||||
display: "swap",
|
||||
fallback: ["Tahoma", "Arial", "sans-serif"],
|
||||
});
|
||||
|
||||
export const STORY_FONT_WEIGHTS: Record<StoryFontFamily, number> = {
|
||||
light: 300,
|
||||
regular: 400,
|
||||
medium: 500,
|
||||
bold: 700,
|
||||
black: 900,
|
||||
};
|
||||
Reference in New Issue
Block a user