Initial commit

This commit is contained in:
payacom
2026-07-08 21:01:18 +03:30
parent 78decbba05
commit d2d3398471
1210 changed files with 9645 additions and 2522 deletions

5
src/lib/auth/feedback.ts Normal file
View File

@@ -0,0 +1,5 @@
export const AUTH_SUCCESS_DELAY_MS = 600;
export function pause(ms: number) {
return new Promise<void>((resolve) => setTimeout(resolve, ms));
}

23
src/lib/auth/otpTimer.ts Normal file
View File

@@ -0,0 +1,23 @@
export const OTP_VALID_SECONDS = 3 * 60;
const OTP_SENT_AT_KEY = "otp_sent_at";
export function markOtpSent(): void {
if (typeof window === "undefined") return;
sessionStorage.setItem(OTP_SENT_AT_KEY, Date.now().toString());
}
export function getOtpRemainingSeconds(): number {
if (typeof window === "undefined") return OTP_VALID_SECONDS;
const sent = sessionStorage.getItem(OTP_SENT_AT_KEY);
if (!sent) return OTP_VALID_SECONDS;
const elapsed = Math.floor((Date.now() - Number(sent)) / 1000);
return Math.max(0, OTP_VALID_SECONDS - elapsed);
}
export function formatOtpCountdown(totalSeconds: number): string {
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
}

75
src/lib/auth/session.ts Normal file
View File

