Add Torob tags, Meshkee SEO routes, and updated static-image slots.
Product pages emit crawler-readable meta, public URLs use id plus Farsi slugs, and the homepage catalog matches the dashboard slot keys. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
f42c30a4b7
commit
c84147e765
+47
-9
@@ -87,13 +87,18 @@ export type ApiCategory = {
|
||||
name: string;
|
||||
nameFa: string | null;
|
||||
slug: string;
|
||||
description?: string | null;
|
||||
isActive?: boolean;
|
||||
};
|
||||
|
||||
export type ApiVariant = {
|
||||
id: string;
|
||||
productId?: string;
|
||||
productTitle?: string | null;
|
||||
productNameFa?: string | null;
|
||||
productImage?: string | null;
|
||||
label?: string | null;
|
||||
price: number;
|
||||
price: number | null;
|
||||
discountedPrice?: number | null;
|
||||
stockQuantity?: number | null;
|
||||
sortOrder?: number;
|
||||
@@ -152,6 +157,18 @@ export async function getProduct(slug: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProductById(productId: string) {
|
||||
try {
|
||||
const data = await apiGet<{ product: ApiProduct }>(
|
||||
`${tenant()}/products/by-id/${encodeURIComponent(productId)}`,
|
||||
);
|
||||
return data.product ?? null;
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 404) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getBlogs(query: { page?: number; pageSize?: number; title?: string } = {}) {
|
||||
return apiGet<Paginated<ApiBlog>>(`${tenant()}/blogs`, query);
|
||||
}
|
||||
@@ -166,6 +183,16 @@ export async function getBlog(slug: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getBlogById(blogId: string) {
|
||||
try {
|
||||
const data = await apiGet<{ blog: ApiBlog }>(`${tenant()}/blogs/by-id/${encodeURIComponent(blogId)}`);
|
||||
return data.blog ?? null;
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 404) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getStoreSpecials() {
|
||||
const data = await apiGet<{ items: ApiStoreSpecial[] }>(`${tenant()}/store-specials`);
|
||||
return data.items ?? [];
|
||||
@@ -173,8 +200,10 @@ export async function getStoreSpecials() {
|
||||
|
||||
export async function getStoreItemByProduct(productId: string) {
|
||||
try {
|
||||
const data = await apiGet<{ storeItem: ApiStoreItem }>(`${tenant()}/store-items/by-product/${productId}`);
|
||||
return data.storeItem;
|
||||
const data = await apiGet<{ storeItem: ApiStoreItem | null }>(
|
||||
`${tenant()}/store-items/by-product/${productId}`,
|
||||
);
|
||||
return data.storeItem ?? null;
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 404) return null;
|
||||
throw error;
|
||||
@@ -195,6 +224,21 @@ export async function getCategoryBySlug(slug: string) {
|
||||
return categories.find((category) => category.slug === slug) ?? null;
|
||||
}
|
||||
|
||||
export async function getCategoryById(categoryId: string) {
|
||||
try {
|
||||
const data = await apiGet<{ category: ApiCategory }>(
|
||||
`${tenant()}/categories/by-id/${encodeURIComponent(categoryId)}`,
|
||||
);
|
||||
return data.category ?? null;
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 404) {
|
||||
const categories = await getCategories();
|
||||
return categories.find((category) => category.id === categoryId) ?? null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export type ApiTenant = {
|
||||
id: string | number;
|
||||
name: string;
|
||||
@@ -219,9 +263,3 @@ export async function getComments(entityType: "product" | "blog", entityId: stri
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveProductSlug(productId: string, name: string) {
|
||||
if (!name) return "";
|
||||
const { items } = await getProducts({ name, pageSize: 20 });
|
||||
return items.find((item) => item.id === productId)?.slug ?? "";
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ export type Product = {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
href?: string;
|
||||
price: number;
|
||||
oldPrice?: number;
|
||||
stock?: number | null;
|
||||
@@ -14,8 +15,10 @@ export type Product = {
|
||||
};
|
||||
|
||||
export type Post = {
|
||||
id?: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
href?: string;
|
||||
date: string;
|
||||
image: ImageSrc;
|
||||
abstract?: string;
|
||||
|
||||
+17
-17
@@ -3,13 +3,13 @@ import {
|
||||
getBlogs,
|
||||
getProducts,
|
||||
getStoreSpecials,
|
||||
resolveProductSlug,
|
||||
type ApiBlog,
|
||||
type ApiProduct,
|
||||
type ApiSpecialItem,
|
||||
type ApiVariant,
|
||||
} from "./api";
|
||||
import { formatDate } from "./format";
|
||||
import { blogHref, productHref } from "./paths";
|
||||
|
||||
function productImage(url?: string | null) {
|
||||
return url || PLACEHOLDER.product;
|
||||
@@ -23,35 +23,39 @@ function pickVariantPrice(variants: ApiVariant[]) {
|
||||
const inStock = variants.filter((variant) => (variant.stockQuantity ?? 0) > 0);
|
||||
const pool = inStock.length ? inStock : variants;
|
||||
const best = pool.reduce((current, next) => {
|
||||
const currentPrice = current.discountedPrice ?? current.price;
|
||||
const nextPrice = next.discountedPrice ?? next.price;
|
||||
const currentPrice = current.discountedPrice ?? current.price ?? 0;
|
||||
const nextPrice = next.discountedPrice ?? next.price ?? 0;
|
||||
return nextPrice < currentPrice ? next : current;
|
||||
});
|
||||
|
||||
const price = best.discountedPrice ?? best.price ?? 0;
|
||||
const oldPrice = best.discountedPrice ? best.price : undefined;
|
||||
const oldPrice = best.discountedPrice ? (best.price ?? undefined) : undefined;
|
||||
const stock = variants.reduce((sum, variant) => sum + (variant.stockQuantity ?? 0), 0);
|
||||
|
||||
return { price, oldPrice, stock };
|
||||
}
|
||||
|
||||
export function mapProduct(item: ApiProduct): Product {
|
||||
const name = item.nameFa || item.title;
|
||||
return {
|
||||
id: item.id,
|
||||
name: item.nameFa || item.title,
|
||||
name,
|
||||
slug: item.slug,
|
||||
href: productHref(item),
|
||||
price: item.store?.minPrice ?? 0,
|
||||
stock: item.store?.inStock ? item.store.variantCount || 1 : 0,
|
||||
image: productImage(item.thumbnailUrl || item.image || item.thumbnail),
|
||||
};
|
||||
}
|
||||
|
||||
export function mapSpecialItem(item: ApiSpecialItem, slug: string): Product {
|
||||
export function mapSpecialItem(item: ApiSpecialItem): Product {
|
||||
const { price, oldPrice, stock } = pickVariantPrice(item.variants ?? []);
|
||||
const name = item.productNameFa || item.productTitle;
|
||||
return {
|
||||
id: item.productId,
|
||||
name: item.productNameFa || item.productTitle,
|
||||
slug,
|
||||
name,
|
||||
slug: "",
|
||||
href: productHref(item.productId, name),
|
||||
price,
|
||||
oldPrice,
|
||||
stock,
|
||||
@@ -61,8 +65,10 @@ export function mapSpecialItem(item: ApiSpecialItem, slug: string): Product {
|
||||
|
||||
export function mapBlog(item: ApiBlog): Post {
|
||||
return {
|
||||
id: item.id,
|
||||
slug: item.slug,
|
||||
title: item.title,
|
||||
href: blogHref(item),
|
||||
date: formatDate(item.publishedAt || item.createdAt || ""),
|
||||
image: item.titleImageUrl || PLACEHOLDER.blog,
|
||||
abstract: item.abstract?.trim() || undefined,
|
||||
@@ -75,16 +81,10 @@ export async function getSpecialProducts(key: string): Promise<Product[]> {
|
||||
const special = specials.find((item) => item.isActive && item.key.toLowerCase() === key.toLowerCase());
|
||||
if (!special) return [];
|
||||
|
||||
const items = special.items
|
||||
return special.items
|
||||
.filter((item) => item.isActive)
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
|
||||
return Promise.all(
|
||||
items.map(async (item) => {
|
||||
const slug = await resolveProductSlug(item.productId, item.productTitle || item.productNameFa || "");
|
||||
return mapSpecialItem(item, slug);
|
||||
}),
|
||||
);
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
.map((item) => mapSpecialItem(item));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import { getCategories, type ApiCategory } from "./api";
|
||||
import { parentCategories } from "./data";
|
||||
import { categoryHref } from "./paths";
|
||||
|
||||
export type MenuCategory = {
|
||||
name: string;
|
||||
href: string;
|
||||
children: { name: string; href: string }[];
|
||||
};
|
||||
|
||||
function hrefForSlug(categories: ApiCategory[], slug: string) {
|
||||
const match = categories.find((category) => category.slug === slug);
|
||||
return match ? categoryHref(match) : `/products/${slug}`;
|
||||
}
|
||||
|
||||
export async function getMenuCategories(): Promise<MenuCategory[]> {
|
||||
const categories = await getCategories();
|
||||
return parentCategories.map((parent) => ({
|
||||
name: parent.name,
|
||||
href: hrefForSlug(categories, parent.slug),
|
||||
children: parent.children.map((child) => ({
|
||||
name: child.name,
|
||||
href: hrefForSlug(categories, child.slug),
|
||||
})),
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { ApiBlog, ApiCategory, ApiProduct } from "./api";
|
||||
import { detailPath, slugifyForUrl } from "./seo-slug";
|
||||
|
||||
export function productTitle(product: Pick<ApiProduct, "id" | "nameFa" | "title">) {
|
||||
return product.nameFa?.trim() || product.title?.trim() || String(product.id);
|
||||
}
|
||||
|
||||
export function categoryTitle(category: Pick<ApiCategory, "id" | "nameFa" | "name">) {
|
||||
return category.nameFa?.trim() || category.name?.trim() || String(category.id);
|
||||
}
|
||||
|
||||
/** Public product URL: /products/{id}/{nameFaSlug} — never use legacy DB slug. */
|
||||
export function productHref(product: Pick<ApiProduct, "id" | "nameFa" | "title"> | string, title?: string | null) {
|
||||
if (typeof product === "string") {
|
||||
return detailPath("/products", product, title?.trim() || product);
|
||||
}
|
||||
return detailPath("/products", product.id, productTitle(product));
|
||||
}
|
||||
|
||||
/** Public category URL: /products/category/{id}/{nameFaSlug} */
|
||||
export function categoryHref(category: Pick<ApiCategory, "id" | "nameFa" | "name">) {
|
||||
return detailPath("/products/category", category.id, categoryTitle(category));
|
||||
}
|
||||
|
||||
/** Public blog URL: /blog/{id}/{titleSlug} */
|
||||
export function blogHref(blog: Pick<ApiBlog, "id" | "title">) {
|
||||
return detailPath("/blog", blog.id, blog.title);
|
||||
}
|
||||
|
||||
export function productNameSlug(title: string) {
|
||||
return slugifyForUrl(title);
|
||||
}
|
||||
|
||||
export function categoryNameSlug(title: string) {
|
||||
return slugifyForUrl(title);
|
||||
}
|
||||
|
||||
export function blogTitleSlug(title: string) {
|
||||
return slugifyForUrl(title);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Build a URL path segment from a title (Persian / Arabic / Latin).
|
||||
* Keeps letters and digits; spaces → hyphens; strips other punctuation.
|
||||
*/
|
||||
export function slugifyForUrl(value: string, fallback = "item"): string {
|
||||
const slug = value
|
||||
.trim()
|
||||
.normalize("NFC")
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/[^\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFFa-zA-Z0-9-]+/g, "")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
|
||||
return slug || fallback;
|
||||
}
|
||||
|
||||
export function detailPath(base: string, id: string | number, title: string) {
|
||||
const slug = slugifyForUrl(title);
|
||||
return `${base}/${encodeURIComponent(String(id))}/${encodeURIComponent(slug)}`;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"baseUrl": "https://salarstore.com",
|
||||
"staticPages": [
|
||||
{
|
||||
"path": "/",
|
||||
"changefreq": "daily",
|
||||
"priority": 1
|
||||
},
|
||||
{
|
||||
"path": "/aboutus",
|
||||
"priority": 0.6
|
||||
},
|
||||
{
|
||||
"path": "/blog",
|
||||
"changefreq": "weekly",
|
||||
"priority": 0.8
|
||||
},
|
||||
{
|
||||
"path": "/contactus",
|
||||
"priority": 0.5
|
||||
},
|
||||
{
|
||||
"path": "/products",
|
||||
"changefreq": "daily",
|
||||
"priority": 0.9
|
||||
}
|
||||
]
|
||||
}
|
||||
+53
-26
@@ -44,13 +44,21 @@ export type StaticImageCatalogSlot = {
|
||||
*/
|
||||
export const STATIC_IMAGE_KEYS = {
|
||||
slider: "slider",
|
||||
bannersBelowSlider: "banners-below-slider",
|
||||
twoBannerBelowSlider: "two-banner-below-slider",
|
||||
categories: "categories",
|
||||
fourBannerSection: "four-banner-section",
|
||||
threeBannerRow: "three-banner-row",
|
||||
fourBanner: "four-banner",
|
||||
twoBannerRow: "two-banner-row",
|
||||
brands: "brands",
|
||||
aboutUsImage: "about-us-image",
|
||||
} as const;
|
||||
|
||||
/** Previous catalog keys still returned by the tenant API until dashboard Refresh. */
|
||||
const SLOT_KEY_ALIASES: Record<string, string[]> = {
|
||||
"two-banner-below-slider": ["banners-below-slider"],
|
||||
"four-banner": ["four-banner-section"],
|
||||
"two-banner-row": ["three-banner-row"],
|
||||
};
|
||||
|
||||
export const STATIC_IMAGE_SLOT_CATALOG: StaticImageCatalogSlot[] = [
|
||||
{
|
||||
key: STATIC_IMAGE_KEYS.slider,
|
||||
@@ -62,8 +70,8 @@ export const STATIC_IMAGE_SLOT_CATALOG: StaticImageCatalogSlot[] = [
|
||||
recommendedWidth: 1200,
|
||||
},
|
||||
{
|
||||
key: STATIC_IMAGE_KEYS.bannersBelowSlider,
|
||||
label: "Banners below slider",
|
||||
key: STATIC_IMAGE_KEYS.twoBannerBelowSlider,
|
||||
label: "Two banners below slider",
|
||||
kind: "list",
|
||||
pageKey: "home",
|
||||
aspectRatio: "13:5",
|
||||
@@ -80,7 +88,7 @@ export const STATIC_IMAGE_SLOT_CATALOG: StaticImageCatalogSlot[] = [
|
||||
recommendedWidth: 160,
|
||||
},
|
||||
{
|
||||
key: STATIC_IMAGE_KEYS.fourBannerSection,
|
||||
key: STATIC_IMAGE_KEYS.fourBanner,
|
||||
label: "Four banner section",
|
||||
kind: "list",
|
||||
pageKey: "home",
|
||||
@@ -89,21 +97,30 @@ export const STATIC_IMAGE_SLOT_CATALOG: StaticImageCatalogSlot[] = [
|
||||
recommendedWidth: 600,
|
||||
},
|
||||
{
|
||||
key: STATIC_IMAGE_KEYS.threeBannerRow,
|
||||
label: "Three banner row",
|
||||
key: STATIC_IMAGE_KEYS.twoBannerRow,
|
||||
label: "Two banner row",
|
||||
kind: "list",
|
||||
pageKey: "home",
|
||||
aspectRatio: "12:5",
|
||||
itemCount: 3,
|
||||
recommendedWidth: 480,
|
||||
itemCount: 2,
|
||||
recommendedWidth: 600,
|
||||
},
|
||||
{
|
||||
key: STATIC_IMAGE_KEYS.brands,
|
||||
label: "Brand logos",
|
||||
kind: "list",
|
||||
pageKey: "home",
|
||||
aspectRatio: "2:1",
|
||||
itemCount: null,
|
||||
recommendedWidth: 196,
|
||||
},
|
||||
{
|
||||
key: STATIC_IMAGE_KEYS.aboutUsImage,
|
||||
label: "About us image",
|
||||
kind: "single",
|
||||
label: "About us images",
|
||||
kind: "list",
|
||||
pageKey: "home",
|
||||
aspectRatio: "8:5",
|
||||
itemCount: 1,
|
||||
itemCount: 2,
|
||||
recommendedWidth: 720,
|
||||
},
|
||||
];
|
||||
@@ -121,8 +138,14 @@ export function slotAlt(image: StaticImage, fallback = ""): string {
|
||||
return image.titleFa?.trim() || image.titleEn?.trim() || fallback;
|
||||
}
|
||||
|
||||
export function slotByKey(slots: StaticImageSlot[], key: string): StaticImageSlot | undefined {
|
||||
const aliases = SLOT_KEY_ALIASES[key] ?? [];
|
||||
return slots.find((slot) => slot.key === key) ?? slots.find((slot) => aliases.includes(slot.key));
|
||||
}
|
||||
|
||||
export function imagesForSlot(slots: StaticImageSlot[], key: string): StaticImageWithUrl[] {
|
||||
const slot = slots.find((item) => item.key === key);
|
||||
const slot = slotByKey(slots, key);
|
||||
const catalog = STATIC_IMAGE_SLOT_CATALOG.find((item) => item.key === key);
|
||||
const raw = slot?.images ?? [];
|
||||
const sorted = [...raw].sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
|
||||
const result: StaticImageWithUrl[] = [];
|
||||
@@ -131,7 +154,11 @@ export function imagesForSlot(slots: StaticImageSlot[], key: string): StaticImag
|
||||
if (!url) continue;
|
||||
result.push({ ...image, url });
|
||||
}
|
||||
if (slot?.kind === "single") return result.slice(0, 1);
|
||||
|
||||
const kind = catalog?.kind ?? slot?.kind ?? "list";
|
||||
const limit = catalog?.itemCount ?? (kind === "single" ? 1 : null);
|
||||
if (kind === "single" || limit === 1) return result.slice(0, 1);
|
||||
if (limit && limit > 0) return result.slice(0, limit);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -142,10 +169,6 @@ export function firstImageForSlot(
|
||||
return imagesForSlot(slots, key)[0];
|
||||
}
|
||||
|
||||
export function slotByKey(slots: StaticImageSlot[], key: string): StaticImageSlot | undefined {
|
||||
return slots.find((slot) => slot.key === key);
|
||||
}
|
||||
|
||||
export async function getStaticImageSlots(pageKey?: string): Promise<StaticImageSlot[]> {
|
||||
try {
|
||||
const url = new URL(`${API.base}/tenants/${API.domain}/website/static-images`);
|
||||
@@ -160,12 +183,16 @@ export async function getStaticImageSlots(pageKey?: string): Promise<StaticImage
|
||||
}
|
||||
|
||||
export async function getStaticImageSlot(key: string): Promise<StaticImageSlot | null> {
|
||||
try {
|
||||
const url = new URL(`${API.base}/tenants/${API.domain}/website/static-images/${key}`);
|
||||
const response = await fetch(url, { next: { revalidate: 120 } });
|
||||
if (!response.ok) return null;
|
||||
return (await response.json()) as StaticImageSlot;
|
||||
} catch {
|
||||
return null;
|
||||
const keys = [key, ...(SLOT_KEY_ALIASES[key] ?? [])];
|
||||
for (const candidate of keys) {
|
||||
try {
|
||||
const url = new URL(`${API.base}/tenants/${API.domain}/website/static-images/${candidate}`);
|
||||
const response = await fetch(url, { next: { revalidate: 120 } });
|
||||
if (!response.ok) continue;
|
||||
return (await response.json()) as StaticImageSlot;
|
||||
} catch {
|
||||
// try next alias
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { ApiProduct, ApiStoreItem, ApiVariant } from "./api";
|
||||
|
||||
function sellingPrice(variant: ApiVariant): number {
|
||||
const value = variant.discountedPrice ?? variant.price;
|
||||
return typeof value === "number" && Number.isFinite(value) ? Math.trunc(value) : 0;
|
||||
}
|
||||
|
||||
function inStock(variant: ApiVariant): boolean {
|
||||
return variant.stockQuantity === null || (typeof variant.stockQuantity === "number" && variant.stockQuantity > 0);
|
||||
}
|
||||
|
||||
export function pickTorobVariant(variants: ApiVariant[]): ApiVariant | undefined {
|
||||
if (!variants.length) return undefined;
|
||||
const available = variants.filter(inStock);
|
||||
const pool = available.length ? available : variants;
|
||||
return pool.reduce((cheapest, variant) =>
|
||||
sellingPrice(variant) < sellingPrice(cheapest) ? variant : cheapest,
|
||||
);
|
||||
}
|
||||
|
||||
function toHttpsUrl(url?: string | null): string | undefined {
|
||||
if (!url) return undefined;
|
||||
const trimmed = url.trim();
|
||||
if (trimmed.startsWith("https://")) return trimmed;
|
||||
if (trimmed.startsWith("http://")) return `https://${trimmed.slice("http://".length)}`;
|
||||
if (trimmed.startsWith("//")) return `https:${trimmed}`;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function torobMetadata(product: ApiProduct, storeItem: ApiStoreItem | null): Pick<Metadata, "other" | "openGraph"> {
|
||||
const variant = storeItem ? pickTorobVariant(storeItem.variants ?? []) : undefined;
|
||||
const price = storeItem && variant ? sellingPrice(variant) : 0;
|
||||
const available = Boolean(storeItem && variant && inStock(variant));
|
||||
const name = variant?.productNameFa || variant?.productTitle || product.nameFa || product.title;
|
||||
const image =
|
||||
toHttpsUrl(variant?.productImage) ||
|
||||
toHttpsUrl(product.thumbnailUrl) ||
|
||||
toHttpsUrl(product.image) ||
|
||||
toHttpsUrl(product.thumbnail) ||
|
||||
toHttpsUrl(product.images?.[0]?.url);
|
||||
|
||||
const other: Record<string, string> = {
|
||||
product_id: product.id,
|
||||
product_name: name,
|
||||
product_price: String(price),
|
||||
availability: available ? "instock" : "outofstock",
|
||||
};
|
||||
|
||||
if (variant && variant.discountedPrice != null) {
|
||||
other.product_old_price = String(typeof variant.price === "number" ? Math.trunc(variant.price) : 0);
|
||||
}
|
||||
|
||||
return {
|
||||
other,
|
||||
...(image ? { openGraph: { images: [{ url: image }] } } : {}),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user