full update

This commit is contained in:
payacom
2026-07-18 14:29:05 +03:30
parent 17f334d0f1
commit af5b27b610
239 changed files with 10283 additions and 3041 deletions

View 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 };
}
}

View File

@@ -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,

View 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);
});
}

View 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);
}

View File

@@ -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;
}

View File

@@ -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)}`;
}

View File

@@ -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));
}

View 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,
};
}

View 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)}`,
};
}

View 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 };
}

View 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}`,
};
}

View 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;
}

View File

@@ -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;
}
}

View File

@@ -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)
);
}

View 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();
}

View File

@@ -8,6 +8,8 @@ export type PostFetchFilters = {
subExpertise?: string;
lat?: string;
lng?: string;
q?: string;
hashtag?: string;
};
export function buildExplorePostFilters(

View 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 };
}

View 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")} تومان`;
}

View 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);
}

View File

@@ -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();

View 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
View 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
View 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
View 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;
}

View File

@@ -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 */
}

View 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";

View File

@@ -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
View 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);
}