This commit is contained in:
payacom
2026-07-23 15:58:15 +03:30
parent af5b27b610
commit a1d7db5e7e
445 changed files with 23456 additions and 8865 deletions

View File

@@ -14,6 +14,7 @@ const PUBLIC_AUTH_PATTERNS = [
/^\/register\/?$/,
/^\/register\/verify$/,
/^\/auth\/google$/,
/^\/auth\/totp\/verify-login$/,
];
export function isPublicAuthPath(pathSegments: string[]): boolean {

View File

@@ -0,0 +1,10 @@
import { GOOGLE_OAUTH_CLIENT_ID } from "@/config/googleAuth";
/** Google OAuth Web Client ID — env at runtime, then committed fallback */
export function getGoogleClientId(): string {
return (
process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID ||
process.env.GOOGLE_CLIENT_ID ||
GOOGLE_OAUTH_CLIENT_ID
).trim();
}

View File

@@ -5,6 +5,8 @@ import {
} from "@/lib/auth/session";
import { AUTH_SUCCESS_DELAY_MS, pause } from "@/lib/auth/feedback";
import { getRegistrationRoute } from "@/lib/auth/registrationRoutes";
import { DEFAULT_LANGUAGE, type AppLanguage } from "@/lib/i18n/registry";
import { translateCommon } from "@/lib/i18n/translate";
export function getSafeRedirectPath(): string {
if (typeof window === "undefined") return "/";
@@ -16,10 +18,10 @@ export function getSafeRedirectPath(): string {
}
export async function navigateIncompleteRegistration(
router: AppRouterInstance,
_router: AppRouterInstance,
step?: string
) {
router.push(getRegistrationRoute(step));
navigateAfterAuth(getRegistrationRoute(step));
}
function navigateAfterAuth(target: string): void {
@@ -27,16 +29,100 @@ function navigateAfterAuth(target: string): void {
window.location.assign(target);
}
function readString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
/** Normalize login payloads from OTP, username, or Google auth endpoints. */
export function parseLoginResponse(raw: unknown): IVerifyOtp {
if (!raw || typeof raw !== "object") {
return { message: "" };
}
const obj = raw as Record<string, unknown>;
if (obj.data && typeof obj.data === "object" && !readString(obj.token)) {
return parseLoginResponse(obj.data);
}
const token =
readString(obj.token) ||
readString(obj.accessToken) ||
readString(obj.access_token);
const page =
obj.page === "home" || obj.page === "auth-page" ? obj.page : undefined;
return {
message: readString(obj.message) || "",
token,
user_type:
typeof obj.user_type === "string"
? obj.user_type
: obj.user_type === null
? null
: undefined,
step: readString(obj.step),
page,
id: obj.id != null ? String(obj.id) : undefined,
auth_provider:
obj.auth_provider === "google" || obj.auth_provider === "mobile"
? obj.auth_provider
: undefined,
email:
typeof obj.email === "string"
? obj.email
: obj.email === null
? null
: undefined,
requires_2fa: Boolean(obj.requires_2fa),
temp_token: readString(obj.temp_token),
registrationComplete:
typeof obj.registrationComplete === "boolean"
? obj.registrationComplete
: undefined,
};
}
function resolvePostLoginPage(response: IVerifyOtp): "home" | "auth-page" {
if (response.page === "home" || response.page === "auth-page") {
return response.page;
}
if (
response.registrationComplete === true ||
response.step === "complete" ||
Boolean(response.user_type)
) {
return "home";
}
return "auth-page";
}
export async function completeLogin(
router: AppRouterInstance,
response: IVerifyOtp,
rawResponse: IVerifyOtp | unknown,
options?: { redirectTo?: string }
): Promise<boolean> {
if (!response?.token) return false;
const response = parseLoginResponse(rawResponse);
if (response.requires_2fa && response.temp_token) {
if (typeof window !== "undefined") {
sessionStorage.setItem("login_2fa_temp", response.temp_token);
const redirect = options?.redirectTo ?? getSafeRedirectPath();
window.location.assign(
`/login-2fa?redirect=${encodeURIComponent(redirect)}`
);
}
return true;
}
if (!response.token) return false;
await setAuthSession(response.token, {
id: response.id,
user_type: response.user_type,
id: response.id ? String(response.id) : undefined,
user_type: response.user_type ?? undefined,
step: response.step,
auth_provider: response.auth_provider,
email: response.email ?? undefined,
@@ -44,21 +130,21 @@ export async function completeLogin(
await pause(AUTH_SUCCESS_DELAY_MS);
if (response.page === "home") {
const page = resolvePostLoginPage(response);
if (page === "home") {
navigateAfterAuth(options?.redirectTo ?? getSafeRedirectPath());
return true;
}
if (response.page === "auth-page") {
router.refresh();
await navigateIncompleteRegistration(router, response.step);
return true;
}
return false;
await navigateIncompleteRegistration(router, response.step);
return true;
}
export function getAxiosErrorMessage(error: unknown): string {
export function getAxiosErrorMessage(
error: unknown,
lang: AppLanguage = DEFAULT_LANGUAGE
): string {
const data = (
error as {
response?: { data?: { message?: string; en_message?: string } };
@@ -66,11 +152,25 @@ export function getAxiosErrorMessage(error: unknown): string {
)?.response?.data;
if (data?.en_message === "Not allowed by CORS") {
return "خطا در اتصال به سرور. صفحه را رفرش کنید و دوباره تلاش کنید.";
return translateCommon(lang, "errors.corsError");
}
if (data?.message) return data.message;
if ((error as Error)?.message === "Network Error") {
return "خطا در ارتباط با سرور. اتصال اینترنت را بررسی کنید.";
return translateCommon(lang, "errors.networkError");
}
return "نام کاربری یا کلمه عبور اشتباه است.";
return translateCommon(lang, "auth.wrongCredentials");
}
export function getLoginFailureMessage(
rawResponse: unknown,
lang: AppLanguage = DEFAULT_LANGUAGE
): string {
const response = parseLoginResponse(rawResponse);
if (response.message) return response.message;
if (response.requires_2fa && !response.temp_token) {
return translateCommon(lang, "auth.invalidServerResponse");
}
return translateCommon(lang, "auth.invalidServerResponse");
}

View File

@@ -1,4 +1,9 @@
import Cookies from "js-cookie";
import {
readLocalStorage,
writeLocalStorage,
removeLocalStorage,
} from "@/lib/safeStorage";
const TOKEN_KEY = "token";
const LOGIN_GRACE_KEY = "auth_login_grace_until";
@@ -73,13 +78,13 @@ export async function setAuthSession(
markLoginGracePeriod();
if (meta?.id) localStorage.setItem("id", meta.id);
if (meta?.user_type) localStorage.setItem("usertype", meta.user_type);
if (meta?.step) localStorage.setItem("step", meta.step);
if (meta?.id) writeLocalStorage("id", String(meta.id));
if (meta?.user_type) writeLocalStorage("usertype", meta.user_type);
if (meta?.step) writeLocalStorage("step", meta.step);
if (meta?.auth_provider) {
localStorage.setItem("auth_provider", meta.auth_provider);
writeLocalStorage("auth_provider", meta.auth_provider);
}
if (meta?.email) localStorage.setItem("email", meta.email);
if (meta?.email) writeLocalStorage("email", meta.email);
}
/** پاک کردن کامل session */
@@ -99,22 +104,21 @@ export async function clearAuthSession(): Promise<void> {
// ignore
}
localStorage.removeItem("id");
localStorage.removeItem("mobile");
localStorage.removeItem("otp");
localStorage.removeItem("token");
localStorage.removeItem("usertype");
localStorage.removeItem("first_name");
localStorage.removeItem("last_name");
localStorage.removeItem("username");
localStorage.removeItem("step");
localStorage.removeItem("auth_provider");
localStorage.removeItem("email");
removeLocalStorage("id");
removeLocalStorage("mobile");
removeLocalStorage("otp");
removeLocalStorage("token");
removeLocalStorage("usertype");
removeLocalStorage("first_name");
removeLocalStorage("last_name");
removeLocalStorage("username");
removeLocalStorage("step");
removeLocalStorage("auth_provider");
removeLocalStorage("email");
}
export function getStoredUserId(): string {
if (typeof window === "undefined") return "";
return localStorage.getItem("id") ?? "";
return readLocalStorage("id") ?? "";
}
export function getAuthToken(): string | undefined {

View File

@@ -0,0 +1,36 @@
import type { TFunction } from "i18next";
export function getBillboardDisplayTypeLabel(
t: TFunction<"common">,
type?: string
): string {
switch (type) {
case "special":
return t("billboards.displayTypes.specialBadge");
case "normal":
return t("billboards.displayTypes.normal");
case "free":
return t("billboards.displayTypes.free");
case "highlight":
return t("billboards.displayTypes.highlight");
default:
return t("billboards.displayTypes.highlight");
}
}
export function getBillboardDisplayTypeOptionLabel(
t: TFunction<"common">,
type?: string
): string {
switch (type) {
case "normal":
case "free":
return t("billboards.displayTypes.normal");
case "special":
return t("billboards.displayTypes.special");
case "highlight":
return t("billboards.displayTypes.highlight");
default:
return t("billboards.displayTypes.highlight");
}
}

View File

@@ -1,3 +1,11 @@
import {
formatLocalizedSeoTitle,
getListSeoTemplates,
getSeoPage,
interpolateTemplate,
} from "@/lib/i18n/seo";
import { DEFAULT_LANGUAGE, type AppLanguage } from "@/lib/i18n/registry";
export type AcademyListFilters = {
category?: string;
type?: string;
@@ -7,41 +15,48 @@ export type AcademyListFilters = {
search?: string;
};
const SITE_SUFFIX = " | مدستاگرام";
const DEFAULT_TITLE = "آموزشها و محل یادگیری فعالان زیبایی";
export const ACADEMY_LIST_DESCRIPTION =
"فقط محل همکاری نیست؛ مدستاگرام یک مسیر رشد و یادگیری برای فعالان زیبایی هم هست.";
const SORT_LABELS: Record<string, string> = {
createdAt_desc: "جدیدترین",
createdAt_asc: "قدیمی‌ترین",
likes_desc: "محبوب‌ترین",
price_asc: "ارزان‌ترین",
price_desc: "گران‌ترین",
};
const TYPE_LABELS: Record<string, string> = {
normal: "معمولی",
pro: "پرو",
free: "رایگان",
};
function resolveSortLabel(filters: AcademyListFilters): string {
if (filters.sortBy && filters.sortOrder) {
const key = `${filters.sortBy}_${filters.sortOrder}`;
if (SORT_LABELS[key]) return SORT_LABELS[key];
}
if (filters.sortBy === "likes" && filters.sortOrder === "desc") {
return "محبوب‌ترین";
}
return "جدیدترین";
export function getAcademyListDescription(lang: AppLanguage = DEFAULT_LANGUAGE) {
return getSeoPage("academy", lang).description;
}
function resolveTypeLabel(filters: AcademyListFilters): string {
if (filters.isFree || filters.type === "free") return "بصورت رایگان";
if (filters.type && TYPE_LABELS[filters.type]) {
return `بصورت ${TYPE_LABELS[filters.type]}`;
/** @deprecated Use getAcademyListDescription(lang) */
export const ACADEMY_LIST_DESCRIPTION = getAcademyListDescription();
function resolveSortLabel(
filters: AcademyListFilters,
lang: AppLanguage
): string {
const sortLabels = getListSeoTemplates(lang).academy.sort as Record<
string,
string
>;
if (filters.sortBy && filters.sortOrder) {
const key = `${filters.sortBy}_${filters.sortOrder}`;
if (sortLabels[key]) return sortLabels[key];
}
if (filters.sortBy === "likes" && filters.sortOrder === "desc") {
return sortLabels.likes_desc ?? sortLabels.createdAt_desc;
}
return sortLabels.createdAt_desc;
}
function resolveTypeLabel(
filters: AcademyListFilters,
lang: AppLanguage
): string {
const typeLabels = getListSeoTemplates(lang).academy.type as Record<
string,
string
>;
if (filters.isFree || filters.type === "free") {
return typeLabels.free ?? "";
}
if (filters.type && typeLabels[filters.type]) {
return lang === "fa"
? `بصورت ${typeLabels[filters.type]}`
: typeLabels[filters.type];
}
return "";
}
@@ -62,7 +77,17 @@ export function buildAcademyListPath(filters: AcademyListFilters): string {
return query ? `/academy?${query}` : "/academy";
}
export function buildAcademyListSeo(filters: AcademyListFilters) {
export function buildAcademyListSeo(
filters: AcademyListFilters,
lang: AppLanguage = DEFAULT_LANGUAGE
) {
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 ||
filters.type ||
@@ -73,24 +98,51 @@ export function buildAcademyListSeo(filters: AcademyListFilters) {
if (!hasFilters) {
return {
title: `${DEFAULT_TITLE}${SITE_SUFFIX}`,
description: ACADEMY_LIST_DESCRIPTION,
path: "/academy",
h1: DEFAULT_TITLE,
title: academyPage.title,
description: academyPage.description,
path: academyPage.path,
h1: academyPage.h1 ?? templates.defaultTitle,
};
}
const sortLabel = resolveSortLabel(filters);
const sortLabel = resolveSortLabel(filters, lang);
const categoryPart = filters.category ? filters.category : "";
const typePart = resolveTypeLabel(filters);
const typePart = resolveTypeLabel(filters, lang);
const searchPart = filters.search
? interpolateTemplate(templates.searchPart, { search: filters.search })
: "";
const titleCore = [sortLabel, "آموزشهای", categoryPart, typePart]
const titleCore = [
searchPart,
sortLabel,
templates.coursesWord,
categoryPart,
typePart,
]
.filter(Boolean)
.join(" ");
.join(templates.titleJoiner ?? " ")
.replace(/\s+/g, " ")
.trim();
const filterDescParts = [
categoryPart,
typePart,
filters.search
? lang === "fa"
? `جستجوی ${filters.search}`
: `search: ${filters.search}`
: "",
]
.filter(Boolean)
.join(lang === "fa" ? "، " : ", ");
return {
title: `${titleCore}${SITE_SUFFIX}`,
description: ACADEMY_LIST_DESCRIPTION,
title: formatLocalizedSeoTitle(titleCore, lang),
description: filterDescParts
? interpolateTemplate(templates.filteredDescription, {
filters: filterDescParts,
})
: academyPage.description,
path: buildAcademyListPath(filters),
h1: titleCore,
};

View File

@@ -0,0 +1,150 @@
import {
buildLocalizedLocationLabel,
formatLocalizedSeoTitle,
getListSeoTemplates,
getSeoPage,
interpolateTemplate,
} from "@/lib/i18n/seo";
import { DEFAULT_LANGUAGE, type AppLanguage } from "@/lib/i18n/registry";
import provinces from "@/data/provinces.json";
import cities from "@/data/cities.json";
export type BillboardsListFilters = {
province?: string;
city?: string;
category?: string;
search?: string;
sort?: string;
};
export function resolveBillboardSeoData(
slugSegments: string[] = [],
filters: BillboardsListFilters
) {
let provinceId = filters.province || "";
let cityId = filters.city || "";
let category = filters.category || "";
let provinceName = "";
let cityName = "";
if (slugSegments.length > 0) {
const fullText = decodeURIComponent(slugSegments[0]).replace(/-/g, " ");
if (fullText.startsWith("تبلیغات")) {
const afterAds = fullText.split("تبلیغات")[1]?.trim();
if (afterAds) {
category = afterAds.split("در")[0]?.trim();
if (category.includes("خدمات مد و زیبایی")) category = "";
}
if (fullText.includes("در شهر")) {
const cityLabel = fullText.split("در شهر")[1]?.trim();
const foundCity = cities.find((c) => cityLabel?.includes(c.name));
if (foundCity) {
cityId = foundCity.id.toString();
cityName = foundCity.name;
}
} else if (fullText.includes("در استان")) {
const provinceLabel = fullText.split("در استان")[1]?.trim();
const foundProvince = provinces.find((p) =>
provinceLabel?.includes(p.name)
);
if (foundProvince) {
provinceId = foundProvince.id.toString();
provinceName = foundProvince.name;
}
}
}
}
if (!cityName && filters.city) {
const city = cities.find((c) => c.id.toString() === filters.city);
if (city) {
cityId = city.id.toString();
cityName = city.name;
}
} else if (!provinceName && filters.province) {
const province = provinces.find((p) => p.id.toString() === filters.province);
if (province) {
provinceId = province.id.toString();
provinceName = province.name;
}
}
const defaultCategory =
(getListSeoTemplates(DEFAULT_LANGUAGE).billboards as Record<string, string>)
.defaultCategory;
const categoryLabel = category || filters.category || defaultCategory;
return {
provinceId,
cityId,
category,
categoryLabel,
provinceName,
cityName,
};
}
export function buildBillboardsListSeo(
slugSegments: string[] = [],
filters: BillboardsListFilters,
lang: AppLanguage = DEFAULT_LANGUAGE
) {
const billboardsPage = getSeoPage("billboards", lang);
const templates = getListSeoTemplates(lang).billboards as Record<
string,
string
>;
const hasFilters = Boolean(
slugSegments.length ||
filters.province ||
filters.city ||
filters.category ||
filters.search ||
filters.sort
);
if (!hasFilters) {
return {
title: billboardsPage.title,
description: billboardsPage.description,
path: billboardsPage.path,
h1: billboardsPage.h1 ?? billboardsPage.title,
...resolveBillboardSeoData(slugSegments, filters),
};
}
const data = resolveBillboardSeoData(slugSegments, filters);
const location = buildLocalizedLocationLabel(
{
cityName: data.cityName,
provinceName: data.provinceName,
},
lang
);
const titleCore = interpolateTemplate(templates.titleCore, {
category: data.categoryLabel,
});
const fullTitleCore = [titleCore, location].filter(Boolean).join(" ").trim();
const path =
slugSegments.length > 0
? `/billboards/${slugSegments.join("/")}`
: billboardsPage.path;
const locationText = location ? ` ${location}` : "";
return {
...data,
title: formatLocalizedSeoTitle(fullTitleCore, lang),
description: interpolateTemplate(templates.filteredDescription, {
category: data.categoryLabel,
location: locationText,
}),
path,
h1: `${fullTitleCore}${templates.h1Suffix}`,
};
}

View File

@@ -1,3 +1,7 @@
import { DEFAULT_LANGUAGE, type AppLanguage } from "@/lib/i18n/registry";
import { formatLocalizedSeoTitle } from "@/lib/i18n/seo";
import { translateCommon } from "@/lib/i18n/translate";
type CourseSeoInput = {
_id?: string;
cuorse_name?: string;
@@ -10,22 +14,39 @@ type CourseSeoInput = {
finalPrice?: number;
};
export function formatCoursePriceLabel(course: CourseSeoInput): string {
if (course.is_free) return "رایگان";
export function formatCoursePriceLabel(
course: CourseSeoInput,
lang: AppLanguage = DEFAULT_LANGUAGE
): string {
if (course.is_free) {
return translateCommon(lang, "academy.course.free");
}
const basePrice = Number(course.finalPrice ?? course.price) || 0;
return `${basePrice.toLocaleString("fa-IR")} تومان`;
const locale = lang === "fa" ? "fa-IR" : "en-US";
return translateCommon(lang, "courseSeo.priceToman", {
price: basePrice.toLocaleString(locale),
});
}
export function buildCourseDetailSeo(course: CourseSeoInput, id: string) {
const courseName = course.cuorse_name || "پکیج آموزشی";
const category = course.category || "دسته‌بندی";
const teacher = course.teacher_name || "مدرس";
export function buildCourseDetailSeo(
course: CourseSeoInput,
id: string,
lang: AppLanguage = DEFAULT_LANGUAGE
) {
const courseName =
course.cuorse_name ||
translateCommon(lang, "courseSeo.defaultPackage");
const category =
course.category || translateCommon(lang, "courseSeo.defaultCategory");
const teacher =
course.teacher_name || translateCommon(lang, "courseSeo.defaultTeacher");
const rawCaption = (course.caption || "").replace(/<[^>]*>?/gm, "").trim();
const priceLabel = formatCoursePriceLabel(course);
const priceLabel = formatCoursePriceLabel(course, lang);
const titleCore = `${courseName} – ${category} – ${teacher}`;
return {
title: `${courseName} – ${category} – ${teacher} | مدستاگرام`,
title: formatLocalizedSeoTitle(titleCore, lang),
description: [rawCaption || courseName, priceLabel].filter(Boolean).join(" - "),
path: `/academy/${id}/${encodeURIComponent(courseName)}`,
};

170
src/lib/buildHomeListSeo.ts Normal file
View File

@@ -0,0 +1,170 @@
import {
buildLocalizedLocationLabel,
formatLocalizedSeoTitle,
getListSeoTemplates,
getSeoPage,
interpolateTemplate,
} from "@/lib/i18n/seo";
import { DEFAULT_LANGUAGE, type AppLanguage } from "@/lib/i18n/registry";
import provinces from "@/data/provinces.json";
import cities from "@/data/cities.json";
import { normalizeUserLevel, parseUserLevelFromSlug } from "@/lib/userLevel";
import { parseExpertiseFromSlug } from "@/lib/expertiseColors";
export type HomeListFilters = {
expertise?: string;
province?: string;
city?: string;
userLevel?: string;
provinceName?: string;
cityName?: string;
};
export function resolveHomeListFilters(
slugSegments: string[] = [],
filters: Omit<HomeListFilters, "provinceName" | "cityName"> & {
rateFilter?: string;
hashtag?: string;
}
): HomeListFilters {
let provinceName = "";
let cityName = "";
let expertise = filters.expertise || "";
let userLevel = normalizeUserLevel(filters.userLevel || "");
let provinceId = filters.province || "";
let cityId = filters.city || "";
if (slugSegments.length > 0) {
const fullText = decodeURIComponent(slugSegments[0]).replace(/-/g, " ");
const parsedExpertise = parseExpertiseFromSlug(fullText);
if (parsedExpertise) expertise = parsedExpertise;
else if (fullText.includes("متخصصین")) expertise = "";
const parsedLevel = parseUserLevelFromSlug(fullText);
if (parsedLevel) userLevel = parsedLevel;
if (fullText.includes("در شهر")) {
const cityLabel = fullText.split("در شهر")[1]?.trim();
const foundCity = cities.find((c) => cityLabel?.includes(c.name));
if (foundCity) {
cityId = foundCity.id.toString();
cityName = foundCity.name;
}
} else if (fullText.includes("در استان")) {
const provinceLabel = fullText.split("در استان")[1]?.trim();
const foundProvince = provinces.find((p) =>
provinceLabel?.includes(p.name)
);
if (foundProvince) {
provinceId = foundProvince.id.toString();
provinceName = foundProvince.name;
}
}
if (!provinceName && !cityName) {
const province = provinces.find((p) => p.slug === slugSegments[0]);
if (province) {
provinceId = province.id.toString();
provinceName = province.name;
if (slugSegments[1]) {
const city = cities.find((c) => c.slug === slugSegments[1]);
if (city) {
cityId = city.id.toString();
cityName = city.name;
}
}
}
}
}
if (!cityName && cityId) {
const city = cities.find((c) => c.id.toString() === cityId);
if (city) cityName = city.name;
} else if (!provinceName && provinceId) {
const province = provinces.find((p) => p.id.toString() === provinceId);
if (province) provinceName = province.name;
}
return {
expertise,
province: provinceId,
city: cityId,
userLevel,
provinceName,
cityName,
};
}
export function buildHomeListPath(
filters: HomeListFilters,
slugPath?: string
): string {
if (slugPath) return slugPath.startsWith("/") ? slugPath : `/${slugPath}`;
return getSeoPage("home", DEFAULT_LANGUAGE).path;
}
export function buildHomeListSeo(
filters: HomeListFilters,
slugPath?: string,
lang: AppLanguage = DEFAULT_LANGUAGE
) {
const homePage = getSeoPage("home", lang);
const templates = getListSeoTemplates(lang).home as Record<string, string>;
const hasFilters = Boolean(
filters.expertise ||
filters.city ||
filters.province ||
filters.userLevel ||
filters.cityName ||
filters.provinceName
);
if (!hasFilters) {
return {
title: homePage.title,
description: homePage.description,
path: homePage.path,
h1: homePage.h1 ?? homePage.title,
};
}
const location = buildLocalizedLocationLabel(
{
cityName: filters.cityName,
provinceName: filters.provinceName,
},
lang
);
const expertisePart = filters.expertise
? interpolateTemplate(templates.expertisePart, {
expertise: filters.expertise,
})
: templates.defaultExpertisePart;
const levelPart = filters.userLevel
? interpolateTemplate(templates.levelPart, { level: filters.userLevel })
: "";
const titleCore = [templates.titlePrefix, expertisePart, levelPart, location]
.filter(Boolean)
.join(" ")
.replace(/\s+/g, " ")
.trim();
const expertiseLabel =
filters.expertise || templates.defaultExpertiseLabel;
const locationText = location ? ` ${location}` : "";
return {
title: formatLocalizedSeoTitle(titleCore, lang),
description: interpolateTemplate(templates.filteredDescription, {
expertise: expertiseLabel,
location: locationText,
}),
path: buildHomeListPath(filters, slugPath),
h1: titleCore,
};
}

135
src/lib/buildProfileSeo.ts Normal file
View File

@@ -0,0 +1,135 @@
import { SITE_URL } from "@/config/pageSeo";
import { DEFAULT_LANGUAGE, type AppLanguage } from "@/lib/i18n/registry";
import { formatLocalizedSeoTitle, getSiteSeoMeta } from "@/lib/i18n/seo";
import { translateCommon } from "@/lib/i18n/translate";
import { User } from "@/types/types";
export function buildProfileSeo(
user: User | null,
username: string,
lang: AppLanguage = DEFAULT_LANGUAGE
) {
const path = `/users/${username}`;
const siteName = getSiteSeoMeta(lang).name;
if (!user) {
const titleCore = translateCommon(lang, "profileSeo.guestTitle", {
username,
});
return {
title: formatLocalizedSeoTitle(titleCore, lang),
description: translateCommon(lang, "profileSeo.guestDescription", {
username,
siteName,
}),
path,
h1: titleCore,
index: false,
};
}
if (user.blocked_you) {
const unavailableTitle = translateCommon(
lang,
"profileSeo.unavailableTitle"
);
return {
title: formatLocalizedSeoTitle(unavailableTitle, lang),
description: translateCommon(lang, "profileSeo.unavailableDescription"),
path,
h1: unavailableTitle,
index: false,
};
}
const fullName = `${user.first_name || ""} ${user.last_name || ""}`.trim();
const city = user.city?.name || "";
const expertise =
user.expertise ||
translateCommon(lang, "profileSeo.defaultExpertise");
const bio =
user.bio ||
translateCommon(lang, "profileSeo.defaultBio", { siteName });
const cityPart = city
? translateCommon(lang, "profileSeo.inCity", { city })
: "";
const titleCore = [fullName || username, expertise, cityPart]
.filter(Boolean)
.join(" – ");
const description = [fullName, expertise, city, bio]
.filter(Boolean)
.join(" – ");
const isIndexable = !user.is_private && !user.private_content_locked;
const citySuffix = city
? translateCommon(lang, "profileSeo.titleInCity", { city })
: "";
return {
title: formatLocalizedSeoTitle(titleCore, lang),
description,
path,
h1: fullName
? `${fullName} – ${expertise}${citySuffix}`
: `${username} – ${expertise}`,
index: isIndexable,
fullName,
expertise,
city,
bio,
profileUrl: `${SITE_URL}${path}`,
};
}
export function buildProfileJsonLd(
user: User,
username: string,
profileUrl: string,
lang: AppLanguage = DEFAULT_LANGUAGE
) {
const fullName = `${user.first_name || ""} ${user.last_name || ""}`.trim();
const siteName = getSiteSeoMeta(lang).name;
return {
"@context": "https://schema.org",
"@type": "ProfilePage",
name: fullName || username,
url: profileUrl,
mainEntity: {
"@type": "Person",
name: fullName || username,
alternateName: username,
description: user.bio,
image: user.profile_image,
jobTitle: user.expertise,
address: user.city?.name
? {
"@type": "PostalAddress",
addressLocality: user.city.name,
addressCountry: "IR",
}
: undefined,
url: profileUrl,
},
breadcrumb: {
"@type": "BreadcrumbList",
itemListElement: [
{
"@type": "ListItem",
position: 1,
name: siteName,
item: SITE_URL,
},
{
"@type": "ListItem",
position: 2,
name: fullName || username,
item: profileUrl,
},
],
},
};
}

View File

@@ -1,18 +1,63 @@
import { Project } from "@/types/types";
import { DEFAULT_LANGUAGE, type AppLanguage } from "@/lib/i18n/registry";
import { formatLocalizedSeoTitle, getSiteSeoMeta } from "@/lib/i18n/seo";
import { translateCommon } from "@/lib/i18n/translate";
export function formatProjectBudget(price: number | null | undefined): string {
export function formatProjectBudget(
price: number | null | undefined,
lang: AppLanguage = DEFAULT_LANGUAGE
): string {
if (price == null || Number.isNaN(Number(price))) return "";
return `${Number(price).toLocaleString("fa-IR")} تومان`;
const locale = lang === "fa" ? "fa-IR" : "en-US";
return translateCommon(lang, "courseSeo.priceToman", {
price: Number(price).toLocaleString(locale),
});
}
export function buildProjectDetailSeo(project: Project) {
const title = `${project.title} ثبت شده در ${project.expertise} - مدستاگرام`;
const budget = formatProjectBudget(project.offer_price);
export function buildProjectDetailSeo(
project: Project,
lang: AppLanguage = DEFAULT_LANGUAGE
) {
const siteName = getSiteSeoMeta(lang).name;
const titleCore = translateCommon(lang, "projectSeo.registeredInExpertise", {
title: project.title,
expertise: project.expertise,
});
const budget = formatProjectBudget(project.offer_price, lang);
const description = [budget, project.description?.trim()]
.filter(Boolean)
.join(" – ");
const path = `/projects/${project._id}/${encodeURIComponent(project.title)}`;
return { title, description, path };
return {
title: formatLocalizedSeoTitle(titleCore, lang),
description:
description ||
translateCommon(lang, "projectSeo.detailDescription", {
title: project.title,
siteName,
}),
path,
};
}
export function buildProjectDetailFallbackSeo(
title: string,
id: string,
lang: AppLanguage = DEFAULT_LANGUAGE
) {
const siteName = getSiteSeoMeta(lang).name;
const decodedTitle =
title ||
translateCommon(lang, "projectSeo.fallbackProject");
return {
title: formatLocalizedSeoTitle(decodedTitle, lang),
description: translateCommon(lang, "projectSeo.detailDescription", {
title: decodedTitle,
siteName,
}),
path: `/projects/${id}/${encodeURIComponent(decodedTitle)}`,
};
}

View File

@@ -1,3 +1,11 @@
import {
formatLocalizedSeoTitle,
getListSeoTemplates,
getSeoPage,
interpolateTemplate,
} from "@/lib/i18n/seo";
import { DEFAULT_LANGUAGE, type AppLanguage } from "@/lib/i18n/registry";
export type ProjectListFilters = {
expertise?: string;
most_requests?: string;
@@ -6,39 +14,43 @@ export type ProjectListFilters = {
gender?: string;
};
const BASE_TITLE = "پروژه ها و فرصتهای همکاری در دنیای زیبایی";
const SITE_SUFFIX = " | مدستاگرام";
export function getProjectsListDescription(lang: AppLanguage = DEFAULT_LANGUAGE) {
return getSeoPage("projects", lang).description;
}
export const PROJECTS_LIST_DESCRIPTION =
"پروژه‌های فعال در حوزه زیبایی را ببینید، درخواست همکاری بدهید یا پروژه جدیدی برای مدل‌ها، عکاس‌ها و آرایشگرها منتشر کنید.";
/** @deprecated Use getProjectsListDescription(lang) */
export const PROJECTS_LIST_DESCRIPTION = getProjectsListDescription();
const AGE_LABELS: Record<string, string> = {
"18-25": "18 تا 25 سال",
old: "25 تا 30 سال",
};
const GENDER_LABELS: Record<string, string> = {
female: "خانم",
male: "آقا",
};
function buildFilterParts(filters: ProjectListFilters): string[] {
function buildFilterParts(
filters: ProjectListFilters,
lang: AppLanguage
): string[] {
const filterLabels = getListSeoTemplates(lang).projects.filters as Record<
string,
string
>;
const parts: string[] = [];
if (filters.expertise) {
parts.push(filters.expertise);
}
if (filters.age && filters.age !== "all" && AGE_LABELS[filters.age]) {
parts.push(AGE_LABELS[filters.age]);
if (filters.age && filters.age !== "all") {
if (filters.age === "18-25" && filterLabels.age18_25) {
parts.push(filterLabels.age18_25);
} else if (filters.age === "old" && filterLabels.ageOld) {
parts.push(filterLabels.ageOld);
}
}
if (filters.gender && GENDER_LABELS[filters.gender]) {
parts.push(GENDER_LABELS[filters.gender]);
if (filters.gender === "female" && filterLabels.genderFemale) {
parts.push(filterLabels.genderFemale);
} else if (filters.gender === "male" && filterLabels.genderMale) {
parts.push(filterLabels.genderMale);
}
if (filters.most_price) {
parts.push("با بیشترین بودجه");
if (filters.most_price && filterLabels.mostPrice) {
parts.push(filterLabels.mostPrice);
}
if (filters.most_requests) {
parts.push("با بیشترین پیشنهاد");
if (filters.most_requests && filterLabels.mostRequests) {
parts.push(filterLabels.mostRequests);
}
return parts;
@@ -57,16 +69,33 @@ export function buildProjectsListPath(filters: ProjectListFilters): string {
return query ? `/projects?${query}` : "/projects";
}
export function buildProjectsListSeo(filters: ProjectListFilters) {
const filterParts = buildFilterParts(filters);
const filterSegment = filterParts.length
? ` برای ${filterParts.join(" ")}`
: "";
export function buildProjectsListSeo(
filters: ProjectListFilters,
lang: AppLanguage = DEFAULT_LANGUAGE
) {
const projectsPage = getSeoPage("projects", lang);
const templates = getListSeoTemplates(lang).projects as Record<string, string>;
const filterParts = buildFilterParts(filters, lang);
const hasFilters = filterParts.length > 0;
if (!hasFilters) {
return {
title: projectsPage.title,
description: projectsPage.description,
path: projectsPage.path,
h1: projectsPage.h1 ?? templates.baseTitle,
};
}
const filterSegment = `${templates.filterPrefix}${filterParts.join(" ")}`;
const titleCore = `${templates.baseTitle}${filterSegment}`.trim();
return {
title: `${BASE_TITLE}${filterSegment}${SITE_SUFFIX}`,
description: PROJECTS_LIST_DESCRIPTION,
title: formatLocalizedSeoTitle(titleCore, lang),
description: interpolateTemplate(templates.filteredDescription, {
filters: filterParts.join(" "),
}),
path: buildProjectsListPath(filters),
h1: `${BASE_TITLE}${filterSegment}`,
h1: titleCore,
};
}

41
src/lib/captionLength.ts Normal file
View File

@@ -0,0 +1,41 @@
import {
buildPostLinkCaptionHeader,
SelectedPostLink,
} from "@/lib/postLinkCaption";
import type { AppLanguage } from "@/lib/i18n/registry";
export const POST_CAPTION_MAX_LENGTH = 2000;
export function buildFullPostCaption(
description: string,
taggedUsers: Array<{ user_name: string }>,
packages: SelectedPostLink[],
projects: SelectedPostLink[],
billboards: SelectedPostLink[],
lang?: AppLanguage
): string {
const tagLine = taggedUsers.map((u) => `@${u.user_name}`).join(" ");
const linkHeader = buildPostLinkCaptionHeader(
[...packages, ...projects, ...billboards],
lang
);
return [description.trim(), tagLine, linkHeader].filter(Boolean).join("\n");
}
export function countPostCaptionLength(
description: string,
taggedUsers: Array<{ user_name: string }>,
packages: SelectedPostLink[],
projects: SelectedPostLink[],
billboards: SelectedPostLink[],
lang?: AppLanguage
): number {
return buildFullPostCaption(
description,
taggedUsers,
packages,
projects,
billboards,
lang
).length;
}

View File

@@ -1,11 +1,34 @@
import type { ChatMessage } from "@/components/chat/ChatMessageCard";
export function parseMessageTime(message: ChatMessage): number {
const withIso = message as ChatMessage & { createdAtIso?: string };
if (withIso.createdAtIso) {
const t = new Date(withIso.createdAtIso).getTime();
if (!Number.isNaN(t)) return t;
}
if (message.createdAt?.includes("T")) {
const t = new Date(message.createdAt).getTime();
if (!Number.isNaN(t)) return t;
}
return 0;
}
const PENDING_MATCH_WINDOW_MS = 120_000;
function matchesPendingServer(server: ChatMessage, pending: ChatMessage): boolean {
if (String(server.senderId) !== String(pending.senderId)) return false;
if (pending.fileType !== server.fileType) return false;
if (String(pending.fileType || "") !== String(server.fileType || "")) return false;
const pendingTime = parseMessageTime(pending);
const serverTime = parseMessageTime(server);
if (pendingTime && serverTime) {
if (Math.abs(serverTime - pendingTime) > PENDING_MATCH_WINDOW_MS) return false;
}
if (pending.fileType === "location" || (!pending.fileType && !pending.file)) {
return server.content === pending.content;
}
return Boolean(pending.fileType && server.fileType === pending.fileType);
}
@@ -20,10 +43,10 @@ export function filterResolvedPendingMessages(
server: ChatMessage[],
pending: ChatMessage[]
): ChatMessage[] {
return pending.filter(
(p) =>
!server.some((s) => s._id === p._id || matchesPendingServer(s, p))
);
return pending.filter((p) => {
if (server.some((s) => String(s._id) === String(p._id))) return false;
return !server.some((s) => matchesPendingServer(s, p));
});
}
export function dedupeChatMessages(messages: ChatMessage[]): ChatMessage[] {

View File

@@ -1,9 +1,63 @@
/** Parse message timestamp (ISO or datetime string) for grouping */
import { DEFAULT_LANGUAGE, type AppLanguage } from "@/lib/i18n/registry";
import { translateCommon } from "@/lib/i18n/translate";
function normalizeJalaliPrefix(raw: string): string | null {
const match = raw.match(/^(\d{4})-(\d{1,2})-(\d{1,2})/);
if (!match) return null;
return `${match[1]}-${Number(match[2])}-${Number(match[3])}`;
}
function getTodayJalaliPrefix(): string | null {
try {
const parts = new Intl.DateTimeFormat("en-u-ca-persian", {
year: "numeric",
month: "numeric",
day: "numeric",
}).formatToParts(new Date());
const year = parts.find((p) => p.type === "year")?.value;
const month = parts.find((p) => p.type === "month")?.value;
const day = parts.find((p) => p.type === "day")?.value;
if (!year || !month || !day) return null;
return `${year}-${Number(month)}-${Number(day)}`;
} catch {
return null;
}
}
function getYesterdayJalaliPrefix(): string | null {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
try {
const parts = new Intl.DateTimeFormat("en-u-ca-persian", {
year: "numeric",
month: "numeric",
day: "numeric",
}).formatToParts(yesterday);
const year = parts.find((p) => p.type === "year")?.value;
const month = parts.find((p) => p.type === "month")?.value;
const day = parts.find((p) => p.type === "day")?.value;
if (!year || !month || !day) return null;
return `${year}-${Number(month)}-${Number(day)}`;
} catch {
return null;
}
}
/** Parse message timestamp (ISO, Jalali, or datetime string) for grouping */
export function parseMessageDate(createdAt?: string | null): Date | null {
if (!createdAt) return null;
const raw = String(createdAt).trim();
const d = new Date(raw);
if (!Number.isNaN(d.getTime())) return d;
const jalaliPrefix = normalizeJalaliPrefix(raw);
if (jalaliPrefix) {
const [y, m, d] = jalaliPrefix.split("-").map(Number);
return new Date(y, m - 1, d);
}
const parsed = new Date(raw);
if (!Number.isNaN(parsed.getTime())) return parsed;
return null;
}
@@ -11,40 +65,98 @@ function isSameDay(a: Date, b: Date): boolean {
return a.toDateString() === b.toDateString();
}
/** Telegram-style date label: امروز، دیروز، or calendar date */
export function formatChatDateLabel(createdAt?: string | null): string {
function formatLocalizedDate(
date: Date,
withYear: boolean,
lang: AppLanguage
): string {
const locale = lang === "fa" ? "fa-IR" : "en-US";
try {
if (withYear) {
return date.toLocaleDateString(locale, {
year: "numeric",
month: "long",
day: "numeric",
});
}
return date.toLocaleDateString(locale, {
month: "long",
day: "numeric",
});
} catch {
return date.toDateString();
}
}
/** Telegram-style date label: today, yesterday, or calendar date */
export function formatChatDateLabel(
createdAt?: string | null,
lang: AppLanguage = DEFAULT_LANGUAGE
): string {
const raw = createdAt ? String(createdAt).trim() : "";
const jalaliPrefix = normalizeJalaliPrefix(raw);
if (jalaliPrefix) {
const today = getTodayJalaliPrefix();
const yesterday = getYesterdayJalaliPrefix();
if (today && jalaliPrefix === today) {
return translateCommon(lang, "chats.today");
}
if (yesterday && jalaliPrefix === yesterday) {
return translateCommon(lang, "chats.yesterday");
}
const [y, m, d] = jalaliPrefix.split("-").map(Number);
const date = new Date(y, m - 1, d);
if (!Number.isNaN(date.getTime())) {
return formatLocalizedDate(date, y !== new Date().getFullYear(), lang);
}
return jalaliPrefix.replace(/-/g, "/");
}
const d = parseMessageDate(createdAt) ?? new Date();
const today = new Date();
const yesterday = new Date();
yesterday.setDate(today.getDate() - 1);
if (isSameDay(d, today)) return "امروز";
if (isSameDay(d, yesterday)) return "دیروز";
if (isSameDay(d, today)) return translateCommon(lang, "chats.today");
if (isSameDay(d, yesterday)) return translateCommon(lang, "chats.yesterday");
if (d.getFullYear() === today.getFullYear()) {
return d.toLocaleDateString("fa-IR", { month: "long", day: "numeric" });
return formatLocalizedDate(d, false, lang);
}
return d.toLocaleDateString("fa-IR", {
year: "numeric",
month: "long",
day: "numeric",
});
return formatLocalizedDate(d, true, lang);
}
/** Bubble time: hours, minutes, seconds only (no date) */
export function formatBubbleTime(createdAt?: string | null): string {
export function formatBubbleTime(
createdAt?: string | null,
lang: AppLanguage = DEFAULT_LANGUAGE
): string {
if (!createdAt) {
return new Date().toLocaleTimeString("fa-IR", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
return "";
}
const raw = String(createdAt).trim();
const locale = lang === "fa" ? "fa-IR" : "en-US";
// ISO datetime — always prefer real clock time
if (raw.includes("T")) {
const d = new Date(raw);
if (!Number.isNaN(d.getTime())) {
try {
return d.toLocaleTimeString(locale, {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
} catch {
return raw;
}
}
}
// Jalali datetime "1403-01-15 14:30:45" or ISO
const timePart = raw.includes(" ")
? raw.split(" ").pop()
: raw.length <= 8 && raw.includes(":")
@@ -57,13 +169,17 @@ export function formatBubbleTime(createdAt?: string | null): string {
return timePart;
}
const d = new Date(raw);
const d = parseMessageDate(raw) ?? new Date(raw);
if (!Number.isNaN(d.getTime())) {
return d.toLocaleTimeString("fa-IR", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
try {
return d.toLocaleTimeString(locale, {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
} catch {
return raw;
}
}
return raw;

View File

@@ -0,0 +1,111 @@
import { parseLocationContent } from "@/components/chat/LocationMessageBubble";
import { parseSharedPost } from "@/components/chat/SharedPostBubble";
import {
extractStoryReactionText,
parseSharedStory,
} from "@/components/chat/SharedStoryBubble";
import type { ChatMessage } from "@/components/chat/ChatMessageCard";
function parseForwarded(content: string): ChatMessage["forwardedFrom"] | null {
try {
const line = content.split("\n")[0];
const parsed = JSON.parse(line);
return parsed.forwardedFrom ?? null;
} catch {
return null;
}
}
function stripJsonPrefix(content: string): string {
return content.replace(/^\{.*\}\n?/, "").trim();
}
function getTextAfterFirstLine(content: string): string {
const lines = content.split("\n");
if (lines.length <= 1) return "";
return lines.slice(1).join("\n").trim();
}
type CopyableMessage = {
content?: string;
file?: string;
fileType?: ChatMessage["fileType"];
forwardedFrom?: ChatMessage["forwardedFrom"];
};
export function getCopyableMessageText(
message: CopyableMessage
): string | null {
const content = message.content?.trim() || "";
if (!content && !message.file) return null;
const location =
message.fileType === "location" ? parseLocationContent(content) : null;
if (location) {
const label = location.label?.trim();
const coords = `${location.lat}, ${location.lng}`;
return label ? `${label}\n${coords}` : coords;
}
const sharedPost = parseSharedPost(content);
if (sharedPost) {
const caption = sharedPost.caption?.trim();
if (caption) return caption;
return null;
}
const sharedStory = parseSharedStory(content);
if (sharedStory) {
const reactionText = extractStoryReactionText(content);
return reactionText || null;
}
const forwarded = message.forwardedFrom || parseForwarded(content);
if (forwarded) {
return getTextAfterFirstLine(content) || null;
}
if (parseLocationContent(content)) {
const loc = parseLocationContent(content)!;
const label = loc.label?.trim();
const coords = `${loc.lat}, ${loc.lng}`;
return label ? `${label}\n${coords}` : coords;
}
const text = stripJsonPrefix(content);
return text || null;
}
export function getCopyableRoomMessageText(content?: string): string | null {
const text = content?.trim();
return text || null;
}
export async function copyTextToClipboard(text: string): Promise<boolean> {
if (typeof window === "undefined") return false;
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
} catch {
// fallback below
}
try {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "");
textarea.style.position = "fixed";
textarea.style.opacity = "0";
textarea.style.pointerEvents = "none";
document.body.appendChild(textarea);
textarea.select();
const copied = document.execCommand("copy");
document.body.removeChild(textarea);
return copied;
} catch {
return false;
}
}

View File

@@ -3,6 +3,7 @@ import {
formatChatDateLabel,
parseMessageDate,
} from "@/lib/chat/formatMessageTime";
import { DEFAULT_LANGUAGE, type AppLanguage } from "@/lib/i18n/registry";
export type MessageGroup =
| { type: "date"; label: string; key: string }
@@ -14,7 +15,10 @@ function dayKey(msg: ChatMessage): string {
return "unknown-day";
}
export function groupMessagesByDate(messages: ChatMessage[]): MessageGroup[] {
export function groupMessagesByDate(
messages: ChatMessage[],
lang: AppLanguage = DEFAULT_LANGUAGE
): MessageGroup[] {
const groups: MessageGroup[] = [];
let lastDay = "";
@@ -26,7 +30,7 @@ export function groupMessagesByDate(messages: ChatMessage[]): MessageGroup[] {
lastDay = dk;
groups.push({
type: "date",
label: formatChatDateLabel(msg.createdAtIso ?? msg.createdAt),
label: formatChatDateLabel(msg.createdAtIso ?? msg.createdAt, lang),
key: `date-${dk}`,
});
}

View File

@@ -78,3 +78,8 @@ export function getMessageRowSpacing(
if (isGroupedWithPrev) return "mt-[2px]";
return "mt-3";
}
/** Media bubbles always use fully rounded corners (photo/video/file preview). */
export function getMediaBubbleRadius(): string {
return "18px";
}

View File

@@ -0,0 +1,20 @@
/** Legacy API values returned before i18n rollout */
const LEGACY_ONLINE_VALUES = new Set(["آنلاین", "Online"]);
export function isUserOnline(
lastOnline?: string | null,
onlineLabel?: string
): boolean {
if (!lastOnline) return false;
if (onlineLabel && lastOnline === onlineLabel) return true;
return LEGACY_ONLINE_VALUES.has(lastOnline);
}
export function resolveOnlineLabel(
lastOnline: string | undefined,
onlineLabel: string,
offlineLabel: string
): string {
if (isUserOnline(lastOnline, onlineLabel)) return onlineLabel;
return lastOnline || offlineLabel;
}

15
src/lib/chat/reactions.ts Normal file
View File

@@ -0,0 +1,15 @@
export const QUICK_REACTIONS = ["❤️", "😂", "👍", "😮", "😢", "🙏"] as const;
export type MessageReaction = {
userId: string;
emoji: string;
};
export function groupReactions(reactions: MessageReaction[] | undefined) {
if (!reactions?.length) return [];
const map = new Map<string, number>();
for (const r of reactions) {
map.set(r.emoji, (map.get(r.emoji) ?? 0) + 1);
}
return [...map.entries()].map(([emoji, count]) => ({ emoji, count }));
}

View File

@@ -3,10 +3,36 @@ import { SOCKET_URL } from "@/components/main/BaseUrl";
let sharedSocket: Socket | null = null;
let refCount = 0;
const connectionListeners = new Set<(connected: boolean) => void>();
type PendingGroupJoin = { roomId: string; userId: string };
const pendingGroupJoins = new Map<string, PendingGroupJoin>();
let pendingUserId: string | null = null;
function notifyConnectionListeners(connected: boolean) {
connectionListeners.forEach((fn) => fn(connected));
}
function flushPendingJoins(): void {
if (!sharedSocket?.connected) return;
if (pendingUserId) {
sharedSocket.emit("joinUser", { userId: pendingUserId });
}
for (const { roomId, userId } of pendingGroupJoins.values()) {
sharedSocket.emit("joinGroupRoom", { roomId, userId });
}
}
export function resolveSocketUrl(): string {
return process.env.NEXT_PUBLIC_SOCKET_URL || SOCKET_URL;
}
export function acquireChatSocket(): Socket {
if (!sharedSocket) {
sharedSocket = io(SOCKET_URL, {
sharedSocket = io(resolveSocketUrl(), {
path: "/socket.io/",
transports: ["websocket", "polling"],
reconnection: true,
reconnectionAttempts: Infinity,
@@ -16,6 +42,13 @@ export function acquireChatSocket(): Socket {
withCredentials: true,
autoConnect: true,
});
sharedSocket.on("connect", () => {
notifyConnectionListeners(true);
flushPendingJoins();
});
sharedSocket.on("disconnect", () => notifyConnectionListeners(false));
sharedSocket.on("connect_error", () => notifyConnectionListeners(false));
}
refCount += 1;
return sharedSocket;
@@ -35,13 +68,19 @@ export function disconnectChatSocket(): void {
sharedSocket.disconnect();
sharedSocket = null;
refCount = 0;
pendingGroupJoins.clear();
pendingUserId = null;
notifyConnectionListeners(false);
}
}
export function joinUserRoom(userId: string): void {
const id = String(userId).trim();
if (!id || !sharedSocket) return;
sharedSocket.emit("joinUser", { userId: id });
if (!id) return;
pendingUserId = id;
if (sharedSocket?.connected) {
sharedSocket.emit("joinUser", { userId: id });
}
}
export function joinChatRoom(userId: string, receiverId: string): void {
@@ -51,6 +90,30 @@ export function joinChatRoom(userId: string, receiverId: string): void {
sharedSocket.emit("joinChat", { userId: uid, receiverId: rid });
}
export function joinGroupRoom(roomId: string, userId: string): void {
const rid = String(roomId).trim();
const uid = String(userId).trim();
if (!rid || !uid) return;
pendingGroupJoins.set(rid, { roomId: rid, userId: uid });
if (sharedSocket?.connected) {
sharedSocket.emit("joinGroupRoom", { roomId: rid, userId: uid });
}
}
export function leaveGroupRoom(roomId: string): void {
pendingGroupJoins.delete(String(roomId).trim());
}
export function isChatSocketConnected(): boolean {
return Boolean(sharedSocket?.connected);
}
export function subscribeChatSocketConnection(
listener: (connected: boolean) => void
): () => void {
connectionListeners.add(listener);
listener(isChatSocketConnected());
return () => {
connectionListeners.delete(listener);
};
}

View File

@@ -1,5 +1,6 @@
import type { InfiniteData } from "@tanstack/react-query";
import type { ChatMessage } from "@/components/chat/ChatMessageCard";
import { parseMessageTime } from "./dedupeMessages";
export type ChatThreadPage = { messages: ChatMessage[]; nextPage?: number };
export type ChatThreadData = InfiniteData<ChatThreadPage, number>;
@@ -64,3 +65,24 @@ export function upsertMessageInThreadCache(
return appendMessageToThreadCache(oldData, message);
}
/** هنگام refetch، پیام‌های تازهٔ cache که هنوز در پاسخ API نیستند حفظ می‌شوند */
export function mergeFetchedThreadPage(
fetched: ChatMessage[],
cachedFirstPage: ChatMessage[] | undefined
): ChatMessage[] {
if (!cachedFirstPage?.length) return fetched;
const fetchedIds = new Set(fetched.map((m) => String(m._id)));
const preserved = cachedFirstPage.filter(
(m) =>
/^[a-f\d]{24}$/i.test(String(m._id)) &&
!fetchedIds.has(String(m._id))
);
if (!preserved.length) return fetched;
return [...fetched, ...preserved].sort(
(a, b) => parseMessageTime(a) - parseMessageTime(b)
);
}

View File

@@ -1,15 +1,36 @@
export const TIMED_MESSAGE_OPTIONS = [
{ label: "خاموش", value: null as number | null },
{ label: "۱۰ ثانیه", value: 10 },
{ label: "۳۰ ثانیه", value: 30 },
{ label: "۱ دقیقه", value: 60 },
{ label: "۵ دقیقه", value: 300 },
{ label: "۱ ساعت", value: 3600 },
import type { TFunction } from "i18next";
export const TIMED_MESSAGE_SECONDS = [
null,
10,
30,
60,
300,
3600,
] as const;
export function formatTimedLabel(seconds: number | null): string {
const opt = TIMED_MESSAGE_OPTIONS.find((o) => o.value === seconds);
return opt?.label ?? "خاموش";
const TIMED_MESSAGE_KEYS = [
"chats.timed.off",
"chats.timed.10s",
"chats.timed.30s",
"chats.timed.1m",
"chats.timed.5m",
"chats.timed.1h",
] as const;
export function getTimedMessageOptions(t: TFunction) {
return TIMED_MESSAGE_SECONDS.map((value, index) => ({
value,
label: t(TIMED_MESSAGE_KEYS[index]),
}));
}
export function formatTimedLabel(seconds: number | null, t: TFunction): string {
const index = TIMED_MESSAGE_SECONDS.indexOf(
seconds as (typeof TIMED_MESSAGE_SECONDS)[number]
);
if (index >= 0) return t(TIMED_MESSAGE_KEYS[index]);
return t("chats.timed.off");
}
export function getRemainingSeconds(expiresAt?: string | null): number | null {

View File

@@ -8,15 +8,20 @@ export function isViewOnceMediaType(
return VIEW_ONCE_MEDIA_TYPES.includes(fileType as ViewOnceMediaType);
}
export function viewOnceLabel(fileType?: string): string {
import type { TFunction } from "i18next";
export function viewOnceLabel(
fileType: string | undefined,
t: TFunction
): string {
switch (fileType) {
case "image":
return "عکس یک‌بار مصرف";
return t("chats.viewOnce.image");
case "video":
return "فیلم یک‌بار مصرف";
return t("chats.viewOnce.video");
case "voice":
return "ویس یک‌بار مصرف";
return t("chats.viewOnce.voice");
default:
return "پیام یک‌بار مصرف";
return t("chats.viewOnce.default");
}
}

View File

@@ -1,15 +1,27 @@
import { Course } from "@/types/types";
import { DEFAULT_LANGUAGE, type AppLanguage } from "@/lib/i18n/registry";
import { translateCommon } from "@/lib/i18n/translate";
export function formatCoursePriceLabel(course: Course): string {
if (course.is_free) return "رایگان";
export function formatCoursePriceLabel(
course: Course,
lang: AppLanguage = DEFAULT_LANGUAGE
): string {
if (course.is_free) {
return translateCommon(lang, "academy.course.free");
}
const price = Number(course.price) || 0;
const offer = Number(course.offer) || 0;
const locale = lang === "fa" ? "fa-IR" : "en-US";
if (offer > 0) {
const finalPrice = Math.round((price * (100 - offer)) / 100);
return `${finalPrice.toLocaleString("fa-IR")} تومان`;
return translateCommon(lang, "courseSeo.priceToman", {
price: finalPrice.toLocaleString(locale),
});
}
return `${price.toLocaleString("fa-IR")} تومان`;
return translateCommon(lang, "courseSeo.priceToman", {
price: price.toLocaleString(locale),
});
}

View File

@@ -0,0 +1,15 @@
export function formatViewCount(count: number): string {
const n = Math.max(0, Math.floor(Number(count) || 0));
if (n >= 1_000_000) {
const v = n / 1_000_000;
return `${v >= 10 ? Math.round(v) : v.toFixed(1).replace(/\.0$/, "")}M`;
}
if (n >= 10_000) {
return `${Math.round(n / 1000)}K`;
}
if (n >= 1000) {
const v = n / 1000;
return `${v.toFixed(1).replace(/\.0$/, "")}K`;
}
return n.toLocaleString("fa-IR");
}

View File

@@ -0,0 +1,47 @@
import {
formatChatDateLabel,
parseMessageDate,
} from "@/lib/chat/formatMessageTime";
import { DEFAULT_LANGUAGE, type AppLanguage } from "@/lib/i18n/registry";
export type DateGroupedItem<T> =
| { type: "date"; label: string; key: string }
| { type: "item"; item: T; key: string };
function dayKey(createdAt?: string | null): string {
const d = parseMessageDate(createdAt);
if (d) return d.toDateString();
return "unknown-day";
}
export function groupItemsByDate<T>(
items: T[],
getCreatedAt: (item: T) => string | null | undefined,
getItemKey: (item: T, index: number) => string,
lang: AppLanguage = DEFAULT_LANGUAGE
): DateGroupedItem<T>[] {
const groups: DateGroupedItem<T>[] = [];
let lastDay = "";
items.forEach((item, index) => {
const createdAt = getCreatedAt(item);
const dk = dayKey(createdAt);
if (dk !== lastDay) {
lastDay = dk;
groups.push({
type: "date",
label: formatChatDateLabel(createdAt, lang),
key: `date-${dk}`,
});
}
groups.push({
type: "item",
item,
key: getItemKey(item, index),
});
});
return groups;
}

View File

@@ -2,32 +2,27 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import HttpApi from "i18next-http-backend";
import {
buildI18nResources,
LOCALE_NAMESPACES,
} from "@/lib/i18n/resources";
import {
DEFAULT_LANGUAGE,
ENABLED_LANGUAGES,
} from "@/lib/i18n/registry";
import { resolveClientLanguage } from "@/lib/i18n/clientLanguage";
i18n
.use(HttpApi)
.use(LanguageDetector)
.use(initReactI18next)
.init({
fallbackLng: "en",
supportedLngs: [
"en", "fa", "ar", "zh", "hi", "es", "sw", "el", "sr",
"fr", "ru", "bn", "pt", "id", "de", "ja", "te", "tr"
],
if (!i18n.isInitialized) {
i18n.use(initReactI18next).init({
lng: typeof window === "undefined" ? DEFAULT_LANGUAGE : resolveClientLanguage(),
fallbackLng: DEFAULT_LANGUAGE,
supportedLngs: [...ENABLED_LANGUAGES],
defaultNS: "common",
ns: ["common"],
debug: process.env.NODE_ENV === "development",
detection: {
order: ["cookie", "localStorage", "navigator", "htmlTag"],
caches: ["cookie"],
},
backend: {
loadPath: "/locales/{{lng}}/{{ns}}.json",
},
react: {
useSuspense: false,
},
ns: [...LOCALE_NAMESPACES],
resources: buildI18nResources(),
interpolation: { escapeValue: false },
react: { useSuspense: false },
});
}
export default i18n;

View File

@@ -0,0 +1,56 @@
import Cookies from "js-cookie";
import {
DEFAULT_LANGUAGE,
isAppLanguage,
LANGUAGE_COOKIE,
type AppLanguage,
} from "./registry";
import {
resolveLanguageFromLanguageTags,
resolveLanguageFromSingleTag,
} from "./resolveLanguage";
export function readStoredLanguagePreference(): AppLanguage | null {
if (typeof window === "undefined") return null;
const cookieMatch = document.cookie
.split("; ")
.find((row) => row.startsWith(`${LANGUAGE_COOKIE}=`))
?.split("=")[1];
if (isAppLanguage(cookieMatch)) return cookieMatch;
const fromStorage = localStorage.getItem(LANGUAGE_COOKIE);
if (isAppLanguage(fromStorage)) return fromStorage;
return null;
}
export function resolveBrowserLanguage(): AppLanguage {
if (typeof navigator === "undefined") return DEFAULT_LANGUAGE;
const tags = [
navigator.language,
...(navigator.languages ?? []),
].filter(Boolean) as string[];
return resolveLanguageFromLanguageTags(tags) ?? DEFAULT_LANGUAGE;
}
/** Client locale: saved preference → system language → default (fa) */
export function resolveClientLanguage(): AppLanguage {
return readStoredLanguagePreference() ?? resolveBrowserLanguage();
}
export function persistLanguagePreference(lang: AppLanguage) {
Cookies.set(LANGUAGE_COOKIE, lang, { expires: 365, sameSite: "lax" });
if (typeof window !== "undefined") {
localStorage.setItem(LANGUAGE_COOKIE, lang);
}
}
export function resolveLanguageFromNavigatorTag(
tag: string | null | undefined
): AppLanguage {
return resolveLanguageFromSingleTag(tag) ?? DEFAULT_LANGUAGE;
}

16
src/lib/i18n/constants.ts Normal file
View File

@@ -0,0 +1,16 @@
/** @deprecated Import from `@/lib/i18n` or `@/lib/i18n/registry` instead */
export {
DEFAULT_LANGUAGE,
ENABLED_LANGUAGES,
getDirection,
getLanguageDefinition,
isAppLanguage,
LANGUAGE_COOKIE,
LANGUAGE_LABELS,
LANGUAGE_REGISTRY,
normalizeAppLanguage,
SUPPORTED_LANGUAGES,
type AppLanguage,
type LanguageDefinition,
type LanguageDirection,
} from "./registry";

76
src/lib/i18n/index.ts Normal file
View File

@@ -0,0 +1,76 @@
import {
DEFAULT_LANGUAGE,
isAppLanguage,
LANGUAGE_COOKIE,
normalizeAppLanguage,
type AppLanguage,
} from "./registry";
import type { ReadonlyRequestCookies } from "next/dist/server/web/spec-extension/adapters/request-cookies";
import { resolveLanguageFromAcceptHeader } from "./resolveLanguage";
export {
DEFAULT_LANGUAGE,
ENABLED_LANGUAGES,
getDirection,
getLanguageDefinition,
isAppLanguage,
LANGUAGE_COOKIE,
LANGUAGE_LABELS,
LANGUAGE_REGISTRY,
normalizeAppLanguage,
SUPPORTED_LANGUAGES,
type AppLanguage,
type LanguageDefinition,
type LanguageDirection,
} from "./registry";
export { syncDocumentLanguage } from "./syncDocumentLanguage";
export { shouldUseLtrLayout, LTR_WHEN_ENGLISH_PREFIXES } from "./pageDirection";
export {
getSeoPage,
getSiteSeoMeta,
getListSeoTemplates,
formatLocalizedSeoTitle,
buildLocalizedLocationLabel,
buildHreflangAlternates,
interpolateTemplate,
} from "./seo";
export type { SeoPageKey, SeoPageMeta, SeoSiteMeta } from "./resources";
export function resolveLanguageFromCookieStore(
cookieStore: Pick<ReadonlyRequestCookies, "get">
): AppLanguage {
const cookieLang = cookieStore.get(LANGUAGE_COOKIE)?.value;
return normalizeAppLanguage(cookieLang);
}
export {
resolveLanguageFromLanguageTags,
resolveLanguageFromAcceptHeader,
resolveLanguageFromSingleTag,
} from "./resolveLanguage";
export {
readStoredLanguagePreference,
resolveBrowserLanguage,
resolveClientLanguage,
persistLanguagePreference,
} from "./clientLanguage";
/** Server-side locale for metadata, SSR, and SEO */
export function resolveServerLanguage(options: {
cookieStore: Pick<ReadonlyRequestCookies, "get">;
acceptLanguage?: string | null;
}): AppLanguage {
const cookieValue = options.cookieStore.get(LANGUAGE_COOKIE)?.value;
if (isAppLanguage(cookieValue)) {
return normalizeAppLanguage(cookieValue);
}
const fromHeader = resolveLanguageFromAcceptHeader(options.acceptLanguage);
if (fromHeader) return fromHeader;
return DEFAULT_LANGUAGE;
}

View File

@@ -0,0 +1,34 @@
import type { AppLanguage } from "./constants";
/**
* Pages that should switch to LTR layout when language is English.
* LTR styling will be applied page-by-page in a later pass.
*/
export const LTR_WHEN_ENGLISH_PREFIXES = [
"/",
"/reels",
"/explore",
"/settings/chats",
"/settings/chats/rooms",
"/academy",
"/projects",
"/new-project",
"/billboards",
"/settings",
"/settings/workroom",
"/users",
] as const;
export function shouldUseLtrLayout(
pathname: string,
language: AppLanguage
): boolean {
if (language !== "en") return false;
const path = pathname.split("?")[0] || "/";
return LTR_WHEN_ENGLISH_PREFIXES.some((prefix) => {
if (prefix === "/") return path === "/";
return path === prefix || path.startsWith(`${prefix}/`);
});
}

16
src/lib/i18n/pwaLang.ts Normal file
View File

@@ -0,0 +1,16 @@
import type { PwaLang } from "@/lib/pwaConfig";
import {
DEFAULT_LANGUAGE,
isAppLanguage,
type AppLanguage,
} from "@/lib/i18n/registry";
import { resolveLanguageFromSingleTag } from "@/lib/i18n/resolveLanguage";
export function resolvePwaLang(input?: string | null): PwaLang {
if (isAppLanguage(input)) return input;
return (resolveLanguageFromSingleTag(input) ?? DEFAULT_LANGUAGE) as PwaLang;
}
export function toPwaLang(lang: AppLanguage): PwaLang {
return lang as PwaLang;
}

100
src/lib/i18n/registry.ts Normal file
View File

@@ -0,0 +1,100 @@
export type LanguageDirection = "rtl" | "ltr";
export type LanguageDefinition = {
/** ISO 639-1 code — also used as folder name under src/locales/ */
code: string;
nativeLabel: string;
englishLabel: string;
direction: LanguageDirection;
htmlLang: string;
ogLocale: string;
dateLocale: string;
/** Show in language picker */
enabled: boolean;
};
/**
* Central language registry.
* To add a new language later:
* 1. Add entry here with enabled: true
* 2. Create src/locales/{code}/common.json + seo.json
* 3. Register bundle in src/lib/i18n/resources.ts
*/
export const LANGUAGE_REGISTRY: Record<string, LanguageDefinition> = {
fa: {
code: "fa",
nativeLabel: "فارسی",
englishLabel: "Persian",
direction: "rtl",
htmlLang: "fa",
ogLocale: "fa_IR",
dateLocale: "fa-IR",
enabled: true,
},
en: {
code: "en",
nativeLabel: "English",
englishLabel: "English",
direction: "ltr",
htmlLang: "en",
ogLocale: "en_US",
dateLocale: "en-US",
enabled: true,
},
// Example for future — set enabled: true after adding locale files:
// ar: {
// code: "ar",
// nativeLabel: "العربية",
// englishLabel: "Arabic",
// direction: "rtl",
// htmlLang: "ar",
// ogLocale: "ar_SA",
// dateLocale: "ar-SA",
// enabled: false,
// },
};
export type AppLanguage = keyof typeof LANGUAGE_REGISTRY;
export const DEFAULT_LANGUAGE: AppLanguage = "fa";
export const LANGUAGE_COOKIE = "modstagram_lang";
export const SUPPORTED_LANGUAGES = Object.keys(LANGUAGE_REGISTRY) as AppLanguage[];
export const ENABLED_LANGUAGES = SUPPORTED_LANGUAGES.filter(
(code) => LANGUAGE_REGISTRY[code]?.enabled
);
export function getLanguageDefinition(
code: string | null | undefined
): LanguageDefinition {
if (code && LANGUAGE_REGISTRY[code]) {
return LANGUAGE_REGISTRY[code];
}
return LANGUAGE_REGISTRY[DEFAULT_LANGUAGE];
}
export function isAppLanguage(
value: string | null | undefined
): value is AppLanguage {
return Boolean(value && LANGUAGE_REGISTRY[value]?.enabled);
}
export function getDirection(code: AppLanguage): LanguageDirection {
return getLanguageDefinition(code).direction;
}
export function normalizeAppLanguage(
value: string | null | undefined
): AppLanguage {
return isAppLanguage(value) ? value : DEFAULT_LANGUAGE;
}
/** Legacy map for components that need native/english labels */
export const LANGUAGE_LABELS = Object.fromEntries(
ENABLED_LANGUAGES.map((code) => {
const def = LANGUAGE_REGISTRY[code];
return [code, { native: def.nativeLabel, english: def.englishLabel }];
})
) as Record<AppLanguage, { native: string; english: string }>;

View File

@@ -0,0 +1,61 @@
import {
DEFAULT_LANGUAGE,
ENABLED_LANGUAGES,
getLanguageDefinition,
isAppLanguage,
type AppLanguage,
} from "./registry";
function tagMatchesLanguage(tag: string, code: AppLanguage): boolean {
const normalized = tag.trim().toLowerCase();
if (!normalized) return false;
const def = getLanguageDefinition(code);
return (
normalized === code ||
normalized.startsWith(`${code}-`) ||
normalized === def.htmlLang ||
normalized.startsWith(`${def.htmlLang}-`)
);
}
/** Match the first supported language from a ordered list of BCP-47 tags */
export function resolveLanguageFromLanguageTags(
tags: string[] | null | undefined
): AppLanguage | null {
if (!tags?.length) return null;
for (const rawTag of tags) {
const tag = rawTag.split(";")[0]?.trim();
if (!tag) continue;
for (const code of ENABLED_LANGUAGES) {
if (tagMatchesLanguage(tag, code)) {
return code;
}
}
}
return null;
}
export function resolveLanguageFromAcceptHeader(
acceptLanguage: string | null | undefined
): AppLanguage | null {
if (!acceptLanguage) return null;
const tags = acceptLanguage
.split(",")
.map((part) => part.split(";")[0]?.trim())
.filter(Boolean) as string[];
return resolveLanguageFromLanguageTags(tags);
}
export function resolveLanguageFromSingleTag(
tag: string | null | undefined
): AppLanguage | null {
if (!tag) return null;
if (isAppLanguage(tag)) return tag;
return resolveLanguageFromLanguageTags([tag]);
}

58
src/lib/i18n/resources.ts Normal file
View File

@@ -0,0 +1,58 @@
import faCommon from "@/locales/fa/common.json";
import faSeo from "@/locales/fa/seo.json";
import enCommon from "@/locales/en/common.json";
import enSeo from "@/locales/en/seo.json";
import {
DEFAULT_LANGUAGE,
ENABLED_LANGUAGES,
type AppLanguage,
} from "./registry";
/** All i18n namespaces — add new namespace here when extending translations */
export const LOCALE_NAMESPACES = ["common", "seo"] as const;
export type LocaleNamespace = (typeof LOCALE_NAMESPACES)[number];
/**
* Locale bundles — add a new language by importing its JSON files here.
*/
export const LOCALE_BUNDLES: Record<
AppLanguage,
Record<LocaleNamespace, Record<string, unknown>>
> = {
fa: {
common: faCommon,
seo: faSeo,
},
en: {
common: enCommon,
seo: enSeo,
},
};
export function buildI18nResources() {
return Object.fromEntries(
ENABLED_LANGUAGES.map((code) => [code, LOCALE_BUNDLES[code]])
);
}
export function getLocaleBundle(lang: AppLanguage) {
return LOCALE_BUNDLES[lang] ?? LOCALE_BUNDLES[DEFAULT_LANGUAGE];
}
export type SeoPageKey = keyof typeof faSeo.pages;
export type SeoSiteMeta = {
name: string;
titleSuffix: string;
defaultDescription: string;
keywords: string[];
};
export type SeoPageMeta = {
title: string;
description: string;
keywords: string[];
path: string;
h1?: string;
};

164
src/lib/i18n/seo.ts Normal file
View File

@@ -0,0 +1,164 @@
import type { Metadata } from "next";
import { SITE_URL } from "@/config/site";
import {
DEFAULT_LANGUAGE,
ENABLED_LANGUAGES,
getLanguageDefinition,
type AppLanguage,
} from "./registry";
import {
getLocaleBundle,
type SeoPageKey,
type SeoPageMeta,
type SeoSiteMeta,
} from "./resources";
export const SITE_BASE_URL = SITE_URL;
type SeoJson = {
site: SeoSiteMeta;
pages: Record<string, SeoPageMeta & { keywords?: string[] }>;
listTemplates: Record<string, Record<string, string | Record<string, string>>>;
};
function getSeoBundle(lang: AppLanguage): SeoJson {
return getLocaleBundle(lang).seo as SeoJson;
}
export function interpolateTemplate(
template: string,
vars: Record<string, string>
): string {
return template.replace(/\{\{(\w+)\}\}/g, (_, key: string) => vars[key] ?? "");
}
export function getSiteSeoMeta(lang: AppLanguage = DEFAULT_LANGUAGE): SeoSiteMeta {
return getSeoBundle(lang).site;
}
export function getSeoPage(
pageKey: SeoPageKey,
lang: AppLanguage = DEFAULT_LANGUAGE
): SeoPageMeta {
const bundle = getSeoBundle(lang);
const page = bundle.pages[pageKey];
if (!page) {
throw new Error(`Missing SEO page key: ${String(pageKey)}`);
}
return {
title: page.title,
description: page.description,
path: page.path,
keywords: page.keywords ?? bundle.site.keywords,
h1: page.h1,
};
}
export function getListSeoTemplates(lang: AppLanguage = DEFAULT_LANGUAGE) {
return getSeoBundle(lang).listTemplates;
}
/** Title format: SiteName | core (or core already includes suffix) */
export function formatLocalizedSeoTitle(
core: string,
lang: AppLanguage = DEFAULT_LANGUAGE
): string {
const site = getSiteSeoMeta(lang);
const trimmed = core.trim();
if (!trimmed) return site.name;
if (
trimmed.startsWith(`${site.name} |`) ||
trimmed.endsWith(`| ${site.titleSuffix}`) ||
trimmed.includes(`| ${site.titleSuffix}`)
) {
return trimmed;
}
return `${site.titleSuffix} | ${trimmed}`;
}
export function buildLocalizedLocationLabel(
options: { cityName?: string; provinceName?: string },
lang: AppLanguage = DEFAULT_LANGUAGE
): string {
const location = getListSeoTemplates(lang).location as Record<string, string>;
if (options.cityName) {
return interpolateTemplate(location.inCity, { city: options.cityName });
}
if (options.provinceName) {
return interpolateTemplate(location.inProvince, {
province: options.provinceName,
});
}
return "";
}
export function buildHreflangAlternates(path: string) {
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
const canonical = `${SITE_URL}${normalizedPath}`;
const languages: Record<string, string> = {};
for (const code of ENABLED_LANGUAGES) {
languages[code] = canonical;
}
languages["x-default"] = canonical;
return { canonical, languages };
}
export function buildLocalizedOpenGraphLocale(lang: AppLanguage): string {
return getLanguageDefinition(lang).ogLocale;
}
export type LocalizedMetadataInput = {
title: string;
description: string;
path: string;
lang?: AppLanguage;
type?: "website" | "article" | "profile";
publishedTime?: string;
modifiedTime?: string;
imageUrl?: string;
imageAlt?: string;
keywords?: string[];
ogTitle?: string;
appendSiteName?: boolean;
index?: boolean;
};
/** Builds Next.js Metadata fields that depend on locale (used by generatePageMetadata) */
export function buildLocalizedMetadataFields(
options: LocalizedMetadataInput
): Pick<
Metadata,
"title" | "description" | "keywords" | "alternates" | "robots" | "openGraph"
> {
const lang = options.lang ?? DEFAULT_LANGUAGE;
const site = getSiteSeoMeta(lang);
const ogLocale = buildLocalizedOpenGraphLocale(lang);
const finalTitle = options.appendSiteName
? `${options.title} | ${site.titleSuffix}`
: options.title;
const ogTitle = options.ogTitle || finalTitle;
const finalDescription = options.description || site.defaultDescription;
return {
title: finalTitle,
description: finalDescription,
keywords: options.keywords ?? site.keywords,
alternates: buildHreflangAlternates(options.path),
robots: {
index: options.index !== false,
follow: true,
},
openGraph: {
title: ogTitle,
description: finalDescription,
url: `${SITE_URL}${options.path.startsWith("/") ? options.path : `/${options.path}`}`,
siteName: site.name,
locale: ogLocale,
type: options.type || "website",
},
};
}

57
src/lib/i18n/server.ts Normal file
View File

@@ -0,0 +1,57 @@
import { cookies, headers } from "next/headers";
import type { Metadata } from "next";
import { generatePageMetadata } from "@/utils/generatePageMetadata";
import { resolveServerLanguage } from "./index";
import type { SeoPageKey } from "./resources";
import { getSeoPage } from "./seo";
import type { AppLanguage } from "./registry";
export async function getServerLanguage(): Promise<AppLanguage> {
const cookieStore = await cookies();
const headerStore = await headers();
return resolveServerLanguage({
cookieStore,
acceptLanguage: headerStore.get("accept-language"),
});
}
type SeoPageMetadataOptions = {
lang?: AppLanguage;
path?: string;
title?: string;
description?: string;
keywords?: string[];
appendSiteName?: boolean;
index?: boolean;
type?: "website" | "article" | "profile";
imageUrl?: string;
imageAlt?: string;
ogTitle?: string;
publishedTime?: string;
modifiedTime?: string;
};
/** Server helper: metadata for a registered SEO page key + cookie/header locale */
export async function generateSeoPageMetadata(
pageKey: SeoPageKey,
options: SeoPageMetadataOptions = {}
): Promise<Metadata> {
const lang = options.lang ?? (await getServerLanguage());
const page = getSeoPage(pageKey, lang);
return generatePageMetadata({
title: options.title ?? page.title,
description: options.description ?? page.description,
path: options.path ?? page.path,
keywords: options.keywords ?? page.keywords,
appendSiteName: options.appendSiteName ?? false,
index: options.index,
type: options.type,
imageUrl: options.imageUrl,
imageAlt: options.imageAlt,
ogTitle: options.ogTitle,
publishedTime: options.publishedTime,
modifiedTime: options.modifiedTime,
lang,
});
}

View File

@@ -0,0 +1,9 @@
import { getLanguageDefinition, type AppLanguage } from "./registry";
export function syncDocumentLanguage(lang: AppLanguage) {
if (typeof document === "undefined") return;
const def = getLanguageDefinition(lang);
document.documentElement.lang = def.htmlLang;
document.documentElement.dir = def.direction;
document.documentElement.dataset.locale = lang;
}

27
src/lib/i18n/translate.ts Normal file
View File

@@ -0,0 +1,27 @@
import { getLocaleBundle } from "./resources";
import type { AppLanguage } from "./registry";
/** Server-safe lookup in common.json by dot path (e.g. `errors.pageLoad`) */
export function translateCommon(
lang: AppLanguage,
key: string,
params?: Record<string, string | number>
): string {
const bundle = getLocaleBundle(lang).common as Record<string, unknown>;
const parts = key.split(".");
let cur: unknown = bundle;
for (const part of parts) {
if (cur == null || typeof cur !== "object") {
cur = undefined;
break;
}
cur = (cur as Record<string, unknown>)[part];
}
if (typeof cur !== "string") return key;
if (!params) return cur;
return Object.entries(params).reduce(
(text, [name, value]) =>
text.replace(new RegExp(`\\{\\{${name}\\}\\}`, "g"), String(value)),
cur
);
}

82
src/lib/iranSheba.ts Normal file
View File

@@ -0,0 +1,82 @@
const IRAN_BANKS: Record<string, string> = {
"010": "بانک مرکزی",
"011": "بانک صنعت و معدن",
"012": "بانک ملت",
"013": "بانک رفاه کارگران",
"014": "بانک مسکن",
"015": "بانک سپه",
"016": "بانک کشاورزی",
"017": "بانک ملی ایران",
"018": "بانک تجارت",
"019": "بانک صادرات ایران",
"020": "بانک توسعه صادرات",
"021": "پست بانک ایران",
"022": "بانک توسعه تعاون",
"051": "موسسه اعتباری توسعه",
"052": "بانک قوامین",
"053": "بانک کارآفرین",
"054": "بانک پارسیان",
"055": "بانک اقتصاد نوین",
"056": "بانک سامان",
"057": "بانک پاسارگاد",
"058": "بانک سرمایه",
"059": "بانک سینا",
"060": "بانک قرض‌الحسنه مهر ایران",
"061": "بانک شهر",
"062": "بانک آینده",
"063": "بانک انصار",
"064": "بانک گردشگری",
"065": "بانک حکمت ایرانیان",
"066": "بانک دی",
"069": "بانک ایران زمین",
"070": "بانک قرض‌الحسنه رسالت",
"073": "بانک کوثر",
"075": "بانک ملل",
"078": "بانک خاورمیانه",
"079": "بانک نور",
"080": "بانک مهر اقتصاد",
};
export function stripShebaPrefix(value: string): string {
const digits = value.replace(/\D/g, "");
if (digits.length === 24) return digits;
const upper = value.replace(/\s/g, "").toUpperCase();
if (upper.startsWith("IR")) {
return upper.slice(2).replace(/\D/g, "").slice(0, 24);
}
return digits.slice(0, 24);
}
function ibanMod97(iban: string): number {
const rearranged = iban.slice(4) + iban.slice(0, 4);
const expanded = rearranged
.split("")
.map((char) => {
const code = char.charCodeAt(0);
if (code >= 65 && code <= 90) return String(code - 55);
return char;
})
.join("");
let remainder = expanded;
while (remainder.length > 2) {
const block = remainder.slice(0, 9);
remainder = String(parseInt(block, 10) % 97) + remainder.slice(block.length);
}
return parseInt(remainder, 10) % 97;
}
export function isValidIranSheba(digits24: string): boolean {
if (!/^\d{24}$/.test(digits24)) return false;
return ibanMod97(`IR${digits24}`) === 1;
}
export function getIranBankName(digits24: string): string | null {
if (digits24.length < 5) return null;
const bankCode = digits24.slice(2, 5);
return IRAN_BANKS[bankCode] ?? null;
}
export function formatShebaForStorage(digits24: string): string {
return `IR${digits24}`;
}

View File

@@ -0,0 +1,47 @@
import type { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime";
const PREFIX = "modstagram-list-scroll";
export function buildListScrollKey(
scope: string,
filters?: Record<string, unknown>
): string {
const filterPart = filters ? JSON.stringify(filters) : "";
return `${PREFIX}:${scope}:${filterPart}`;
}
export function saveListScroll(key: string): void {
if (typeof window === "undefined") return;
sessionStorage.setItem(key, String(window.scrollY));
}
export function peekListScroll(key: string): number | null {
if (typeof window === "undefined") return null;
const raw = sessionStorage.getItem(key);
if (raw == null) return null;
const y = Number.parseInt(raw, 10);
return Number.isFinite(y) ? y : null;
}
export function consumeListScroll(key: string): number | null {
if (typeof window === "undefined") return null;
const y = peekListScroll(key);
if (y != null) sessionStorage.removeItem(key);
return y;
}
export function navigateWithSavedScroll(
router: AppRouterInstance,
key: string,
url: string
): void {
saveListScroll(key);
router.push(url, { scroll: false });
}
export function initManualScrollRestoration(): void {
if (typeof window === "undefined") return;
if ("scrollRestoration" in window.history) {
window.history.scrollRestoration = "manual";
}
}

17
src/lib/postExpertise.ts Normal file
View File

@@ -0,0 +1,17 @@
import { User } from "@/types/types";
export const POST_EXPERTISE_REQUIRED_MESSAGE =
"کاربر گرامی برای ثبت پست باید تخصص کاربری خود را مشخص کنید";
export const EXPERTISE_EDIT_PATH = "/settings/edit/expertise";
export function userHasExpertise(user?: User | null): boolean {
return Boolean(user?.expertise?.trim());
}
export function buildExpertiseEditUrl(returnPath?: string): string {
if (!returnPath || !returnPath.startsWith("/") || returnPath.startsWith("//")) {
return EXPERTISE_EDIT_PATH;
}
return `${EXPERTISE_EDIT_PATH}?redirect=${encodeURIComponent(returnPath)}`;
}

228
src/lib/postLinkCaption.ts Normal file
View File

@@ -0,0 +1,228 @@
import { DEFAULT_LANGUAGE, type AppLanguage } from "@/lib/i18n/registry";
export type PostLinkKind = "pkg" | "project" | "billboard";
/** مارکر لینک داخل کپشن */
export const POST_LINK_TOKEN_REGEX =
/\[\[(pkg|project|billboard):([a-f0-9]{24})\]\]([\s\S]*?)\[\[\/\1\]\]/gi;
/** @deprecated use POST_LINK_TOKEN_REGEX */
export const POST_PACKAGE_TOKEN_REGEX =
/\[\[pkg:([a-f0-9]{24})\]\]([\s\S]*?)\[\[\/pkg\]\]/gi;
export interface SelectedPostLink {
kind: PostLinkKind;
_id: string;
title: string;
}
const FA_LINK_LABELS = {
pkg: "پکیج آموزشی",
project: "پروژه",
billboard: "بیلبورد",
} as const;
const EN_LINK_LABELS = {
pkg: "Training package",
project: "Project",
billboard: "Billboard",
} as const;
export function getPostLinkLabels(lang: AppLanguage = DEFAULT_LANGUAGE) {
return lang === "en" ? EN_LINK_LABELS : FA_LINK_LABELS;
}
const ALL_LINK_LABELS = [
...Object.values(FA_LINK_LABELS),
...Object.values(EN_LINK_LABELS),
];
const LINK_LABEL_PATTERN = new RegExp(
`^(${ALL_LINK_LABELS.map((label) => label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})\\s*:`
);
export function buildPostLinkPath(
kind: PostLinkKind,
id: string,
title: string
) {
const name = encodeURIComponent(title || kind);
switch (kind) {
case "pkg":
return `/academy/${id}/${name}`;
case "project":
return `/projects/${id}/${name}`;
case "billboard":
return `/billboards/${id}/${name}`;
}
}
export function buildPostLinkCaptionToken(
kind: PostLinkKind,
id: string,
title: string
) {
return `[[${kind}:${id}]]${title.trim()}[[/${kind}]]`;
}
/** خط اول کپشن — برچسب + لینک‌های قابل کلیک */
export function buildPostLinkCaptionHeader(
links: Array<SelectedPostLink | null | undefined>,
lang: AppLanguage = DEFAULT_LANGUAGE
) {
const list = links.filter((l): l is SelectedPostLink => Boolean(l?._id));
if (!list.length) return "";
const linkLabels = getPostLinkLabels(lang);
const groups: Record<PostLinkKind, SelectedPostLink[]> = {
pkg: [],
project: [],
billboard: [],
};
for (const link of list) {
groups[link.kind].push(link);
}
const parts: string[] = [];
(["pkg", "project", "billboard"] as const).forEach((kind) => {
if (!groups[kind].length) return;
const tokens = groups[kind]
.map((l) => buildPostLinkCaptionToken(l.kind, l._id, l.title))
.join(" – ");
parts.push(`${linkLabels[kind]}: ${tokens}`);
});
return parts.join("\n");
}
/** @deprecated */
export function buildCourseDetailPath(courseId: string, courseName: string) {
return buildPostLinkPath("pkg", courseId, courseName);
}
/** @deprecated */
export function buildPostPackageCaptionToken(
courseId: string,
courseName: string
) {
return buildPostLinkCaptionToken("pkg", courseId, courseName);
}
export function stripPostLinkTokens(text: string) {
return text.replace(POST_LINK_TOKEN_REGEX, "$3");
}
/** خطوط حاوی لینک پکیج/پروژه/بیلبورد را از بدنه جدا می‌کند */
export function splitCaptionBodyAndLinks(caption: string) {
if (!caption?.trim()) {
return { body: "", links: "" };
}
const bodyLines: string[] = [];
const linkLines: string[] = [];
for (const line of caption.split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
const hasLinkToken = new RegExp(POST_LINK_TOKEN_REGEX.source, "i").test(
line
);
POST_LINK_TOKEN_REGEX.lastIndex = 0;
const hasLinkLabel = LINK_LABEL_PATTERN.test(trimmed);
if (hasLinkToken || hasLinkLabel) {
if (
trimmed.includes(" – ") &&
(trimmed.match(new RegExp(ALL_LINK_LABELS.join("|"), "g")) || []).length > 1
) {
trimmed.split(" – ").forEach((part) => {
if (part.trim()) linkLines.push(part.trim());
});
} else {
linkLines.push(line);
}
} else {
bodyLines.push(line);
}
}
return {
body: bodyLines.join("\n"),
links: linkLines.join("\n"),
};
}
/** بدنه کپشن + لینک‌ها در انتها (برای پست‌های قدیمی که لینک اول بود) */
export function reorderCaptionLinksToEnd(caption: string) {
const { body, links } = splitCaptionBodyAndLinks(caption);
return [body, links].filter(Boolean).join("\n");
}
/** @deprecated */
export function stripPostPackageTokens(text: string) {
return stripPostLinkTokens(text);
}
/** @deprecated — use buildPostLinkCaptionHeader */
export function buildPostLinkCaptionTokens(
links: Array<SelectedPostLink | null | undefined>
) {
return links
.filter((link): link is SelectedPostLink => Boolean(link?._id))
.map((link) => buildPostLinkCaptionToken(link.kind, link._id, link.title));
}
export function captionNeedsTruncation(caption: string, maxLen: number) {
const { body } = splitCaptionBodyAndLinks(reorderCaptionLinksToEnd(caption));
const plain = stripPostLinkTokens(body).replace(/\s+/g, " ").trim();
return plain.length > maxLen;
}
/** آستانه «بیشتر» در فید پست‌ها — مثل اینستاگرام */
export const POST_CAPTION_FIRST_LINE_MORE_AT = 20;
/** نمایش «بیشتر» در صفحه پست‌ها: خط دوم یا ۲۰+ کاراکتر در خط اول */
export function postCaptionNeedsMore(body: string): boolean {
if (!body?.trim()) return false;
const plain = stripPostLinkTokens(body);
const lines = plain.split("\n");
const firstLine = (lines[0] ?? "").replace(/\s+/g, " ").trim();
const hasSecondLine = lines
.slice(1)
.some((line) => line.replace(/\s+/g, " ").trim().length > 0);
if (hasSecondLine) return true;
return firstLine.length >= POST_CAPTION_FIRST_LINE_MORE_AT;
}
/** متن کوتاه‌شده برای حالت جمع‌شده — همراه «بیشتر» در همان خط */
export function getCollapsedCaptionPreview(body: string): string {
if (!body?.trim() || !postCaptionNeedsMore(body)) return body;
const plain = stripPostLinkTokens(body);
const plainLines = plain.split("\n");
const firstPlainLine = (plainLines[0] ?? "").replace(/\s+/g, " ").trim();
const hasSecondLine = plainLines
.slice(1)
.some((line) => line.replace(/\s+/g, " ").trim().length > 0);
const firstBodyLine = body.split("\n")[0] ?? body;
if (firstPlainLine.length <= POST_CAPTION_FIRST_LINE_MORE_AT) {
return hasSecondLine ? firstBodyLine : body;
}
return firstPlainLine.slice(0, POST_CAPTION_FIRST_LINE_MORE_AT).trimEnd();
}
export function truncateCaptionPlain(caption: string, maxLen: number) {
const reordered = reorderCaptionLinksToEnd(caption);
const { body, links } = splitCaptionBodyAndLinks(reordered);
const plain = stripPostLinkTokens(body);
if (plain.length <= maxLen) return reordered;
const truncatedBody = plain.slice(0, maxLen);
return links ? `${truncatedBody}\n${links}` : truncatedBody;
}

View File

@@ -0,0 +1,52 @@
export const POST_LOCATION_NOTICE_KEY = "modstagram_post_location_notice_v1";
export function hasSeenPostLocationNotice(): boolean {
if (typeof window === "undefined") return true;
return window.localStorage.getItem(POST_LOCATION_NOTICE_KEY) === "1";
}
export function markPostLocationNoticeSeen(): void {
if (typeof window === "undefined") return;
window.localStorage.setItem(POST_LOCATION_NOTICE_KEY, "1");
}
export function captureVideoFrame(
video: HTMLVideoElement,
timeSec: number
): Promise<Blob> {
return new Promise((resolve, reject) => {
const onSeeked = () => {
video.removeEventListener("seeked", onSeeked);
try {
const canvas = document.createElement("canvas");
canvas.width = video.videoWidth || 720;
canvas.height = video.videoHeight || 1280;
const ctx = canvas.getContext("2d");
if (!ctx) {
reject(new Error("canvas unavailable"));
return;
}
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
canvas.toBlob(
(blob) => {
if (!blob) {
reject(new Error("frame capture failed"));
return;
}
resolve(blob);
},
"image/webp",
0.88
);
} catch (err) {
reject(err);
}
};
video.addEventListener("seeked", onSeeked);
video.currentTime = Math.min(
Math.max(0, timeSec),
Math.max(0, (video.duration || 1) - 0.05)
);
});
}

View File

@@ -0,0 +1 @@
export * from "./postLinkCaption";

View File

@@ -1,4 +1,7 @@
import { Post } from "@/types/types";
import { DEFAULT_LANGUAGE, type AppLanguage } from "@/lib/i18n/registry";
import { getSiteSeoMeta } from "@/lib/i18n/seo";
import { translateCommon } from "@/lib/i18n/translate";
function slugify(text: string): string {
return text
@@ -38,33 +41,57 @@ export function buildPostPublicUrl(
}
export function buildPostSeoTitle(
post: Pick<Post, "caption" | "user_name" | "first_name" | "last_name" | "expertise">
post: Pick<Post, "caption" | "user_name" | "first_name" | "last_name" | "expertise">,
lang: AppLanguage = DEFAULT_LANGUAGE
): string {
const siteName = getSiteSeoMeta(lang).name;
const author =
[post.first_name, post.last_name].filter(Boolean).join(" ") ||
post.user_name ||
"کاربر";
translateCommon(lang, "postsMeta.userFallback");
const caption = post.caption?.trim();
if (caption) {
return `${caption.slice(0, 55)} | ${author} | مدستاگرام`;
return translateCommon(lang, "postsMeta.titleWithCaption", {
caption: caption.slice(0, 55),
author,
siteName,
});
}
if (post.expertise) {
return `پست ${post.expertise} از ${author} | مدستاگرام`;
return translateCommon(lang, "postsMeta.titleWithExpertise", {
expertise: post.expertise,
author,
siteName,
});
}
return `پست ${author} | مدستاگرام`;
return translateCommon(lang, "postsMeta.titleSimple", { author, siteName });
}
export function buildPostSeoDescription(
post: Pick<Post, "caption" | "user_name" | "first_name" | "last_name" | "expertise">
post: Pick<Post, "caption" | "user_name" | "first_name" | "last_name" | "expertise">,
lang: AppLanguage = DEFAULT_LANGUAGE
): string {
const siteName = getSiteSeoMeta(lang).name;
const author =
[post.first_name, post.last_name].filter(Boolean).join(" ") ||
post.user_name ||
"کاربر";
translateCommon(lang, "postsMeta.userFallback");
const caption = post.caption?.trim();
if (caption) {
return `${caption.slice(0, 140)} — ${author} در مدستاگرام، پلتفرم تخصصی زیبایی، مدلینگ و عکاسی.`;
return translateCommon(lang, "postsMeta.descWithCaption", {
caption: caption.slice(0, 140),
author,
siteName,
});
}
const expertise = post.expertise ? ` (${post.expertise})` : "";
return `مشاهده پست${expertise} ${author} در مدستاگرام — پلتفرم تخصصی حوزه زیبایی، مدلینگ و عکاسی.`;
const expertise = post.expertise
? translateCommon(lang, "postsMeta.expertiseSuffix", {
expertise: post.expertise,
})
: "";
return translateCommon(lang, "postsMeta.descSimple", {
author,
expertise,
siteName,
});
}

View File

@@ -1,3 +1,5 @@
import { readLocalStorage, writeLocalStorage, removeLocalStorage } from "@/lib/safeStorage";
const DISMISS_KEY = "pwa_install_dismissed_until";
export type BeforeInstallPromptEvent = Event & {
@@ -21,19 +23,16 @@ export function isIosDevice(): boolean {
}
export function isDismissed(): boolean {
if (typeof window === "undefined") return false;
const until = Number(localStorage.getItem(DISMISS_KEY) || 0);
const until = Number(readLocalStorage(DISMISS_KEY) || 0);
return Date.now() < until;
}
/** عدم نمایش تا ۷ روز */
export function dismissInstallPrompt(days = 7): void {
if (typeof window === "undefined") return;
const until = Date.now() + days * 24 * 60 * 60 * 1000;
localStorage.setItem(DISMISS_KEY, String(until));
writeLocalStorage(DISMISS_KEY, String(until));
}
export function clearInstallDismissal(): void {
if (typeof window === "undefined") return;
localStorage.removeItem(DISMISS_KEY);
removeLocalStorage(DISMISS_KEY);
}

111
src/lib/pwaConfig.ts Normal file
View File

@@ -0,0 +1,111 @@
export type PwaLang = "fa" | "en";
export const PWA_THEME_COLOR = "#FF107D";
export const PWA_BACKGROUND_COLOR = "#ffffff";
export const PWA_ID = "modstagram-pwa";
export const PWA_VERSION = "10";
export { resolvePwaLang, toPwaLang } from "@/lib/i18n/pwaLang";
const COPY = {
fa: {
name: "مدستاگرام",
short_name: "مدستاگرام",
description: "شبکه تخصصی مدل، عکاس و آرایشگر",
lang: "fa",
dir: "rtl" as const,
installTitle: "نصب اپلیکیشن مدستاگرام",
installDescription:
"برای دسترسی سریع‌تر و تجربه شبیه اپ موبایل، مدستاگرام را روی گوشی یا سیستم خود نصب کنید.",
installButton: "نصب اپلیکیشن",
installing: "در حال نصب...",
later: "بعداً",
gotIt: "متوجه شدم",
ok: "باشه",
close: "بستن",
iosSteps: [
"دکمه Share (مربع با فلش) را در Safari بزنید.",
"گزینه «Add to Home Screen» یا «افزودن به صفحه اصلی» را انتخاب کنید.",
"در بالا «Add» یا «افزودن» را بزنید.",
],
},
en: {
name: "Modstagram",
short_name: "Modstagram",
description: "Professional network for models, photographers and makeup artists",
lang: "en",
dir: "ltr" as const,
installTitle: "Install Modstagram",
installDescription:
"Install Modstagram on your device for faster access and an app-like experience.",
installButton: "Install app",
installing: "Installing...",
later: "Later",
gotIt: "Got it",
ok: "OK",
close: "Close",
iosSteps: [
"Tap the Share button (square with arrow) in Safari.",
"Choose “Add to Home Screen”.",
"Tap “Add” in the top corner.",
],
},
};
export function getPwaLabels(lang: PwaLang) {
return COPY[lang];
}
export function buildWebManifest(lang: PwaLang) {
const labels = getPwaLabels(lang);
return {
name: labels.name,
short_name: labels.short_name,
description: labels.description,
lang: labels.lang,
dir: labels.dir,
scope: "/",
start_url: `/?source=pwa&lang=${lang}`,
id: PWA_ID,
display: "standalone",
display_override: ["standalone", "minimal-ui", "browser"],
orientation: "portrait-primary",
prefer_related_applications: false,
theme_color: PWA_THEME_COLOR,
background_color: PWA_BACKGROUND_COLOR,
categories: ["social", "business"],
icons: [
{
src: "/images/icons/192x192.png",
sizes: "192x192",
type: "image/png",
purpose: "any",
},
{
src: "/images/icons/256x256.png",
sizes: "256x256",
type: "image/png",
purpose: "any",
},
{
src: "/images/icons/512x512.png",
sizes: "512x512",
type: "image/png",
purpose: "any",
},
{
src: "/images/icons/maskable-192.png",
sizes: "192x192",
type: "image/png",
purpose: "maskable",
},
{
src: "/images/icons/maskable-512.png",
sizes: "512x512",
type: "image/png",
purpose: "maskable",
},
],
};
}

View File

@@ -23,3 +23,19 @@ export function readReelsSeedPost(id: string): Post | null {
return null;
}
}
export function updateReelsSeedPostEngagement(
postId: string,
patch: Partial<Pick<Post, "is_liked" | "likesCount" | "is_saved" | "savesCount">>
): void {
if (typeof window === "undefined") return;
try {
const key = `${KEY_PREFIX}${postId}`;
const raw = sessionStorage.getItem(key);
if (!raw) return;
const post = JSON.parse(raw) as Post;
sessionStorage.setItem(key, JSON.stringify({ ...post, ...patch }));
} catch {
/* ignore */
}
}

28
src/lib/safeStorage.ts Normal file
View File

@@ -0,0 +1,28 @@
/** Safe localStorage access — Safari private mode can throw SecurityError. */
export function readLocalStorage(key: string): string | null {
if (typeof window === "undefined") return null;
try {
return window.localStorage.getItem(key);
} catch {
return null;
}
}
export function writeLocalStorage(key: string, value: string): boolean {
if (typeof window === "undefined") return false;
try {
window.localStorage.setItem(key, value);
return true;
} catch {
return false;
}
}
export function removeLocalStorage(key: string): void {
if (typeof window === "undefined") return;
try {
window.localStorage.removeItem(key);
} catch {
// ignore
}
}

View File

@@ -0,0 +1,21 @@
import {
buildLocalizedLocationLabel,
formatLocalizedSeoTitle,
} from "@/lib/i18n/seo";
import { DEFAULT_LANGUAGE, type AppLanguage } from "@/lib/i18n/registry";
/** @deprecated Use formatLocalizedSeoTitle(core, lang) */
export function formatSeoTitle(
core: string,
lang: AppLanguage = DEFAULT_LANGUAGE
): string {
return formatLocalizedSeoTitle(core, lang);
}
/** @deprecated Use buildLocalizedLocationLabel(options, lang) */
export function buildLocationLabel(
options: { cityName?: string; provinceName?: string },
lang: AppLanguage = DEFAULT_LANGUAGE
): string {
return buildLocalizedLocationLabel(options, lang);
}

View File

@@ -0,0 +1,37 @@
function normalizeSubExpertiseLabel(value?: string | null) {
return String(value || "").trim();
}
export function subExpertiseListIncludes(
list: string[] | undefined,
value?: string | null
) {
const needle = normalizeSubExpertiseLabel(value);
if (!needle) return false;
return (list || []).some(
(item) => normalizeSubExpertiseLabel(item) === needle
);
}
export function resolveDisplaySubExpertise(
subExpertise: string[] | undefined,
displaySubExpertise?: string | null
): string | null {
const list = (subExpertise || [])
.map((item) => normalizeSubExpertiseLabel(item))
.filter(Boolean);
if (!list.length) return null;
const display = normalizeSubExpertiseLabel(displaySubExpertise);
if (display && list.includes(display)) return display;
return list[0];
}
export function getPostSubExpertiseLabels(
_mainExpertise?: string | null,
subExpertise: string[] = [],
displaySubExpertise?: string | null
): string[] {
const display = resolveDisplaySubExpertise(subExpertise, displaySubExpertise);
return display ? [display] : [];
}

View File

@@ -1,8 +1,8 @@
/** حروف انگلیسی، اعداد و . _ - — بدون فارسی و کاراکترهای غیرمعمول */
export const USERNAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9._-]{5,99}$/;
export const USERNAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9._-]{2,7}$/;
export const USERNAME_MIN_LENGTH = 6;
export const USERNAME_MAX_LENGTH = 100;
export const USERNAME_MIN_LENGTH = 3;
export const USERNAME_MAX_LENGTH = 8;
export const USERNAME_ALLOWED_CHARS = /[^a-zA-Z0-9._-]/g;
@@ -13,7 +13,9 @@ export const USERNAME_START_MESSAGE =
"نام کاربری باید با حرف یا عدد انگلیسی شروع شود";
export function sanitizeUsernameInput(value: string): string {
return value.replace(USERNAME_ALLOWED_CHARS, "");
return value
.replace(USERNAME_ALLOWED_CHARS, "")
.slice(0, USERNAME_MAX_LENGTH);
}
export function isValidUsername(value: string): boolean {