@@ -0,0 +1,75 @@
import Cookies from "js-cookie";
const TOKEN_KEY = "token";
export type AuthUserMeta = {
id?: string;
user_type?: string;
step?: string;
};
const cookieOptions = (): Cookies.CookieAttributes => ({
path: "/",
expires: 365,
sameSite: "Lax",
secure: typeof window !== "undefined" && window.location.protocol === "https:",
});
/** ذخیره توکن در کوکی مرورگر + Route Handler سرور (برای middleware) */
export async function setAuthSession(
token: string,
meta?: AuthUserMeta
): Promise<void> {
if (!token) return;
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",
});
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);
}
/** پاک کردن کامل session */
export async function clearAuthSession(): Promise<void> {
Cookies.remove(TOKEN_KEY, { path: "/" });
try {
await fetch("/api/auth/session", {
method: "DELETE",
credentials: "same-origin",
});
} catch {
// 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");
}
export function getStoredUserId(): string {
if (typeof window === "undefined") return "";
return localStorage.getItem("id") ?? "";
}
export function getAuthToken(): string | undefined {
if (typeof window === "undefined") return undefined;
return Cookies.get(TOKEN_KEY);
}
export function isAuthenticated(): boolean {
return Boolean(getAuthToken());
}

View File

@@ -1,3 +1,37 @@
/** Parse message timestamp (ISO 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;
return null;
}
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 {
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 (d.getFullYear() === today.getFullYear()) {
return d.toLocaleDateString("fa-IR", { month: "long", day: "numeric" });
}
return d.toLocaleDateString("fa-IR", {
year: "numeric",
month: "long",
day: "numeric",
});
}
/** Bubble time: hours, minutes, seconds only (no date) */
export function formatBubbleTime(createdAt?: string | null): string {
if (!createdAt) {

View File

@@ -1,43 +1,17 @@
import type { ChatMessage } from "@/components/chat/ChatMessageCard";
import {
formatChatDateLabel,
parseMessageDate,
} from "@/lib/chat/formatMessageTime";
export type MessageGroup =
| { type: "date"; label: string; key: string }
| { type: "message"; message: ChatMessage; key: string };
function normalizeDateInput(isoOrTime?: string | null): string {
if (isoOrTime == null || String(isoOrTime).trim() === "") {
return new Date().toISOString();
}
return String(isoOrTime);
}
function formatDateLabel(isoOrTime?: string | null): string {
const raw = normalizeDateInput(isoOrTime);
const d = new Date(raw);
if (!Number.isNaN(d.getTime())) {
const today = new Date();
const yesterday = new Date();
yesterday.setDate(today.getDate() - 1);
if (d.toDateString() === today.toDateString()) return "امروز";
if (d.toDateString() === yesterday.toDateString()) return "دیروز";
return d.toLocaleDateString("fa-IR", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
});
}
const fallback = raw.split(" ")[0];
return fallback || "امروز";
}
function dayKey(isoOrTime?: string | null): string {
const raw = normalizeDateInput(isoOrTime);
const d = new Date(raw);
if (!Number.isNaN(d.getTime())) {
return d.toDateString();
}
return raw.split(" ")[0] || "unknown-day";
function dayKey(createdAt?: string | null): string {
const d = parseMessageDate(createdAt);
if (d) return d.toDateString();
return "unknown-day";
}
export function groupMessagesByDate(messages: ChatMessage[]): MessageGroup[] {
@@ -52,7 +26,7 @@ export function groupMessagesByDate(messages: ChatMessage[]): MessageGroup[] {
lastDay = dk;
groups.push({
type: "date",
label: formatDateLabel(msg.createdAt),
label: formatChatDateLabel(msg.createdAt),
key: `date-${dk}`,
});
}

View File

@@ -0,0 +1,80 @@
import type { ChatMessage } from "@/components/chat/ChatMessageCard";
import { parseMessageDate } from "@/lib/chat/formatMessageTime";
export type BubbleGroupPosition = "single" | "first" | "middle" | "last";
function dayKey(createdAt?: string | null): string {
const d = parseMessageDate(createdAt);
if (d) return d.toDateString();
return "unknown-day";
}
export function sameSender(a: ChatMessage, b: ChatMessage): boolean {
return String(a.senderId) === String(b.senderId);
}
function sameVisualGroup(a: ChatMessage, b: ChatMessage): boolean {
return sameSender(a, b) && dayKey(a.createdAt) === dayKey(b.createdAt);
}
export { sameVisualGroup };
export function getBubbleGroupPosition(
messages: ChatMessage[],
index: number
): BubbleGroupPosition {
const current = messages[index];
if (!current) return "single";
const sameAsPrev =
index > 0 && sameVisualGroup(messages[index - 1], current);
const sameAsNext =
index < messages.length - 1 &&
sameVisualGroup(messages[index + 1], current);
if (!sameAsPrev && !sameAsNext) return "single";
if (!sameAsPrev && sameAsNext) return "first";
if (sameAsPrev && sameAsNext) return "middle";
return "last";
}
/** Instagram-style corner radii (TL TR BR BL) */
export function getBubbleRadius(
isOutgoing: boolean,
position: BubbleGroupPosition
): string {
const r = 18;
const s = 4;
if (isOutgoing) {
switch (position) {
case "single":
return `${r}px ${r}px ${r}px ${s}px`;
case "first":
return `${r}px ${r}px ${r}px ${s}px`;
case "middle":
return `${s}px ${r}px ${r}px ${s}px`;
case "last":
return `${s}px ${r}px ${r}px ${r}px`;
}
}
switch (position) {
case "single":
return `${r}px ${r}px ${s}px ${r}px`;
case "first":
return `${r}px ${r}px ${s}px ${r}px`;
case "middle":
return `${r}px ${s}px ${s}px ${r}px`;
case "last":
return `${r}px ${s}px ${r}px ${r}px`;
}
}
export function getMessageRowSpacing(
position: BubbleGroupPosition,
isGroupedWithPrev: boolean
): string {
if (isGroupedWithPrev) return "mt-[2px]";
return "mt-3";
}

View File

@@ -0,0 +1,3 @@
export function chatThreadQueryKey(senderId: string, receiverId: string) {
return ["chatThread", senderId, receiverId] as const;
}

View File

@@ -0,0 +1,36 @@
import type { InfiniteData } from "@tanstack/react-query";
import type { ChatMessage } from "@/components/chat/ChatMessageCard";
export type ChatThreadPage = { messages: ChatMessage[]; nextPage?: number };
export type ChatThreadData = InfiniteData<ChatThreadPage, number>;
export function normalizeThreadId(id: unknown): string {
if (!id) return "";
return String(id);
}
export function appendMessageToThreadCache(
oldData: ChatThreadData | undefined,
message: ChatMessage
): ChatThreadData {
if (!oldData?.pages?.length) {
return {
pages: [{ messages: [message], nextPage: undefined }],
pageParams: [1],
};
}
const exists = oldData.pages.some((p) =>
p.messages.some((m) => m._id === message._id)
);
if (exists) return oldData;
const newPages = [...oldData.pages];
const firstPage = newPages[0];
newPages[0] = {
...firstPage,
messages: [...firstPage.messages, message],
};
return { ...oldData, pages: newPages };
}

View File

@@ -0,0 +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 },
] as const;
export function formatTimedLabel(seconds: number | null): string {
const opt = TIMED_MESSAGE_OPTIONS.find((o) => o.value === seconds);
return opt?.label ?? "خاموش";
}
export function getRemainingSeconds(expiresAt?: string | null): number | null {
if (!expiresAt) return null;
const ms = new Date(expiresAt).getTime() - Date.now();
if (Number.isNaN(ms) || ms <= 0) return 0;
return Math.ceil(ms / 1000);
}
export function formatCountdown(totalSeconds: number): string {
if (totalSeconds >= 3600) {
const h = Math.floor(totalSeconds / 3600);
const m = Math.floor((totalSeconds % 3600) / 60);
return `${h}:${String(m).padStart(2, "0")}`;
}
const m = Math.floor(totalSeconds / 60);
const s = totalSeconds % 60;
return `${m}:${String(s).padStart(2, "0")}`;
}
export function buildExpiresAtIso(seconds: number | null): string | undefined {
if (!seconds) return undefined;
return new Date(Date.now() + seconds * 1000).toISOString();
}

22
src/lib/chat/viewOnce.ts Normal file
View File

@@ -0,0 +1,22 @@
export const VIEW_ONCE_MEDIA_TYPES = ["image", "video", "voice"] as const;
export type ViewOnceMediaType = (typeof VIEW_ONCE_MEDIA_TYPES)[number];
export function isViewOnceMediaType(
fileType?: string | null
): fileType is ViewOnceMediaType {
return VIEW_ONCE_MEDIA_TYPES.includes(fileType as ViewOnceMediaType);
}
export function viewOnceLabel(fileType?: string): string {
switch (fileType) {
case "image":
return "عکس یک‌بار مصرف";
case "video":
return "فیلم یک‌بار مصرف";
case "voice":
return "ویس یک‌بار مصرف";
default:
return "پیام یک‌بار مصرف";
}
}

34
src/lib/userLevel.ts Normal file
View File

@@ -0,0 +1,34 @@
export const USER_LEVELS = {
NEW: "تازه وارد",
STANDARD: "استاندارد",
PRO: "حرفه‌ای",
MASTER: "استاد",
} as const;
/** Normalize level from URL slug or query (handles ZWNJ / spacing variants). */
export function normalizeUserLevel(raw?: string | null): string {
if (!raw) return "";
const text = raw.trim().replace(/\u200c/g, "").replace(/\s+/g, " ");
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.PRO || text === "حرفه ای") return USER_LEVELS.PRO;
return raw.trim();
}
/** Extract user level token from Persian SEO slug text. */
export function parseUserLevelFromSlug(fullText: string): string {
const text = fullText.replace(/\u200c/g, " ");
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.MASTER)) return USER_LEVELS.MASTER;
if (/حرفه[\s\u200c]*ای/.test(text) || text.includes(USER_LEVELS.PRO)) {
return USER_LEVELS.PRO;
}
return "";
}

View File

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

View File

@@ -0,0 +1,20 @@
import * as yup from "yup";
import {
USERNAME_MAX_LENGTH,
USERNAME_MIN_LENGTH,
USERNAME_REGEX,
USERNAME_START_MESSAGE,
USERNAME_VALIDATION_MESSAGE,
} from "./username";
export const usernameSchema = yup
.string()
.required("نام کاربری الزامی است")
.min(USERNAME_MIN_LENGTH, `نام کاربری باید حداقل ${USERNAME_MIN_LENGTH} کاراکتر باشد`)
.max(USERNAME_MAX_LENGTH, `نام کاربری نمی‌تواند بیشتر از ${USERNAME_MAX_LENGTH} کاراکتر باشد`)
.matches(/^[a-zA-Z0-9]/, USERNAME_START_MESSAGE)
.matches(USERNAME_REGEX, USERNAME_VALIDATION_MESSAGE);
export const usernameFormSchema = yup.object({
username: usernameSchema,
});