lang
This commit is contained in:
@@ -14,6 +14,7 @@ const PUBLIC_AUTH_PATTERNS = [
|
||||
/^\/register\/?$/,
|
||||
/^\/register\/verify$/,
|
||||
/^\/auth\/google$/,
|
||||
/^\/auth\/totp\/verify-login$/,
|
||||
];
|
||||
|
||||
export function isPublicAuthPath(pathSegments: string[]): boolean {
|
||||
|
||||
10
src/lib/auth/googleClientId.ts
Normal file
10
src/lib/auth/googleClientId.ts
Normal 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();
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
36
src/lib/billboards/displayTypeLabel.ts
Normal file
36
src/lib/billboards/displayTypeLabel.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
150
src/lib/buildBillboardsListSeo.ts
Normal file
150
src/lib/buildBillboardsListSeo.ts
Normal 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}`,
|
||||
};
|
||||
}
|
||||
@@ -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
170
src/lib/buildHomeListSeo.ts
Normal 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
135
src/lib/buildProfileSeo.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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)}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
41
src/lib/captionLength.ts
Normal 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;
|
||||
}
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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;
|
||||
|
||||
111
src/lib/chat/getCopyableMessageText.ts
Normal file
111
src/lib/chat/getCopyableMessageText.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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}`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
20
src/lib/chat/onlineStatus.ts
Normal file
20
src/lib/chat/onlineStatus.ts
Normal 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
15
src/lib/chat/reactions.ts
Normal 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 }));
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
15
src/lib/formatViewCount.ts
Normal file
15
src/lib/formatViewCount.ts
Normal 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");
|
||||
}
|
||||
47
src/lib/groupItemsByDate.ts
Normal file
47
src/lib/groupItemsByDate.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
56
src/lib/i18n/clientLanguage.ts
Normal file
56
src/lib/i18n/clientLanguage.ts
Normal 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
16
src/lib/i18n/constants.ts
Normal 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
76
src/lib/i18n/index.ts
Normal 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;
|
||||
}
|
||||
34
src/lib/i18n/pageDirection.ts
Normal file
34
src/lib/i18n/pageDirection.ts
Normal 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
16
src/lib/i18n/pwaLang.ts
Normal 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
100
src/lib/i18n/registry.ts
Normal 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 }>;
|
||||
61
src/lib/i18n/resolveLanguage.ts
Normal file
61
src/lib/i18n/resolveLanguage.ts
Normal 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
58
src/lib/i18n/resources.ts
Normal 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
164
src/lib/i18n/seo.ts
Normal 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
57
src/lib/i18n/server.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
9
src/lib/i18n/syncDocumentLanguage.ts
Normal file
9
src/lib/i18n/syncDocumentLanguage.ts
Normal 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
27
src/lib/i18n/translate.ts
Normal 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
82
src/lib/iranSheba.ts
Normal 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}`;
|
||||
}
|
||||
47
src/lib/listScrollRestoration.ts
Normal file
47
src/lib/listScrollRestoration.ts
Normal 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
17
src/lib/postExpertise.ts
Normal 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
228
src/lib/postLinkCaption.ts
Normal 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;
|
||||
}
|
||||
52
src/lib/postLocationNotice.ts
Normal file
52
src/lib/postLocationNotice.ts
Normal 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)
|
||||
);
|
||||
});
|
||||
}
|
||||
1
src/lib/postPackageCaption.ts
Normal file
1
src/lib/postPackageCaption.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./postLinkCaption";
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
111
src/lib/pwaConfig.ts
Normal 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",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -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
28
src/lib/safeStorage.ts
Normal 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
|
||||
}
|
||||
}
|
||||
21
src/lib/seo/formatSeoTitle.ts
Normal file
21
src/lib/seo/formatSeoTitle.ts
Normal 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);
|
||||
}
|
||||
37
src/lib/subExpertiseDisplay.ts
Normal file
37
src/lib/subExpertiseDisplay.ts
Normal 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] : [];
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user