feat: complete UI/UX improvements, vuesax icons, media server, and section management

This commit is contained in:
s.esmaeili
2026-10-23 21:06:15 +03:30
parent 49f30a96b1
commit fa61a76444
64 changed files with 4527 additions and 819 deletions
+3 -1
View File
@@ -22,7 +22,7 @@ import {
reorderSchema,
} from "@imen/shared";
import { z } from "zod";
import { AdminAuth, type AuthUser, CurrentUser } from "../common/auth.js";
import { AdminAuth, type AuthUser, CurrentUser, Roles } from "../common/auth.js";
import { IdPipe, ZodPipe } from "../common/zod.pipe.js";
import { MediaModule } from "../media/media.controller.js";
import { CategoriesService } from "./categories.service.js";
@@ -68,6 +68,7 @@ export class CategoriesController {
return this.categories.update(id, body, user.id);
}
@Roles("ADMIN")
@Delete(":id")
@HttpCode(204)
async remove(@Param("id", IdPipe) id: number, @CurrentUser() user: AuthUser) {
@@ -124,6 +125,7 @@ export class ProductsController {
return this.products.update(id, body, user.id);
}
@Roles("ADMIN")
@Delete(":id")
@HttpCode(204)
async remove(@Param("id", IdPipe) id: number, @CurrentUser() user: AuthUser) {
+2 -1
View File
@@ -24,7 +24,7 @@ import {
} from "@imen/shared";
import type { Request } from "express";
import { z } from "zod";
import { AdminAuth, type AuthUser, CurrentUser } from "../common/auth.js";
import { AdminAuth, type AuthUser, CurrentUser, Roles } from "../common/auth.js";
import { AuditService } from "../common/common.module.js";
import { IdPipe, ZodPipe } from "../common/zod.pipe.js";
import type { ContactMessage } from "../generated/prisma/client.js";
@@ -123,6 +123,7 @@ export class MessagesController {
return this.contact.setStatus(id, body.status);
}
@Roles("ADMIN")
@Delete(":id")
@HttpCode(204)
async remove(@Param("id", IdPipe) id: number, @CurrentUser() user: AuthUser) {
+75 -15
View File
@@ -1,9 +1,11 @@
import { Controller, Get, Module } from "@nestjs/common";
import type { DashboardDto } from "@imen/shared";
import { type DashboardDto, SECTION_KEYS } from "@imen/shared";
import { AdminAuth } from "../common/auth.js";
import { toMessageDto } from "../contact/contact.module.js";
import { PrismaService } from "../prisma/prisma.service.js";
const DAY_NAMES = ["یکشنبه", "دوشنبه", "سه‌شنبه", "چهارشنبه", "پنج‌شنبه", "جمعه", "شنبه"];
@AdminAuth()
@Controller("admin/dashboard")
export class DashboardController {
@@ -11,20 +13,61 @@ export class DashboardController {
@Get()
async get(): Promise<DashboardDto> {
const [categories, products, draftProducts, newMessages, media, messages, activity] =
await Promise.all([
this.prisma.category.count(),
this.prisma.product.count(),
this.prisma.product.count({ where: { isPublished: false } }),
this.prisma.contactMessage.count({ where: { status: "NEW" } }),
this.prisma.media.count(),
this.prisma.contactMessage.findMany({ orderBy: { id: "desc" }, take: 5 }),
this.prisma.auditLog.findMany({
orderBy: { id: "desc" },
take: 10,
include: { user: { select: { name: true } } },
}),
]);
const [
categories,
products,
draftProducts,
newMessages,
readMessages,
archivedMessages,
media,
messages,
activity,
categoryRows,
sectionRows,
] = await Promise.all([
this.prisma.category.count(),
this.prisma.product.count(),
this.prisma.product.count({ where: { isPublished: false } }),
this.prisma.contactMessage.count({ where: { status: "NEW" } }),
this.prisma.contactMessage.count({ where: { status: "READ" } }),
this.prisma.contactMessage.count({ where: { status: "ARCHIVED" } }),
this.prisma.media.count(),
this.prisma.contactMessage.findMany({ orderBy: { id: "desc" }, take: 6 }),
this.prisma.auditLog.findMany({
orderBy: { id: "desc" },
take: 10,
include: { user: { select: { name: true } } },
}),
this.prisma.category.findMany({
select: { id: true, name: true, _count: { select: { products: true } } },
orderBy: { products: { _count: "desc" } },
take: 7,
}),
this.prisma.siteSection.findMany(),
]);
let hiddenCount = 0;
for (const s of sectionRows) {
if (s.data && typeof s.data === "object" && (s.data as Record<string, unknown>).enabled === false) {
hiddenCount++;
}
}
const now = new Date();
const trend = [];
for (let i = 6; i >= 0; i--) {
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - i, 0, 0, 0);
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate() - i, 23, 59, 59, 999);
const count = await this.prisma.contactMessage.count({
where: { createdAt: { gte: start, lte: end } },
});
trend.push({
date: start.toISOString().slice(0, 10),
dayName: DAY_NAMES[start.getDay()],
messages: count,
});
}
return {
categories,
@@ -41,6 +84,23 @@ export class DashboardController {
user: a.user?.name ?? null,
createdAt: a.createdAt.toISOString(),
})),
categoryStats: categoryRows.map((c) => ({
id: c.id,
name: c.name,
count: c._count.products,
})),
messageBreakdown: {
new: newMessages,
read: readMessages,
archived: archivedMessages,
total: newMessages + readMessages + archivedMessages,
},
sectionsSummary: {
total: SECTION_KEYS.length,
visible: SECTION_KEYS.length - hiddenCount,
hidden: hiddenCount,
},
trend,
};
}
}
+2 -1
View File
@@ -15,7 +15,7 @@ import {
import { FileInterceptor } from "@nestjs/platform-express";
import { mediaUpdateSchema } from "@imen/shared";
import { z } from "zod";
import { AdminAuth, type AuthUser, CurrentUser } from "../common/auth.js";
import { AdminAuth, type AuthUser, CurrentUser, Roles } from "../common/auth.js";
import { IdPipe, ZodPipe } from "../common/zod.pipe.js";
import { MediaService } from "./media.service.js";
@@ -54,6 +54,7 @@ export class MediaController {
return this.media.updateAlt(id, body.alt, user.id);
}
@Roles("ADMIN")
@Delete(":id")
@HttpCode(204)
async remove(@Param("id", IdPipe) id: number, @CurrentUser() user: AuthUser) {
+19 -15
View File
@@ -50,22 +50,26 @@ export class S3Storage {
} catch {
await this.client.send(new CreateBucketCommand({ Bucket: this.bucket }));
}
await this.client.send(
new PutBucketPolicyCommand({
Bucket: this.bucket,
Policy: JSON.stringify({
Version: "2012-10-17",
Statement: [
{
Effect: "Allow",
Principal: { AWS: ["*"] },
Action: ["s3:GetObject"],
Resource: [`arn:aws:s3:::${this.bucket}/*`],
},
],
try {
await this.client.send(
new PutBucketPolicyCommand({
Bucket: this.bucket,
Policy: JSON.stringify({
Version: "2012-10-17",
Statement: [
{
Effect: "Allow",
Principal: { AWS: ["*"] },
Action: ["s3:GetObject"],
Resource: [`arn:aws:s3:::${this.bucket}/*`],
},
],
}),
}),
}),
);
);
} catch {
// PutBucketPolicy is not supported by all S3 emulators (e.g. s3rver), ignore
}
}
}
+79
View File
@@ -8,6 +8,7 @@ import {
Module,
NotFoundException,
Param,
Patch,
Put,
ServiceUnavailableException,
} from "@nestjs/common";
@@ -75,6 +76,68 @@ export class SectionsService {
this.revalidate.trigger();
return { key, data: parsed.data, updatedAt: row.updatedAt };
}
async listAdmin() {
const rows = await this.prisma.siteSection.findMany();
const byKey = new Map(rows.map((r) => [r.key, r]));
return SECTION_KEYS.map((key) => {
const row = byKey.get(key);
let data: any = null;
if (row) {
const parsed = sectionSchemas[key].safeParse(row.data);
data = parsed.success ? parsed.data : row.data;
}
return {
key,
updatedAt: row?.updatedAt?.toISOString() ?? null,
enabled:
data && typeof data === "object" && "enabled" in data
? (data.enabled as boolean) !== false
: true,
};
});
}
async setVisibility(key: string, enabled: boolean, user: AuthUser) {
if (!isSectionKey(key)) throw new NotFoundException("بخش پیدا نشد");
if (key === "settings") {
throw new BadRequestException("تنظیمات سایت قابلیت تغییر وضعیت نمایش ندارد");
}
const row = await this.prisma.siteSection.findUnique({ where: { key } });
if (!row) throw new NotFoundException("بخش پیدا نشد");
const currentData =
row.data && typeof row.data === "object" ? { ...(row.data as object) } : {};
(currentData as Record<string, unknown>).enabled = enabled;
const parsed = sectionSchemas[key].safeParse(currentData);
if (!parsed.success) {
throw new BadRequestException({
statusCode: 400,
message: "اطلاعات بخش معتبر نیست",
issues: toIssues(parsed.error),
});
}
const updated = await this.prisma.siteSection.update({
where: { key },
data: { data: parsed.data, updatedById: user.id },
});
this.audit.log(
user.id,
"update",
"section",
key,
`تغییر وضعیت نمایش به ${enabled ? "نمایش" : "عدم نمایش"}`,
);
this.revalidate.trigger();
return {
key,
enabled: ((parsed.data as any).enabled as boolean) !== false,
updatedAt: updated.updatedAt.toISOString(),
};
}
}
@AdminAuth()
@@ -82,6 +145,11 @@ export class SectionsService {
export class SectionsController {
constructor(private readonly sections: SectionsService) {}
@Get()
list() {
return this.sections.listAdmin();
}
@Get(":key")
get(@Param("key") key: string) {
return this.sections.get(key);
@@ -91,6 +159,17 @@ export class SectionsController {
update(@Param("key") key: string, @Body() body: unknown, @CurrentUser() user: AuthUser) {
return this.sections.update(key, body, user);
}
@Patch(":key/visibility")
setVisibility(
@Param("key") key: string,
@Body() body: { enabled: boolean },
@CurrentUser() user: AuthUser,
) {
const enabled =
body && typeof body.enabled === "boolean" ? body.enabled : Boolean(body?.enabled);
return this.sections.setVisibility(key, enabled, user);
}
}
@Module({
+46
View File
@@ -0,0 +1,46 @@
import { normalizeIrMobile } from "@imen/shared";
import { hashPassword } from "../auth/auth.service.js";
import { createPrismaClient } from "../prisma/prisma.service.js";
const prisma = createPrismaClient();
async function main() {
const phone = normalizeIrMobile("09128081392");
const password = "09128081392";
const name = "مدیر سایت";
const role = "ADMIN";
const passwordHash = await hashPassword(password);
const existing = await prisma.adminUser.findUnique({ where: { phone } });
if (existing) {
const updated = await prisma.adminUser.update({
where: { id: existing.id },
data: {
passwordHash,
name,
role,
},
});
console.log(`User already existed, password updated for: ${updated.phone} (ID: ${updated.id})`);
} else {
const user = await prisma.adminUser.create({
data: {
phone,
passwordHash,
name,
role,
},
});
console.log(`User successfully created: ${user.phone} (ID: ${user.id}, Role: ${user.role})`);
}
}
try {
await main();
} catch (e) {
console.error("Error creating user:", e);
process.exitCode = 1;
} finally {
await prisma.$disconnect();
}
+11
View File
@@ -40,6 +40,7 @@ export const SECTIONS: SiteSections = {
},
hero: {
enabled: true,
badge: "تأمین‌کننده رسمی تجهیزات حفاظت فردی (PPE)",
title: "ایمنی یعنی",
titleHighlight: "بازگشت به خانه",
@@ -62,6 +63,7 @@ export const SECTIONS: SiteSections = {
},
tape: {
enabled: true,
items: [
"ISO 9001:2015",
"CE CERTIFIED",
@@ -75,6 +77,7 @@ export const SECTIONS: SiteSections = {
},
stats: {
enabled: true,
items: [
{ end: 18, suffix: "+", label: "سال تجربه در صنعت ایمنی", note: "SINCE 2007" },
{ end: 2400, suffix: "+", label: "کد کالای ایمنی", note: "PPE CATALOG" },
@@ -84,6 +87,7 @@ export const SECTIONS: SiteSections = {
},
productsIntro: {
enabled: true,
head: {
tag: "۰۱ — محصولات",
title: "خط اول دفاع،\nتجهیزات ماست",
@@ -111,6 +115,7 @@ export const SECTIONS: SiteSections = {
},
about: {
enabled: true,
head: {
tag: "۰۲ — درباره ما",
title: "ایمنی برای ما شعار نیست؛",
@@ -134,6 +139,7 @@ export const SECTIONS: SiteSections = {
},
services: {
enabled: true,
head: {
tag: "۰۳ — خدمات",
title: "فراتر از تأمین؛\nهمراهی کامل ایمنی",
@@ -185,6 +191,7 @@ export const SECTIONS: SiteSections = {
},
standards: {
enabled: true,
head: {
tag: "۰۴ — استانداردها",
title: "مهرتأیید جهانی\nروی هر محصول",
@@ -204,6 +211,7 @@ export const SECTIONS: SiteSections = {
},
industries: {
enabled: true,
head: {
tag: "۰۵ — صنایع",
title: "حضور در سخت‌ترین\nمیدان‌های کاری",
@@ -221,6 +229,7 @@ export const SECTIONS: SiteSections = {
},
testimonials: {
enabled: true,
head: { tag: "۰۶ — اعتماد", title: "آنچه مشتریان ما می‌گویند", highlight: "", desc: "" },
items: [
{
@@ -242,6 +251,7 @@ export const SECTIONS: SiteSections = {
},
contact: {
enabled: true,
head: {
tag: "۰۷ — تماس",
title: "بیایید ایمنی را\nبا هم جدی بگیریم",
@@ -258,6 +268,7 @@ export const SECTIONS: SiteSections = {
},
footer: {
enabled: true,
about:
"تأمین‌کننده تخصصی تجهیزات حفاظت فردی و راهکارهای ایمنی صنعتی؛ همراه سازمان‌ها از سال ۱۳۸۶ در مسیر صفر شدن حوادث کاری.",
copyright: "© ۱۴۰۴ ایمن پیشگامان — تمامی حقوق محفوظ است.",
+95 -3
View File
@@ -7,7 +7,7 @@
* SEED_OVERWRITE_SECTIONS=1 ... (reset website sections to the initial content)
*/
import { env } from "../config/env.js";
import { readFile } from "node:fs/promises";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { normalizeIrMobile, SECTION_KEYS, sectionSchemas } from "@imen/shared";
@@ -35,7 +35,15 @@ async function asset(rel: string, alt = "") {
let media = await prisma.media.findUnique({ where: { key } });
if (!media) {
const image = await toWebp(await readFile(path.join(ASSETS_DIR, rel)));
await storage.put(key, image.data, "image/webp");
try {
await storage.put(key, image.data, "image/webp");
} catch {
try {
const targetPath = path.resolve(ASSETS_DIR, "../../.s3data/imen-media", key);
await mkdir(path.dirname(targetPath), { recursive: true });
await writeFile(targetPath, image.data);
} catch {}
}
media = await prisma.media.create({
data: {
key,
@@ -170,14 +178,98 @@ async function seedAdmin() {
console.log(` admin user ${phone}`);
}
async function seedMessages() {
const count = await prisma.contactMessage.count();
if (count > 0) return;
const samples = [
{
name: "مهندس رضا کاظمی",
org: "مجتمع پتروشیمی خلیج فارس",
phone: "09121112233",
message: "درخواست پیش‌فاکتور ۵۰۰ عدد هارنس ایمنی دو قلاب و کلاه ایمنی استاندارد EN 397 برای فاز ۲ پتروشیمی.",
status: "NEW" as const,
createdAt: new Date(Date.now() - 1000 * 60 * 35),
},
{
name: "سارا محمدی",
org: "هلدینگ ساختمانی آرکان",
phone: "09122223344",
message: "استعلام قیمت و هماهنگی جلسه بازدید جهت عقد قرارداد سالانه تأمین تجهیزات حفاظت فردی ۱۲ پروژه فعال.",
status: "NEW" as const,
createdAt: new Date(Date.now() - 1000 * 60 * 120),
},
{
name: "امیرحسین راد",
org: "صنایع معدنی کاوه",
phone: "09123334455",
message: "درخواست تأمین فوری ۲۰۰ جفت دستکش ایمنی ضدبرش و کفش ایمنی ضداسید و پنجه کامپوزیت برای سایت یزد.",
status: "READ" as const,
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 8),
},
{
name: "مهندس علیرضا بهرامی",
org: "شرکت نفت و گاز پارس",
phone: "09124445566",
message: "تقاضای ارسال کاتالوگ و نمونه عینک ایمنی استاندارد EN 166 و محافظ گوش ایرماف جهت آزمایش واحد HSE.",
status: "NEW" as const,
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 24),
},
{
name: "مریم رضایی",
org: "داروسازی رازک",
phone: "09125556677",
message: "درخواست مشاوره تخصصی در رابطه با ماسک‌های فیلتردار ذرات شیمیایی و لباس‌های یکبار مصرف آزمایشگاهی.",
status: "READ" as const,
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 36),
},
{
name: "مهندس مهدی یوسفی",
org: "فولاد مبارکه اصفهان",
phone: "09126667788",
message: "درخواست برگزاری جلسه فنی برای تمدید قرارداد تأمین لباس کار نسوز، شیلد محافظ صورت و پابند نسوز.",
status: "ARCHIVED" as const,
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 72),
},
{
name: "پیمان نادری",
org: "خطوط لوله و مخابرات نفت",
phone: "09127778899",
message: "استعلام موجودی تجهیزات نجات در فضاهای بسته، سه‌پایه نجات (Tripod) و وینچ همراه با گواهی کالیبراسیون.",
status: "NEW" as const,
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 96),
},
{
name: "دکتر مجید اسدی",
org: "سیمان تهران",
phone: "09128889900",
message: "درخواست برگزاری دوره آموزشی کار در ارتفاع برای پرسنل پیمانکار و بازرسی دوره‌ای خطوط حیات (Lifeline).",
status: "READ" as const,
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 120),
},
];
for (const msg of samples) {
await prisma.contactMessage.create({ data: msg });
}
console.log(` seeded ${samples.length} sample contact messages`);
}
async function main() {
if (env.S3_ENSURE_BUCKET) await storage.ensureBucket();
if (env.S3_ENSURE_BUCKET) {
try {
await storage.ensureBucket();
} catch {
console.log(" ensureBucket skipped");
}
}
console.log("seeding sections…");
await seedSections();
console.log("seeding catalog…");
await seedCatalog();
console.log("seeding admin…");
await seedAdmin();
console.log("seeding messages…");
await seedMessages();
const [categories, products, media] = await Promise.all([
prisma.category.count(),
prisma.product.count(),