full update
This commit is contained in:
32
src/lib/api/fetchApiJson.ts
Normal file
32
src/lib/api/fetchApiJson.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { getApiBaseUrl } from "@/components/main/BaseUrl";
|
||||
|
||||
type FetchApiJsonResult<T> = {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
data: T | null;
|
||||
};
|
||||
|
||||
export async function fetchApiJson<T>(
|
||||
path: string,
|
||||
options?: RequestInit
|
||||
): Promise<FetchApiJsonResult<T>> {
|
||||
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
||||
const url = `${getApiBaseUrl()}${normalizedPath}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
cache: "no-store",
|
||||
...options,
|
||||
});
|
||||
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
if (!contentType.includes("application/json")) {
|
||||
return { ok: false, status: response.status, data: null };
|
||||
}
|
||||
|
||||
const data = (await response.json()) as T;
|
||||
return { ok: response.ok, status: response.status, data };
|
||||
} catch {
|
||||
return { ok: false, status: 0, data: null };
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ export const UPSTREAM_API =
|
||||
|
||||
type ProxyOptions = {
|
||||
stripAuth?: boolean;
|
||||
cacheable?: boolean;
|
||||
};
|
||||
|
||||
const PUBLIC_AUTH_PATTERNS = [
|
||||
@@ -46,13 +47,26 @@ export async function proxyToUpstream(
|
||||
method,
|
||||
headers,
|
||||
body,
|
||||
cache: "no-store",
|
||||
cache: options?.cacheable ? "force-cache" : "no-store",
|
||||
});
|
||||
|
||||
const responseHeaders = new Headers();
|
||||
const upstreamType = upstream.headers.get("content-type");
|
||||
if (upstreamType) responseHeaders.set("content-type", upstreamType);
|
||||
|
||||
const acceptRanges = upstream.headers.get("accept-ranges");
|
||||
if (acceptRanges) responseHeaders.set("accept-ranges", acceptRanges);
|
||||
|
||||
const contentLength = upstream.headers.get("content-length");
|
||||
if (contentLength) responseHeaders.set("content-length", contentLength);
|
||||
|
||||
if (options?.cacheable && upstream.ok) {
|
||||
responseHeaders.set(
|
||||
"Cache-Control",
|
||||
"public, max-age=31536000, immutable"
|
||||
);
|
||||
}
|
||||
|
||||
return new Response(upstream.body, {
|
||||
status: upstream.status,
|
||||
headers: responseHeaders,
|
||||
|
||||
58
src/lib/appearanceOptions.ts
Normal file
58
src/lib/appearanceOptions.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/** گزینههای رنگ چشم — همان فایلهای مشخصات ظاهری */
|
||||
export const EYE_COLOR_OPTIONS = Array.from({ length: 26 }, (_, i) => {
|
||||
const file = `${String(i + 1).padStart(2, "0")}.png`;
|
||||
return { value: file, label: `چشم ${i + 1}`, src: `/images/eyes/${file}` };
|
||||
});
|
||||
|
||||
/** گزینههای رنگ مو — همان فایلهای مشخصات ظاهری */
|
||||
export const HAIR_COLOR_OPTIONS = Array.from({ length: 85 }, (_, i) => {
|
||||
const file = `${String(i + 1).padStart(2, "0")}.png`;
|
||||
return { value: file, label: `مو ${i + 1}`, src: `/images/hairs/${file}` };
|
||||
});
|
||||
|
||||
export type AppearanceFilters = {
|
||||
heightMin?: string;
|
||||
heightMax?: string;
|
||||
weightMin?: string;
|
||||
weightMax?: string;
|
||||
sizeMin?: string;
|
||||
sizeMax?: string;
|
||||
hair_color?: string;
|
||||
eye_color?: string;
|
||||
};
|
||||
|
||||
export function readAppearanceFiltersFromSearch(
|
||||
params: URLSearchParams
|
||||
): AppearanceFilters {
|
||||
return {
|
||||
heightMin: params.get("heightMin") || "",
|
||||
heightMax: params.get("heightMax") || "",
|
||||
weightMin: params.get("weightMin") || "",
|
||||
weightMax: params.get("weightMax") || "",
|
||||
sizeMin: params.get("sizeMin") || "",
|
||||
sizeMax: params.get("sizeMax") || "",
|
||||
hair_color: params.get("hair_color") || "",
|
||||
eye_color: params.get("eye_color") || "",
|
||||
};
|
||||
}
|
||||
|
||||
export function appendAppearanceFiltersToSearch(
|
||||
params: URLSearchParams,
|
||||
filters: AppearanceFilters
|
||||
) {
|
||||
const entries: (keyof AppearanceFilters)[] = [
|
||||
"heightMin",
|
||||
"heightMax",
|
||||
"weightMin",
|
||||
"weightMax",
|
||||
"sizeMin",
|
||||
"sizeMax",
|
||||
"hair_color",
|
||||
"eye_color",
|
||||
];
|
||||
entries.forEach((key) => {
|
||||
const val = filters[key];
|
||||
if (val) params.set(key, val);
|
||||
else params.delete(key);
|
||||
});
|
||||
}
|
||||
68
src/lib/auth/fetchRegisterStatus.ts
Normal file
68
src/lib/auth/fetchRegisterStatus.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { getApiBaseUrl } from "@/components/main/BaseUrl";
|
||||
import Cookies from "js-cookie";
|
||||
import {
|
||||
getRegistrationRoute,
|
||||
resolveRegistrationRedirect,
|
||||
} from "@/lib/auth/registrationRoutes";
|
||||
|
||||
export type RegisterStatusResponse = {
|
||||
step?: string;
|
||||
page?: "home" | "auth-page";
|
||||
registrationComplete?: boolean;
|
||||
};
|
||||
|
||||
export async function fetchRegisterStatus(): Promise<RegisterStatusResponse | null> {
|
||||
const token = Cookies.get("token");
|
||||
if (!token) return null;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${getApiBaseUrl()}/register/status`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!response.ok) return null;
|
||||
|
||||
return (await response.json()) as RegisterStatusResponse;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveAuthenticatedEntryRedirect(
|
||||
redirectParam?: string | null
|
||||
): Promise<string> {
|
||||
const status = await fetchRegisterStatus();
|
||||
if (!status) {
|
||||
return redirectParam &&
|
||||
redirectParam.startsWith("/") &&
|
||||
!redirectParam.startsWith("//")
|
||||
? redirectParam
|
||||
: "/";
|
||||
}
|
||||
|
||||
if (status.page === "home" || status.registrationComplete) {
|
||||
return redirectParam &&
|
||||
redirectParam.startsWith("/") &&
|
||||
!redirectParam.startsWith("//")
|
||||
? redirectParam
|
||||
: "/";
|
||||
}
|
||||
|
||||
if (redirectParam) {
|
||||
const registrationPath = redirectParam.split("?")[0];
|
||||
const allowed = resolveRegistrationRedirect(
|
||||
registrationPath,
|
||||
status.step
|
||||
);
|
||||
if (allowed === null) {
|
||||
return redirectParam;
|
||||
}
|
||||
}
|
||||
|
||||
return getRegistrationRoute(status.step);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime";
|
||||
import type { IVerifyOtp } from "@/types/types";
|
||||
import { setAuthSession } from "@/lib/auth/session";
|
||||
import {
|
||||
setAuthSession,
|
||||
} from "@/lib/auth/session";
|
||||
import { AUTH_SUCCESS_DELAY_MS, pause } from "@/lib/auth/feedback";
|
||||
import { getRegistrationRoute } from "@/lib/auth/registrationRoutes";
|
||||
|
||||
@@ -20,6 +22,11 @@ export async function navigateIncompleteRegistration(
|
||||
router.push(getRegistrationRoute(step));
|
||||
}
|
||||
|
||||
function navigateAfterAuth(target: string): void {
|
||||
if (typeof window === "undefined") return;
|
||||
window.location.assign(target);
|
||||
}
|
||||
|
||||
export async function completeLogin(
|
||||
router: AppRouterInstance,
|
||||
response: IVerifyOtp,
|
||||
@@ -36,14 +43,14 @@ export async function completeLogin(
|
||||
});
|
||||
|
||||
await pause(AUTH_SUCCESS_DELAY_MS);
|
||||
router.refresh();
|
||||
|
||||
if (response.page === "home") {
|
||||
router.push(options?.redirectTo ?? getSafeRedirectPath());
|
||||
navigateAfterAuth(options?.redirectTo ?? getSafeRedirectPath());
|
||||
return true;
|
||||
}
|
||||
|
||||
if (response.page === "auth-page") {
|
||||
router.refresh();
|
||||
await navigateIncompleteRegistration(router, response.step);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,22 @@
|
||||
export const REGISTRATION_ENTRY_PATH = "/register";
|
||||
export const REGISTRATION_OTP_PATH = "/register-otp";
|
||||
|
||||
export const REGISTRATION_STEP_PATHS = [
|
||||
"/register/username",
|
||||
"/register/password",
|
||||
"/register/fullname",
|
||||
"/register/complete",
|
||||
"/register/usertype",
|
||||
] as const;
|
||||
|
||||
export type RegistrationStep =
|
||||
| "user_name"
|
||||
| "password"
|
||||
| "first_name"
|
||||
| "registration_choice"
|
||||
| "user_type"
|
||||
| "complete";
|
||||
|
||||
export function getRegistrationRoute(step?: string): string {
|
||||
switch (step) {
|
||||
case "user_name":
|
||||
@@ -14,3 +33,69 @@ export function getRegistrationRoute(step?: string): string {
|
||||
return "/register/username";
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeRegistrationPath(pathname: string): string {
|
||||
const base = pathname.split("?")[0].replace(/\/$/, "") || "/";
|
||||
if (base.startsWith("/register/usertype")) return "/register/usertype";
|
||||
if (base.startsWith("/register/complete")) return "/register/complete";
|
||||
if (base.startsWith("/register/fullname")) return "/register/fullname";
|
||||
if (base.startsWith("/register/password")) return "/register/password";
|
||||
if (base.startsWith("/register/username")) return "/register/username";
|
||||
if (base === REGISTRATION_OTP_PATH) return REGISTRATION_OTP_PATH;
|
||||
if (base === REGISTRATION_ENTRY_PATH) return REGISTRATION_ENTRY_PATH;
|
||||
return base;
|
||||
}
|
||||
|
||||
export function isRegistrationPath(pathname: string): boolean {
|
||||
const path = normalizeRegistrationPath(pathname);
|
||||
return (
|
||||
path === REGISTRATION_ENTRY_PATH ||
|
||||
path === REGISTRATION_OTP_PATH ||
|
||||
REGISTRATION_STEP_PATHS.includes(
|
||||
path as (typeof REGISTRATION_STEP_PATHS)[number]
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function requiresRegistrationToken(pathname: string): boolean {
|
||||
const path = normalizeRegistrationPath(pathname);
|
||||
return REGISTRATION_STEP_PATHS.includes(
|
||||
path as (typeof REGISTRATION_STEP_PATHS)[number]
|
||||
);
|
||||
}
|
||||
|
||||
/** اگر کاربر مجاز به pathname فعلی نیست، مسیر درست را برمیگرداند */
|
||||
export function resolveRegistrationRedirect(
|
||||
pathname: string,
|
||||
step?: string
|
||||
): string | null {
|
||||
const normalized = normalizeRegistrationPath(pathname);
|
||||
|
||||
if (!requiresRegistrationToken(normalized)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!step || step === "complete") {
|
||||
return step === "complete" ? "/" : getRegistrationRoute(step);
|
||||
}
|
||||
|
||||
const allowed = getRegistrationRoute(step);
|
||||
|
||||
if (
|
||||
step === "registration_choice" &&
|
||||
(normalized === "/register/complete" || normalized === "/register/usertype")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (normalized === allowed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return allowed;
|
||||
}
|
||||
|
||||
export function buildLoginRedirect(pathname: string): string {
|
||||
const safePath = pathname.startsWith("/") ? pathname : "/register";
|
||||
return `/login?redirect=${encodeURIComponent(safePath)}`;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Cookies from "js-cookie";
|
||||
|
||||
const TOKEN_KEY = "token";
|
||||
const LOGIN_GRACE_KEY = "auth_login_grace_until";
|
||||
|
||||
export type AuthUserMeta = {
|
||||
id?: string;
|
||||
@@ -14,9 +15,44 @@ const cookieOptions = (): Cookies.CookieAttributes => ({
|
||||
path: "/",
|
||||
expires: 365,
|
||||
sameSite: "Lax",
|
||||
secure: typeof window !== "undefined" && window.location.protocol === "https:",
|
||||
secure:
|
||||
typeof window !== "undefined" && window.location.protocol === "https:",
|
||||
});
|
||||
|
||||
async function syncServerCookie(token: string): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch("/api/auth/session", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token }),
|
||||
credentials: "same-origin",
|
||||
});
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** پس از ورود، برای جلوگیری از logout اشتباه روی 401های موقت */
|
||||
export function markLoginGracePeriod(ms = 8000): void {
|
||||
if (typeof window === "undefined") return;
|
||||
sessionStorage.setItem(LOGIN_GRACE_KEY, String(Date.now() + ms));
|
||||
}
|
||||
|
||||
export function isInLoginGracePeriod(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
const until = Number(sessionStorage.getItem(LOGIN_GRACE_KEY) || 0);
|
||||
return Date.now() < until;
|
||||
}
|
||||
|
||||
/** همگامسازی کوکی کلاینت با Route Handler (برای middleware) */
|
||||
export async function ensureAuthSessionSynced(): Promise<boolean> {
|
||||
if (typeof window === "undefined") return false;
|
||||
const token = Cookies.get(TOKEN_KEY);
|
||||
if (!token) return false;
|
||||
return syncServerCookie(token);
|
||||
}
|
||||
|
||||
/** ذخیره توکن در کوکی مرورگر + Route Handler سرور (برای middleware) */
|
||||
export async function setAuthSession(
|
||||
token: string,
|
||||
@@ -26,17 +62,23 @@ export async function setAuthSession(
|
||||
|
||||
Cookies.set(TOKEN_KEY, token, cookieOptions());
|
||||
|
||||
await fetch("/api/auth/session", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token }),
|
||||
credentials: "same-origin",
|
||||
});
|
||||
let synced = await syncServerCookie(token);
|
||||
if (!synced) {
|
||||
synced = await syncServerCookie(token);
|
||||
}
|
||||
|
||||
if (!synced) {
|
||||
console.warn("[auth] server session cookie sync failed");
|
||||
}
|
||||
|
||||
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?.auth_provider) localStorage.setItem("auth_provider", meta.auth_provider);
|
||||
if (meta?.auth_provider) {
|
||||
localStorage.setItem("auth_provider", meta.auth_provider);
|
||||
}
|
||||
if (meta?.email) localStorage.setItem("email", meta.email);
|
||||
}
|
||||
|
||||
@@ -44,6 +86,10 @@ export async function setAuthSession(
|
||||
export async function clearAuthSession(): Promise<void> {
|
||||
Cookies.remove(TOKEN_KEY, { path: "/" });
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
sessionStorage.removeItem(LOGIN_GRACE_KEY);
|
||||
}
|
||||
|
||||
try {
|
||||
await fetch("/api/auth/session", {
|
||||
method: "DELETE",
|
||||
@@ -79,3 +125,9 @@ export function getAuthToken(): string | undefined {
|
||||
export function isAuthenticated(): boolean {
|
||||
return Boolean(getAuthToken());
|
||||
}
|
||||
|
||||
export const PROTECTED_APP_PATHS = ["/settings", "/new-post", "/new-project"];
|
||||
|
||||
export function isProtectedAppPath(path: string): boolean {
|
||||
return PROTECTED_APP_PATHS.some((prefix) => path.startsWith(prefix));
|
||||
}
|
||||
|
||||
97
src/lib/buildAcademyListSeo.ts
Normal file
97
src/lib/buildAcademyListSeo.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
export type AcademyListFilters = {
|
||||
category?: string;
|
||||
type?: string;
|
||||
isFree?: boolean;
|
||||
sortBy?: string;
|
||||
sortOrder?: string;
|
||||
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 "جدیدترین";
|
||||
}
|
||||
|
||||
function resolveTypeLabel(filters: AcademyListFilters): string {
|
||||
if (filters.isFree || filters.type === "free") return "بصورت رایگان";
|
||||
if (filters.type && TYPE_LABELS[filters.type]) {
|
||||
return `بصورت ${TYPE_LABELS[filters.type]}`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function buildAcademyListPath(filters: AcademyListFilters): string {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.search) params.set("search", filters.search);
|
||||
if (filters.category) params.set("category", filters.category);
|
||||
if (filters.type && filters.type !== "free") params.set("type", filters.type);
|
||||
if (filters.isFree || filters.type === "free") params.set("isFree", "true");
|
||||
if (filters.sortBy && filters.sortBy !== "createdAt") {
|
||||
params.set("sortBy", filters.sortBy);
|
||||
}
|
||||
if (filters.sortOrder && filters.sortOrder !== "desc") {
|
||||
params.set("sortOrder", filters.sortOrder);
|
||||
}
|
||||
const query = params.toString();
|
||||
return query ? `/academy?${query}` : "/academy";
|
||||
}
|
||||
|
||||
export function buildAcademyListSeo(filters: AcademyListFilters) {
|
||||
const hasFilters = Boolean(
|
||||
filters.category ||
|
||||
filters.type ||
|
||||
filters.isFree ||
|
||||
filters.search ||
|
||||
(filters.sortBy && filters.sortBy !== "createdAt")
|
||||
);
|
||||
|
||||
if (!hasFilters) {
|
||||
return {
|
||||
title: `${DEFAULT_TITLE}${SITE_SUFFIX}`,
|
||||
description: ACADEMY_LIST_DESCRIPTION,
|
||||
path: "/academy",
|
||||
h1: DEFAULT_TITLE,
|
||||
};
|
||||
}
|
||||
|
||||
const sortLabel = resolveSortLabel(filters);
|
||||
const categoryPart = filters.category ? filters.category : "";
|
||||
const typePart = resolveTypeLabel(filters);
|
||||
|
||||
const titleCore = [sortLabel, "آموزشهای", categoryPart, typePart]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return {
|
||||
title: `${titleCore}${SITE_SUFFIX}`,
|
||||
description: ACADEMY_LIST_DESCRIPTION,
|
||||
path: buildAcademyListPath(filters),
|
||||
h1: titleCore,
|
||||
};
|
||||
}
|
||||
32
src/lib/buildCourseDetailSeo.ts
Normal file
32
src/lib/buildCourseDetailSeo.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
type CourseSeoInput = {
|
||||
_id?: string;
|
||||
cuorse_name?: string;
|
||||
category?: string;
|
||||
teacher_name?: string;
|
||||
caption?: string;
|
||||
price?: string | number;
|
||||
offer?: string | number;
|
||||
is_free?: boolean;
|
||||
finalPrice?: number;
|
||||
};
|
||||
|
||||
export function formatCoursePriceLabel(course: CourseSeoInput): string {
|
||||
if (course.is_free) return "رایگان";
|
||||
|
||||
const basePrice = Number(course.finalPrice ?? course.price) || 0;
|
||||
return `${basePrice.toLocaleString("fa-IR")} تومان`;
|
||||
}
|
||||
|
||||
export function buildCourseDetailSeo(course: CourseSeoInput, id: string) {
|
||||
const courseName = course.cuorse_name || "پکیج آموزشی";
|
||||
const category = course.category || "دستهبندی";
|
||||
const teacher = course.teacher_name || "مدرس";
|
||||
const rawCaption = (course.caption || "").replace(/<[^>]*>?/gm, "").trim();
|
||||
const priceLabel = formatCoursePriceLabel(course);
|
||||
|
||||
return {
|
||||
title: `${courseName} – ${category} – ${teacher} | مدستاگرام`,
|
||||
description: [rawCaption || courseName, priceLabel].filter(Boolean).join(" - "),
|
||||
path: `/academy/${id}/${encodeURIComponent(courseName)}`,
|
||||
};
|
||||
}
|
||||
18
src/lib/buildProjectDetailSeo.ts
Normal file
18
src/lib/buildProjectDetailSeo.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Project } from "@/types/types";
|
||||
|
||||
export function formatProjectBudget(price: number | null | undefined): string {
|
||||
if (price == null || Number.isNaN(Number(price))) return "";
|
||||
return `${Number(price).toLocaleString("fa-IR")} تومان`;
|
||||
}
|
||||
|
||||
export function buildProjectDetailSeo(project: Project) {
|
||||
const title = `${project.title} ثبت شده در ${project.expertise} - مدستاگرام`;
|
||||
const budget = formatProjectBudget(project.offer_price);
|
||||
const description = [budget, project.description?.trim()]
|
||||
.filter(Boolean)
|
||||
.join(" – ");
|
||||
|
||||
const path = `/projects/${project._id}/${encodeURIComponent(project.title)}`;
|
||||
|
||||
return { title, description, path };
|
||||
}
|
||||
72
src/lib/buildProjectsListSeo.ts
Normal file
72
src/lib/buildProjectsListSeo.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
export type ProjectListFilters = {
|
||||
expertise?: string;
|
||||
most_requests?: string;
|
||||
most_price?: string;
|
||||
age?: string;
|
||||
gender?: string;
|
||||
};
|
||||
|
||||
const BASE_TITLE = "پروژه ها و فرصتهای همکاری در دنیای زیبایی";
|
||||
const SITE_SUFFIX = " | مدستاگرام";
|
||||
|
||||
export const PROJECTS_LIST_DESCRIPTION =
|
||||
"پروژههای فعال در حوزه زیبایی را ببینید، درخواست همکاری بدهید یا پروژه جدیدی برای مدلها، عکاسها و آرایشگرها منتشر کنید.";
|
||||
|
||||
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[] {
|
||||
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.gender && GENDER_LABELS[filters.gender]) {
|
||||
parts.push(GENDER_LABELS[filters.gender]);
|
||||
}
|
||||
if (filters.most_price) {
|
||||
parts.push("با بیشترین بودجه");
|
||||
}
|
||||
if (filters.most_requests) {
|
||||
parts.push("با بیشترین پیشنهاد");
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
export function buildProjectsListPath(filters: ProjectListFilters): string {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (filters.expertise) params.set("expertise", filters.expertise);
|
||||
if (filters.most_requests) params.set("most_requests", filters.most_requests);
|
||||
if (filters.most_price) params.set("most_price", filters.most_price);
|
||||
if (filters.age) params.set("age", filters.age);
|
||||
if (filters.gender) params.set("gender", filters.gender);
|
||||
|
||||
const query = params.toString();
|
||||
return query ? `/projects?${query}` : "/projects";
|
||||
}
|
||||
|
||||
export function buildProjectsListSeo(filters: ProjectListFilters) {
|
||||
const filterParts = buildFilterParts(filters);
|
||||
const filterSegment = filterParts.length
|
||||
? ` برای ${filterParts.join(" ")}`
|
||||
: "";
|
||||
|
||||
return {
|
||||
title: `${BASE_TITLE}${filterSegment}${SITE_SUFFIX}`,
|
||||
description: PROJECTS_LIST_DESCRIPTION,
|
||||
path: buildProjectsListPath(filters),
|
||||
h1: `${BASE_TITLE}${filterSegment}`,
|
||||
};
|
||||
}
|
||||
20
src/lib/chat/deleteWindow.ts
Normal file
20
src/lib/chat/deleteWindow.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export const DELETE_WINDOW_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export function parseMessageCreatedAt(
|
||||
createdAt?: string | null,
|
||||
createdAtIso?: string | null
|
||||
): Date | null {
|
||||
const raw = createdAtIso || createdAt;
|
||||
if (!raw) return null;
|
||||
const d = new Date(String(raw));
|
||||
return Number.isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
export function isMessageDeletable(
|
||||
createdAt?: string | null,
|
||||
createdAtIso?: string | null
|
||||
): boolean {
|
||||
const created = parseMessageCreatedAt(createdAt, createdAtIso);
|
||||
if (!created) return false;
|
||||
return Date.now() - created.getTime() <= DELETE_WINDOW_MS;
|
||||
}
|
||||
@@ -28,10 +28,13 @@ export function getChatSocket(): Socket | null {
|
||||
|
||||
export function releaseChatSocket(): void {
|
||||
refCount = Math.max(0, refCount - 1);
|
||||
if (refCount === 0 && sharedSocket) {
|
||||
sharedSocket.removeAllListeners();
|
||||
}
|
||||
|
||||
export function disconnectChatSocket(): void {
|
||||
if (sharedSocket) {
|
||||
sharedSocket.disconnect();
|
||||
sharedSocket = null;
|
||||
refCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,23 +15,16 @@ function expertiseMatches(a?: string, b?: string): boolean {
|
||||
return aliases.includes(b);
|
||||
}
|
||||
|
||||
function shuffle<T>(items: T[], seed = Date.now()): T[] {
|
||||
const copy = [...items];
|
||||
let state = seed % 2147483647 || 1;
|
||||
const random = () => {
|
||||
state = (state * 16807) % 2147483647;
|
||||
return (state - 1) / 2147483646;
|
||||
};
|
||||
for (let i = copy.length - 1; i > 0; i -= 1) {
|
||||
const j = Math.floor(random() * (i + 1));
|
||||
[copy[i], copy[j]] = [copy[j], copy[i]];
|
||||
}
|
||||
return copy;
|
||||
function sortByRecency(posts: Post[]): Post[] {
|
||||
return [...posts].sort(
|
||||
(a, b) =>
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
);
|
||||
}
|
||||
|
||||
function interleaveWeighted<T>(primary: T[], secondary: T[]): T[] {
|
||||
if (!primary.length) return shuffle(secondary);
|
||||
if (!secondary.length) return shuffle(primary);
|
||||
if (!primary.length) return sortByRecency(secondary as Post[]) as T[];
|
||||
if (!secondary.length) return sortByRecency(primary as Post[]) as T[];
|
||||
|
||||
const result: T[] = [];
|
||||
let primaryIdx = 0;
|
||||
@@ -77,7 +70,7 @@ function matchesSeedCategory(post: Post, seed: Post): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Cold-start reels order: random, but ~72% same category as clicked post */
|
||||
/** Cold-start reels: جدیدترینها با اولویت همان دستهٔ پست کلیکشده */
|
||||
export function reorderColdStartFeed(posts: Post[], seed: Post): Post[] {
|
||||
const sameCategory: Post[] = [];
|
||||
const others: Post[] = [];
|
||||
@@ -90,9 +83,8 @@ export function reorderColdStartFeed(posts: Post[], seed: Post): Post[] {
|
||||
}
|
||||
});
|
||||
|
||||
const seedKey = String(seed._id || "");
|
||||
return interleaveWeighted(
|
||||
shuffle(sameCategory, seedKey.length * 997),
|
||||
shuffle(others, seedKey.length * 499)
|
||||
sortByRecency(sameCategory),
|
||||
sortByRecency(others)
|
||||
);
|
||||
}
|
||||
|
||||
55
src/lib/expertiseColors.ts
Normal file
55
src/lib/expertiseColors.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
const EXPERTISE_COLORS: Record<string, string> = {
|
||||
مدل: "#B89FFF",
|
||||
آرایشگر: "#23bace",
|
||||
عکاس: "#dace63",
|
||||
پوشاک: "#f472b6",
|
||||
آموزشی: "#38bdf8",
|
||||
};
|
||||
|
||||
const FALLBACK_PALETTE = [
|
||||
"#B89FFF",
|
||||
"#23bace",
|
||||
"#dace63",
|
||||
"#38bdf8",
|
||||
"#f97316",
|
||||
"#ec4899",
|
||||
"#a78bfa",
|
||||
"#fbbf24",
|
||||
"#34d399",
|
||||
"#f472b6",
|
||||
];
|
||||
|
||||
export function getExpertiseColor(name: string, index = 0): string {
|
||||
return EXPERTISE_COLORS[name] ?? FALLBACK_PALETTE[index % FALLBACK_PALETTE.length];
|
||||
}
|
||||
|
||||
/** استخراج نام تخصص از slug فارسی — مثلاً «استخدام پوشاک در شهر تهران» → «پوشاک» */
|
||||
export function parseExpertiseFromSlug(fullText: string): string {
|
||||
if (
|
||||
fullText.includes("متخصصین مد و زیبایی") ||
|
||||
/متخصصین(?!\s)/.test(fullText)
|
||||
) {
|
||||
return "";
|
||||
}
|
||||
|
||||
let rest = fullText.trim();
|
||||
const prefixes = [
|
||||
"ویدئو ها و نمونه کارهای",
|
||||
"ویدئو ها",
|
||||
"استخدام",
|
||||
];
|
||||
for (const prefix of prefixes) {
|
||||
if (rest.startsWith(prefix)) {
|
||||
rest = rest.slice(prefix.length).trim();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!rest) return "";
|
||||
|
||||
const withoutLevel = rest.split(/\s+سطح\s+/)[0] ?? rest;
|
||||
const withoutLocation =
|
||||
withoutLevel.split(/\s+در\s+(?:شهر|استان)\s+/)[0] ?? withoutLevel;
|
||||
|
||||
return withoutLocation.trim();
|
||||
}
|
||||
@@ -8,6 +8,8 @@ export type PostFetchFilters = {
|
||||
subExpertise?: string;
|
||||
lat?: string;
|
||||
lng?: string;
|
||||
q?: string;
|
||||
hashtag?: string;
|
||||
};
|
||||
|
||||
export function buildExplorePostFilters(
|
||||
|
||||
36
src/lib/explore/postMedia.ts
Normal file
36
src/lib/explore/postMedia.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Post } from "@/types/types";
|
||||
import { buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import { resolvePostMediaType } from "@/lib/reelsMediaType";
|
||||
|
||||
const VIDEO_EXT = /\.(mp4|webm|mov|m4v|mkv)(\?|$)/i;
|
||||
|
||||
export function getVideoPoster(post: Post): string | undefined {
|
||||
if (post.post_image) return buildStorageUrl(post.post_image);
|
||||
const imageFile = post.files?.find((file) => file.type === "image");
|
||||
if (imageFile?.path) return buildStorageUrl(imageFile.path);
|
||||
const imagePath = post.post_images?.[0];
|
||||
if (imagePath) return buildStorageUrl(imagePath);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getPostMedia(post: Post): {
|
||||
kind: "video" | "image";
|
||||
src: string;
|
||||
poster?: string;
|
||||
} | null {
|
||||
const file = post.files?.[0];
|
||||
if (!file?.path) return null;
|
||||
|
||||
const src = buildStorageUrl(file.path);
|
||||
const resolvedType = resolvePostMediaType(post);
|
||||
const isVideo =
|
||||
resolvedType === "video" ||
|
||||
file.type === "video" ||
|
||||
VIDEO_EXT.test(file.path);
|
||||
|
||||
if (isVideo) {
|
||||
return { kind: "video", src, poster: getVideoPoster(post) };
|
||||
}
|
||||
|
||||
return { kind: "image", src };
|
||||
}
|
||||
15
src/lib/formatCoursePriceLabel.ts
Normal file
15
src/lib/formatCoursePriceLabel.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Course } from "@/types/types";
|
||||
|
||||
export function formatCoursePriceLabel(course: Course): string {
|
||||
if (course.is_free) return "رایگان";
|
||||
|
||||
const price = Number(course.price) || 0;
|
||||
const offer = Number(course.offer) || 0;
|
||||
|
||||
if (offer > 0) {
|
||||
const finalPrice = Math.round((price * (100 - offer)) / 100);
|
||||
return `${finalPrice.toLocaleString("fa-IR")} تومان`;
|
||||
}
|
||||
|
||||
return `${price.toLocaleString("fa-IR")} تومان`;
|
||||
}
|
||||
9
src/lib/formatLikeDisplay.ts
Normal file
9
src/lib/formatLikeDisplay.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export const LIKE_DISPLAY_THRESHOLD = 50;
|
||||
export const LIKE_LABEL_BELOW_THRESHOLD = "پسند";
|
||||
|
||||
/** زیر ۵۰ → «پسند»، از ۵۰ به بالا → عدد */
|
||||
export function formatLikeDisplay(count: number): string {
|
||||
const n = Math.max(0, Math.floor(Number(count) || 0));
|
||||
if (n < LIKE_DISPLAY_THRESHOLD) return LIKE_LABEL_BELOW_THRESHOLD;
|
||||
return String(n);
|
||||
}
|
||||
@@ -6,6 +6,10 @@ export function getCreateProjectPath(): string {
|
||||
return "/new-project";
|
||||
}
|
||||
|
||||
export function getCreateAcademyPath(): string {
|
||||
return "/settings/academy/course";
|
||||
}
|
||||
|
||||
/** @deprecated همه کاربران میتوانند پست و پروژه ثبت کنند */
|
||||
export function getCreateContentPath(_userType?: string | null): string {
|
||||
return getCreatePostPath();
|
||||
|
||||
6
src/lib/isModelExpertise.ts
Normal file
6
src/lib/isModelExpertise.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/** کاربر با تخصص مدل / مدلینگ */
|
||||
export function isModelExpertise(expertise?: string | null): boolean {
|
||||
if (!expertise) return false;
|
||||
const n = expertise.trim();
|
||||
return n === "مدلینگ" || n === "مدل";
|
||||
}
|
||||
70
src/lib/postSlug.ts
Normal file
70
src/lib/postSlug.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { Post } from "@/types/types";
|
||||
|
||||
function slugify(text: string): string {
|
||||
return text
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^\p{L}\p{N}\s-]/gu, "")
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-|-$/g, "")
|
||||
.slice(0, 80);
|
||||
}
|
||||
|
||||
export function buildPostSlug(post: Pick<Post, "caption" | "user_name" | "first_name" | "last_name">): string {
|
||||
const author =
|
||||
[post.first_name, post.last_name].filter(Boolean).join(" ") ||
|
||||
post.user_name ||
|
||||
"post";
|
||||
const captionPart = post.caption ? slugify(post.caption.slice(0, 60)) : "";
|
||||
const authorPart = slugify(author);
|
||||
const combined = [authorPart, captionPart].filter(Boolean).join("-");
|
||||
return combined || "post";
|
||||
}
|
||||
|
||||
export function buildPostPath(
|
||||
postId: string,
|
||||
post?: Pick<Post, "caption" | "user_name" | "first_name" | "last_name">
|
||||
): string {
|
||||
const slug = post ? buildPostSlug(post) : "post";
|
||||
return `/posts/${postId}/${slug}`;
|
||||
}
|
||||
|
||||
export function buildPostPublicUrl(
|
||||
postId: string,
|
||||
post?: Pick<Post, "caption" | "user_name" | "first_name" | "last_name">
|
||||
): string {
|
||||
return `https://modstagram.com${buildPostPath(postId, post)}`;
|
||||
}
|
||||
|
||||
export function buildPostSeoTitle(
|
||||
post: Pick<Post, "caption" | "user_name" | "first_name" | "last_name" | "expertise">
|
||||
): string {
|
||||
const author =
|
||||
[post.first_name, post.last_name].filter(Boolean).join(" ") ||
|
||||
post.user_name ||
|
||||
"کاربر";
|
||||
const caption = post.caption?.trim();
|
||||
if (caption) {
|
||||
return `${caption.slice(0, 55)} | ${author} | مدستاگرام`;
|
||||
}
|
||||
if (post.expertise) {
|
||||
return `پست ${post.expertise} از ${author} | مدستاگرام`;
|
||||
}
|
||||
return `پست ${author} | مدستاگرام`;
|
||||
}
|
||||
|
||||
export function buildPostSeoDescription(
|
||||
post: Pick<Post, "caption" | "user_name" | "first_name" | "last_name" | "expertise">
|
||||
): string {
|
||||
const author =
|
||||
[post.first_name, post.last_name].filter(Boolean).join(" ") ||
|
||||
post.user_name ||
|
||||
"کاربر";
|
||||
const caption = post.caption?.trim();
|
||||
if (caption) {
|
||||
return `${caption.slice(0, 140)} — ${author} در مدستاگرام، پلتفرم تخصصی زیبایی، مدلینگ و عکاسی.`;
|
||||
}
|
||||
const expertise = post.expertise ? ` (${post.expertise})` : "";
|
||||
return `مشاهده پست${expertise} ${author} در مدستاگرام — پلتفرم تخصصی حوزه زیبایی، مدلینگ و عکاسی.`;
|
||||
}
|
||||
39
src/lib/pwa/install.ts
Normal file
39
src/lib/pwa/install.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
const DISMISS_KEY = "pwa_install_dismissed_until";
|
||||
|
||||
export type BeforeInstallPromptEvent = Event & {
|
||||
prompt: () => Promise<void>;
|
||||
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
|
||||
};
|
||||
|
||||
export function isStandaloneMode(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
|
||||
const nav = window.navigator as Navigator & { standalone?: boolean };
|
||||
return (
|
||||
window.matchMedia("(display-mode: standalone)").matches ||
|
||||
nav.standalone === true
|
||||
);
|
||||
}
|
||||
|
||||
export function isIosDevice(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
return /iphone|ipad|ipod/i.test(window.navigator.userAgent);
|
||||
}
|
||||
|
||||
export function isDismissed(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
const until = Number(localStorage.getItem(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));
|
||||
}
|
||||
|
||||
export function clearInstallDismissal(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
localStorage.removeItem(DISMISS_KEY);
|
||||
}
|
||||
35
src/lib/reelsMediaType.ts
Normal file
35
src/lib/reelsMediaType.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Post } from "@/types/types";
|
||||
|
||||
const VIDEO_EXT = /\.(mp4|webm|mov|m4v|mkv)(\?|$)/i;
|
||||
|
||||
export type ReelsMediaType = "image" | "video";
|
||||
|
||||
type PostMediaSource = Pick<Post, "type" | "files"> | null | undefined;
|
||||
|
||||
export function resolvePostMediaType(
|
||||
post: PostMediaSource
|
||||
): ReelsMediaType | undefined {
|
||||
if (post?.type === "video" || post?.type === "image") return post.type;
|
||||
const file = post?.files?.[0];
|
||||
const fileType = file?.type;
|
||||
if (fileType === "video" || fileType === "image") return fileType;
|
||||
if (file?.path && VIDEO_EXT.test(file.path)) return "video";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function resolveReelsMediaType(
|
||||
post: PostMediaSource,
|
||||
videoOnly = false
|
||||
): ReelsMediaType | undefined {
|
||||
if (videoOnly) return "video";
|
||||
return resolvePostMediaType(post);
|
||||
}
|
||||
|
||||
export function postMatchesReelsMediaType(
|
||||
post: Pick<Post, "type" | "files">,
|
||||
mediaType?: ReelsMediaType
|
||||
): boolean {
|
||||
if (!mediaType) return true;
|
||||
const resolved = resolvePostMediaType(post);
|
||||
return !resolved || resolved === mediaType;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Post } from "@/types/types";
|
||||
import { prefetchPostVideo } from "@/lib/videoPreload";
|
||||
|
||||
const KEY_PREFIX = "reels-seed-post-";
|
||||
|
||||
@@ -6,6 +7,7 @@ export function cacheReelsSeedPost(post: Post): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
sessionStorage.setItem(`${KEY_PREFIX}${post._id}`, JSON.stringify(post));
|
||||
prefetchPostVideo(post, "high");
|
||||
} catch {
|
||||
/* ignore quota errors */
|
||||
}
|
||||
|
||||
66
src/lib/ui/buttonStyles.ts
Normal file
66
src/lib/ui/buttonStyles.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Shared Modstagram button design — border #C3C3C3, accent #FC8EAC */
|
||||
export const BTN_BORDER = "border-[#C3C3C3]";
|
||||
|
||||
export const btnBase =
|
||||
"btn-modern gentle-transition font-semibold active:scale-[0.98] disabled:pointer-events-none disabled:opacity-50";
|
||||
|
||||
export const btnDefault = cn(
|
||||
btnBase,
|
||||
"rounded-3xl bg-white/90 px-4 text-foreground",
|
||||
BTN_BORDER,
|
||||
"hover:bg-white dark:bg-neutral-900/90 dark:hover:bg-neutral-900"
|
||||
);
|
||||
|
||||
export const btnPrimary = cn(
|
||||
btnBase,
|
||||
"btn-modern--primary rounded-3xl px-4 text-white",
|
||||
BTN_BORDER
|
||||
);
|
||||
|
||||
export const btnSecondary = cn(btnDefault, "font-medium text-foreground/90");
|
||||
|
||||
export const btnFilterChip = cn(
|
||||
btnBase,
|
||||
"rounded-3xl bg-white/90 px-4 py-1.5 text-xs font-semibold",
|
||||
BTN_BORDER,
|
||||
"dark:bg-neutral-900/90"
|
||||
);
|
||||
|
||||
export const btnFilterChipActive = cn(
|
||||
btnBase,
|
||||
"btn-modern--selected rounded-3xl px-4 py-1.5 text-xs font-semibold text-white",
|
||||
BTN_BORDER
|
||||
);
|
||||
|
||||
export function filterChipClass(active: boolean, className?: string) {
|
||||
return cn(active ? btnFilterChipActive : btnFilterChip, className);
|
||||
}
|
||||
|
||||
export function toggleBtnClass(active: boolean, className?: string) {
|
||||
return cn(
|
||||
btnBase,
|
||||
"rounded-3xl px-4",
|
||||
BTN_BORDER,
|
||||
active ? "btn-modern--selected text-white" : "bg-white/90 text-foreground dark:bg-neutral-900/90",
|
||||
className
|
||||
);
|
||||
}
|
||||
|
||||
export function selectionCardClass(active: boolean, className?: string) {
|
||||
return cn(
|
||||
"w-full max-w-full p-2",
|
||||
active && "btn-modern--selected text-white",
|
||||
className
|
||||
);
|
||||
}
|
||||
|
||||
/** Compact profile / grid action buttons */
|
||||
export const profileActionBtnClass = cn(
|
||||
btnDefault,
|
||||
"flex h-7 md:h-8 w-full max-w-full items-center justify-center rounded-3xl px-2 text-center text-[12px] font-semibold md:text-sm max-sm:text-[8px]"
|
||||
);
|
||||
|
||||
export const btnHeightSm = "h-9";
|
||||
export const btnHeightMd = "h-10";
|
||||
@@ -3,6 +3,7 @@ export const USER_LEVELS = {
|
||||
STANDARD: "استاندارد",
|
||||
PRO: "حرفهای",
|
||||
MASTER: "استاد",
|
||||
SUPERMODEL: "سپورمدل",
|
||||
} as const;
|
||||
|
||||
/** Normalize level from URL slug or query (handles ZWNJ / spacing variants). */
|
||||
@@ -14,6 +15,7 @@ export function normalizeUserLevel(raw?: string | null): string {
|
||||
if (text === USER_LEVELS.NEW) return USER_LEVELS.NEW;
|
||||
if (text === USER_LEVELS.STANDARD) return USER_LEVELS.STANDARD;
|
||||
if (text === USER_LEVELS.MASTER) return USER_LEVELS.MASTER;
|
||||
if (text === USER_LEVELS.SUPERMODEL) return USER_LEVELS.SUPERMODEL;
|
||||
if (text === USER_LEVELS.PRO || text === "حرفه ای") return USER_LEVELS.PRO;
|
||||
|
||||
return raw.trim();
|
||||
@@ -25,6 +27,7 @@ export function parseUserLevelFromSlug(fullText: string): string {
|
||||
|
||||
if (text.includes(USER_LEVELS.NEW)) return USER_LEVELS.NEW;
|
||||
if (text.includes(USER_LEVELS.STANDARD)) return USER_LEVELS.STANDARD;
|
||||
if (text.includes(USER_LEVELS.SUPERMODEL)) return USER_LEVELS.SUPERMODEL;
|
||||
if (text.includes(USER_LEVELS.MASTER)) return USER_LEVELS.MASTER;
|
||||
if (/حرفه[\s\u200c]*ای/.test(text) || text.includes(USER_LEVELS.PRO)) {
|
||||
return USER_LEVELS.PRO;
|
||||
|
||||
84
src/lib/videoPreload.ts
Normal file
84
src/lib/videoPreload.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { Post } from "@/types/types";
|
||||
import { buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import { resolvePostMediaType } from "@/lib/reelsMediaType";
|
||||
|
||||
const MAX_WARM_VIDEOS = 8;
|
||||
const warmVideos = new Map<string, HTMLVideoElement>();
|
||||
const prefetchedLinks = new Set<string>();
|
||||
|
||||
export function getPostVideoSrc(post: Pick<Post, "files" | "type">): string | undefined {
|
||||
const videoFile =
|
||||
post.files?.find((file) => file.type === "video") ??
|
||||
(resolvePostMediaType(post) === "video" ? post.files?.[0] : undefined);
|
||||
|
||||
if (!videoFile?.path) return undefined;
|
||||
return buildStorageUrl(videoFile.path);
|
||||
}
|
||||
|
||||
function evictOldestWarmVideo() {
|
||||
const firstKey = warmVideos.keys().next().value as string | undefined;
|
||||
if (!firstKey) return;
|
||||
warmVideos.get(firstKey)?.remove();
|
||||
warmVideos.delete(firstKey);
|
||||
}
|
||||
|
||||
function warmVideoElement(url: string) {
|
||||
if (typeof window === "undefined" || !url || warmVideos.has(url)) return;
|
||||
|
||||
const video = document.createElement("video");
|
||||
video.preload = "auto";
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
video.setAttribute("playsinline", "");
|
||||
video.setAttribute("webkit-playsinline", "");
|
||||
video.src = url;
|
||||
video.style.cssText =
|
||||
"position:fixed;width:0;height:0;opacity:0;pointer-events:none;z-index:-1";
|
||||
|
||||
document.body.appendChild(video);
|
||||
video.load();
|
||||
|
||||
if (warmVideos.size >= MAX_WARM_VIDEOS) evictOldestWarmVideo();
|
||||
warmVideos.set(url, video);
|
||||
}
|
||||
|
||||
function linkPrefetchVideo(url: string) {
|
||||
if (typeof window === "undefined" || !url || prefetchedLinks.has(url)) return;
|
||||
prefetchedLinks.add(url);
|
||||
|
||||
const link = document.createElement("link");
|
||||
link.rel = "prefetch";
|
||||
link.as = "video";
|
||||
link.href = url;
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
export type VideoPrefetchPriority = "high" | "low";
|
||||
|
||||
/** پیشبارگذاری ویدئو در HTTP cache + buffer مخفی */
|
||||
export function prefetchVideoUrl(
|
||||
url: string | undefined,
|
||||
priority: VideoPrefetchPriority = "high"
|
||||
) {
|
||||
if (!url) return;
|
||||
linkPrefetchVideo(url);
|
||||
if (priority === "high") warmVideoElement(url);
|
||||
}
|
||||
|
||||
export function prefetchPostVideo(
|
||||
post: Pick<Post, "files" | "type" | "_id"> | null | undefined,
|
||||
priority: VideoPrefetchPriority = "high"
|
||||
) {
|
||||
prefetchVideoUrl(getPostVideoSrc(post ?? undefined), priority);
|
||||
}
|
||||
|
||||
export function prefetchPostVideos(
|
||||
posts: Array<Pick<Post, "files" | "type" | "_id">>,
|
||||
priority: VideoPrefetchPriority = "low"
|
||||
) {
|
||||
posts.forEach((post) => prefetchPostVideo(post, priority));
|
||||
}
|
||||
|
||||
export function isVideoWarming(url: string | undefined): boolean {
|
||||
return !!url && warmVideos.has(url);
|
||||
}
|
||||
Reference in New Issue
Block a user