Initial commit
This commit is contained in:
5
src/lib/auth/feedback.ts
Normal file
5
src/lib/auth/feedback.ts
Normal 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
23
src/lib/auth/otpTimer.ts
Normal 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
75
src/lib/auth/session.ts
Normal 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());
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}`,
|
||||
});
|
||||
}
|
||||
|
||||
80
src/lib/chat/messageGrouping.ts
Normal file
80
src/lib/chat/messageGrouping.ts
Normal 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";
|
||||
}
|
||||
3
src/lib/chat/queryKeys.ts
Normal file
3
src/lib/chat/queryKeys.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function chatThreadQueryKey(senderId: string, receiverId: string) {
|
||||
return ["chatThread", senderId, receiverId] as const;
|
||||
}
|
||||
36
src/lib/chat/threadCache.ts
Normal file
36
src/lib/chat/threadCache.ts
Normal 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 };
|
||||
}
|
||||
36
src/lib/chat/timedMessages.ts
Normal file
36
src/lib/chat/timedMessages.ts
Normal 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
22
src/lib/chat/viewOnce.ts
Normal 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
34
src/lib/userLevel.ts
Normal 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 "";
|
||||
}
|
||||
21
src/lib/validation/username.ts
Normal file
21
src/lib/validation/username.ts
Normal 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);
|
||||
}
|
||||
20
src/lib/validation/usernameSchema.ts
Normal file
20
src/lib/validation/usernameSchema.ts
Normal 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,
|
||||
});
|
||||
Reference in New Issue
Block a user