shop full

This commit is contained in:
payacom
2026-08-04 21:12:03 +03:30
parent dc4467d43d
commit 89320d57b9
89 changed files with 5761 additions and 2386 deletions

View File

@@ -51,11 +51,8 @@ export function buildProfileSeo(
user.bio ||
translateCommon(lang, "profileSeo.defaultBio", { siteName });
const cityPart = city
? translateCommon(lang, "profileSeo.inCity", { city })
: "";
const titleCore = [fullName || username, expertise, cityPart]
const nameSegments = fullName ? [fullName, username] : [username];
const titleCore = [...nameSegments, expertise, city]
.filter(Boolean)
.join(" – ");
@@ -69,7 +66,7 @@ export function buildProfileSeo(
: "";
return {
title: formatLocalizedSeoTitle(titleCore, lang),
title: `${titleCore} | ${siteName}`,
description,
path,
h1: fullName

View File

@@ -13,11 +13,13 @@ export function buildFullPostCaption(
packages: SelectedPostLink[],
projects: SelectedPostLink[],
billboards: SelectedPostLink[],
lang?: AppLanguage
lang?: AppLanguage,
shops: SelectedPostLink[] = [],
shopProducts: SelectedPostLink[] = []
): string {
const tagLine = taggedUsers.map((u) => `@${u.user_name}`).join(" ");
const linkHeader = buildPostLinkCaptionHeader(
[...packages, ...projects, ...billboards],
[...packages, ...projects, ...billboards, ...shops, ...shopProducts],
lang
);
return [description.trim(), tagLine, linkHeader].filter(Boolean).join("\n");
@@ -29,7 +31,9 @@ export function countPostCaptionLength(
packages: SelectedPostLink[],
projects: SelectedPostLink[],
billboards: SelectedPostLink[],
lang?: AppLanguage
lang?: AppLanguage,
shops: SelectedPostLink[] = [],
shopProducts: SelectedPostLink[] = []
): number {
return buildFullPostCaption(
description,
@@ -37,6 +41,8 @@ export function countPostCaptionLength(
packages,
projects,
billboards,
lang
lang,
shops,
shopProducts
).length;
}

View File

@@ -55,14 +55,19 @@ export async function getDmConversationKey(
const cached = dmKeyCache.get(ck);
if (cached) return cached;
const { privateKey } = await ensureIdentityKeys(me);
const peerPubB64 = await fetchPeerPublicKey(peer);
if (!peerPubB64) return null;
try {
const { privateKey } = await ensureIdentityKeys(me);
const peerPubB64 = await fetchPeerPublicKey(peer);
if (!peerPubB64) return null;
const peerPub = await importPublicKeySpkiB64(peerPubB64);
const key = await deriveDmAesKey(privateKey, peerPub, me, peer);
dmKeyCache.set(ck, key);
return key;
const peerPub = await importPublicKeySpkiB64(peerPubB64);
const key = await deriveDmAesKey(privateKey, peerPub, me, peer);
dmKeyCache.set(ck, key);
return key;
} catch {
// شبکه/IndexedDB می‌تواند گذرا خطا بدهد — نباید کل صفحه رمزگشایی را خراب کند
return null;
}
}
export function clearDmKeyCache(): void {
@@ -95,15 +100,20 @@ export async function decryptDmContent(
const remembered = recallOutboundPlaintext(raw);
if (remembered) return remembered;
const key = await getDmConversationKey(myUserId, peerUserId);
if (!key) return E2EE_LOCKED_PREVIEW;
try {
const key = await getDmConversationKey(myUserId, peerUserId);
if (!key) return E2EE_LOCKED_PREVIEW;
const pt = await decryptText(key, raw);
if (pt != null) {
rememberOutboundPlaintext(raw, pt);
return pt;
const pt = await decryptText(key, raw);
if (pt != null) {
rememberOutboundPlaintext(raw, pt);
return pt;
}
return E2EE_LOCKED_PREVIEW;
} catch {
// خطای گذرا در رمزگشایی یک پیام نباید بقیه پیام‌های صفحه را قفل نشان دهد
return E2EE_LOCKED_PREVIEW;
}
return E2EE_LOCKED_PREVIEW;
}
export async function decryptDmMessageList<
@@ -120,26 +130,38 @@ export async function decryptDmMessageList<
): Promise<T[]> {
const out: T[] = [];
for (const msg of messages) {
// خطای رمزگشایی یک پیام نباید کل صفحه را از حالت رمزگشایی‌شده خارج کند —
// هر پیام مستقل پردازش می‌شود و در بدترین حالت فقط همان یکی قفل نمایش داده می‌شود
let next = msg;
const content = normalizeMessageContent(msg.content);
if (content && isE2eeEnvelope(content)) {
const decrypted = await decryptDmContent(myUserId, peerUserId, content);
next = { ...next, content: decrypted };
} else if (typeof msg.content !== "string" && content) {
next = { ...next, content };
try {
const content = normalizeMessageContent(msg.content);
if (content && isE2eeEnvelope(content)) {
const decrypted = await decryptDmContent(myUserId, peerUserId, content);
next = { ...next, content: decrypted };
} else if (typeof msg.content !== "string" && content) {
next = { ...next, content };
}
} catch {
next = { ...next, content: E2EE_LOCKED_PREVIEW };
}
if (next.replyTo?.content) {
const replyRaw = normalizeMessageContent(next.replyTo.content);
if (isE2eeEnvelope(replyRaw)) {
const replyContent = await decryptDmContent(
myUserId,
peerUserId,
replyRaw
);
next = {
...next,
replyTo: { ...next.replyTo, content: replyContent },
};
try {
if (next.replyTo?.content) {
const replyRaw = normalizeMessageContent(next.replyTo.content);
if (isE2eeEnvelope(replyRaw)) {
const replyContent = await decryptDmContent(
myUserId,
peerUserId,
replyRaw
);
next = {
...next,
replyTo: { ...next.replyTo, content: replyContent },
};
}
}
} catch {
if (next.replyTo) {
next = { ...next, replyTo: { ...next.replyTo, content: E2EE_LOCKED_PREVIEW } };
}
}
out.push(next);

View File

@@ -144,7 +144,10 @@ export function buildLocalizedMetadataFields(
const finalDescription = options.description || site.defaultDescription;
return {
title: finalTitle,
// {absolute} skips the root layout's "%s | siteName" template — finalTitle
// already carries the site-name suffix, so applying the template again
// would duplicate it.
title: { absolute: finalTitle },
description: finalDescription,
keywords: options.keywords ?? site.keywords,
alternates: buildHreflangAlternates(options.path),

View File

@@ -1,19 +0,0 @@
const POST_EDIT_WINDOW_MS = 24 * 60 * 60 * 1000;
export function canEditPostWithinWindow(
createdAt?: string | Date | null
): boolean {
if (!createdAt) return false;
const ts = new Date(createdAt).getTime();
if (Number.isNaN(ts)) return false;
return Date.now() - ts < POST_EDIT_WINDOW_MS;
}
export function postEditRemainingMs(
createdAt?: string | Date | null
): number {
if (!createdAt) return 0;
const ts = new Date(createdAt).getTime();
if (Number.isNaN(ts)) return 0;
return Math.max(0, POST_EDIT_WINDOW_MS - (Date.now() - ts));
}

View File

@@ -1,10 +1,10 @@
import { DEFAULT_LANGUAGE, type AppLanguage } from "@/lib/i18n/registry";
export type PostLinkKind = "pkg" | "project" | "billboard";
export type PostLinkKind = "pkg" | "project" | "billboard" | "shop" | "shopproduct";
/** مارکر لینک داخل کپشن */
export const POST_LINK_TOKEN_REGEX =
/\[\[(pkg|project|billboard):([a-f0-9]{24})\]\]([\s\S]*?)\[\[\/\1\]\]/gi;
/\[\[(pkg|project|billboard|shop|shopproduct):([a-f0-9]{24})\]\]([\s\S]*?)\[\[\/\1\]\]/gi;
/** @deprecated use POST_LINK_TOKEN_REGEX */
export const POST_PACKAGE_TOKEN_REGEX =
@@ -19,12 +19,16 @@ const FA_LINK_LABELS = {
pkg: "پکیج آموزشی",
project: "پروژه",
billboard: "بیلبورد",
shop: "فروشگاه",
shopproduct: "کالا",
} as const;
const EN_LINK_LABELS = {
pkg: "Training package",
project: "Project",
billboard: "Billboard",
shop: "Shop",
shopproduct: "Product",
} as const;
export function getPostLinkLabels(lang: AppLanguage = DEFAULT_LANGUAGE) {
@@ -53,6 +57,10 @@ export function buildPostLinkPath(
return `/projects/${id}/${name}`;
case "billboard":
return `/billboards/${id}/${name}`;
case "shop":
return `/shops/profile/${id}`;
case "shopproduct":
return `/shops/listing/${id}`;
}
}
@@ -77,13 +85,15 @@ export function buildPostLinkCaptionHeader(
pkg: [],
project: [],
billboard: [],
shop: [],
shopproduct: [],
};
for (const link of list) {
groups[link.kind].push(link);
}
const parts: string[] = [];
(["pkg", "project", "billboard"] as const).forEach((kind) => {
(["pkg", "project", "billboard", "shop", "shopproduct"] as const).forEach((kind) => {
if (!groups[kind].length) return;
const tokens = groups[kind]
.map((l) => buildPostLinkCaptionToken(l.kind, l._id, l.title))