feat: monorepo with NestJS API, admin panel and DB-driven website
Restructure the repo into a pnpm + Turborepo monorepo: - apps/web: Nuxt 4 site, now server-rendered and reading all content from the API; pages cached with SWR and purged on content changes - apps/api: NestJS 12 + Prisma 7 + PostgreSQL. Auth with short-lived access tokens and rotating httpOnly refresh cookies, roles (ADMIN, EDITOR), catalog, editable site sections, media upload to S3-compatible storage (converted to WebP), contact messages, audit log - apps/admin: Vue 3 admin panel (Ant Design Vue, Pinia, Tailwind) for products, categories, site sections, messages, media and users - packages/shared: Zod schemas and types shared by API and admin Also: - seed script that loads the original 8 categories / 89 products and section content - Docker images for api (with a one-off migrate service), web, admin, and an nginx gateway for both domains - dev and production compose files, daily database backup service - API e2e tests Removes the committed static out/ build and the old root Dockerfile. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
31076174e2
commit
789d224ea0
@@ -0,0 +1,48 @@
|
||||
import { Controller, Get, Module } from "@nestjs/common";
|
||||
import { APP_FILTER, APP_GUARD } from "@nestjs/core";
|
||||
import { ThrottlerGuard, ThrottlerModule } from "@nestjs/throttler";
|
||||
import { AuthModule } from "./auth/auth.controller.js";
|
||||
import { CatalogModule } from "./catalog/catalog.module.js";
|
||||
import { CommonModule } from "./common/common.module.js";
|
||||
import { PrismaExceptionFilter } from "./common/prisma-exception.filter.js";
|
||||
import { ContactModule } from "./contact/contact.module.js";
|
||||
import { DashboardModule } from "./dashboard/dashboard.module.js";
|
||||
import { MediaModule } from "./media/media.controller.js";
|
||||
import { PrismaModule, PrismaService } from "./prisma/prisma.service.js";
|
||||
import { PublicModule } from "./public/public.module.js";
|
||||
import { SectionsModule } from "./sections/sections.module.js";
|
||||
import { UsersModule } from "./users/users.controller.js";
|
||||
|
||||
@Controller("health")
|
||||
class HealthController {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
@Get()
|
||||
async health() {
|
||||
await this.prisma.$queryRaw`SELECT 1`;
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
// generous default; sensitive routes (login, contact form) set stricter limits
|
||||
ThrottlerModule.forRoot([{ ttl: 60_000, limit: 300 }]),
|
||||
PrismaModule,
|
||||
CommonModule,
|
||||
AuthModule,
|
||||
UsersModule,
|
||||
MediaModule,
|
||||
CatalogModule,
|
||||
SectionsModule,
|
||||
ContactModule,
|
||||
PublicModule,
|
||||
DashboardModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
providers: [
|
||||
{ provide: APP_GUARD, useClass: ThrottlerGuard },
|
||||
{ provide: APP_FILTER, useClass: PrismaExceptionFilter },
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { Body, Controller, Get, HttpCode, Module, Post, Req, Res } from "@nestjs/common";
|
||||
import { JwtModule, type JwtSignOptions } from "@nestjs/jwt";
|
||||
import { Throttle } from "@nestjs/throttler";
|
||||
import {
|
||||
type AuthResponse,
|
||||
type ChangePasswordInput,
|
||||
changePasswordSchema,
|
||||
type LoginInput,
|
||||
loginSchema,
|
||||
} from "@imen/shared";
|
||||
import type { Request, Response } from "express";
|
||||
import { env, isProd } from "../config/env.js";
|
||||
import { AdminAuth, type AuthUser, CurrentUser } from "../common/auth.js";
|
||||
import { ZodPipe } from "../common/zod.pipe.js";
|
||||
import { AuthService, type IssuedTokens } from "./auth.service.js";
|
||||
|
||||
const COOKIE = "imen_rt";
|
||||
const COOKIE_PATH = "/api/admin/auth";
|
||||
|
||||
const ua = (req: Request) => req.headers["user-agent"] ?? "";
|
||||
const readCookie = (req: Request): string | undefined =>
|
||||
(req.cookies as Record<string, string> | undefined)?.[COOKIE];
|
||||
|
||||
@Controller("admin/auth")
|
||||
export class AuthController {
|
||||
constructor(private readonly auth: AuthService) {}
|
||||
|
||||
@Post("login")
|
||||
@HttpCode(200)
|
||||
@Throttle({ default: { limit: 8, ttl: 60_000 } })
|
||||
async login(
|
||||
@Body(new ZodPipe(loginSchema)) body: LoginInput,
|
||||
@Req() req: Request,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<AuthResponse> {
|
||||
return this.respond(res, await this.auth.login(body, ua(req)));
|
||||
}
|
||||
|
||||
@Post("refresh")
|
||||
@HttpCode(200)
|
||||
@Throttle({ default: { limit: 30, ttl: 60_000 } })
|
||||
async refresh(
|
||||
@Req() req: Request,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<AuthResponse> {
|
||||
try {
|
||||
return this.respond(res, await this.auth.refresh(readCookie(req), ua(req)));
|
||||
} catch (e) {
|
||||
res.clearCookie(COOKIE, { path: COOKIE_PATH });
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@Post("logout")
|
||||
@HttpCode(204)
|
||||
async logout(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
|
||||
await this.auth.logout(readCookie(req));
|
||||
res.clearCookie(COOKIE, { path: COOKIE_PATH });
|
||||
}
|
||||
|
||||
@Get("me")
|
||||
@AdminAuth()
|
||||
me(@CurrentUser() user: AuthUser) {
|
||||
return this.auth.me(user.id);
|
||||
}
|
||||
|
||||
@Post("change-password")
|
||||
@HttpCode(204)
|
||||
@AdminAuth()
|
||||
async changePassword(
|
||||
@Body(new ZodPipe(changePasswordSchema)) body: ChangePasswordInput,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
await this.auth.changePassword(user.id, body);
|
||||
}
|
||||
|
||||
private respond(res: Response, t: IssuedTokens): AuthResponse {
|
||||
res.cookie(COOKIE, t.refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: isProd && env.COOKIE_SECURE,
|
||||
sameSite: "strict",
|
||||
path: COOKIE_PATH,
|
||||
expires: t.refreshExpiresAt,
|
||||
});
|
||||
return { accessToken: t.accessToken, user: t.user };
|
||||
}
|
||||
}
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
JwtModule.register({
|
||||
global: true,
|
||||
secret: env.JWT_SECRET,
|
||||
signOptions: { expiresIn: env.JWT_ACCESS_TTL as JwtSignOptions["expiresIn"] },
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { BadRequestException, Injectable, UnauthorizedException } from "@nestjs/common";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import { hash, verify } from "@node-rs/argon2";
|
||||
import type { AdminUserDto, AuthResponse, ChangePasswordInput, LoginInput } from "@imen/shared";
|
||||
import { env } from "../config/env.js";
|
||||
import type { JwtPayload } from "../common/auth.js";
|
||||
import { AuditService } from "../common/common.module.js";
|
||||
import type { AdminUser } from "../generated/prisma/client.js";
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
|
||||
export const hashPassword = (password: string) => hash(password);
|
||||
|
||||
const sha256 = (v: string) => createHash("sha256").update(v).digest("hex");
|
||||
|
||||
export const toUserDto = (u: AdminUser): AdminUserDto => ({
|
||||
id: u.id,
|
||||
email: u.email,
|
||||
name: u.name,
|
||||
role: u.role,
|
||||
isActive: u.isActive,
|
||||
lastLoginAt: u.lastLoginAt?.toISOString() ?? null,
|
||||
createdAt: u.createdAt.toISOString(),
|
||||
});
|
||||
|
||||
export interface IssuedTokens extends AuthResponse {
|
||||
refreshToken: string;
|
||||
refreshExpiresAt: Date;
|
||||
}
|
||||
|
||||
const INVALID_LOGIN = "ایمیل یا رمز عبور اشتباه است";
|
||||
|
||||
/** hash of a random password, verified against when the email is unknown so timing stays the same */
|
||||
const DUMMY_HASH = hashPassword(randomBytes(16).toString("hex"));
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwt: JwtService,
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
async login(input: LoginInput, userAgent: string): Promise<IssuedTokens> {
|
||||
const user = await this.prisma.adminUser.findUnique({ where: { email: input.email } });
|
||||
// verify even when the user is missing so response time does not reveal valid emails
|
||||
const ok = await verify(user?.passwordHash ?? (await DUMMY_HASH), input.password).catch(
|
||||
() => false,
|
||||
);
|
||||
if (!user || !ok || !user.isActive) throw new UnauthorizedException(INVALID_LOGIN);
|
||||
|
||||
const updated = await this.prisma.adminUser.update({
|
||||
where: { id: user.id },
|
||||
data: { lastLoginAt: new Date() },
|
||||
});
|
||||
this.audit.log(user.id, "login", "user", user.id, user.email);
|
||||
return this.issue(updated, userAgent);
|
||||
}
|
||||
|
||||
/** Rotates the refresh token: the old one is revoked and a new pair is issued */
|
||||
async refresh(token: string | undefined, userAgent: string): Promise<IssuedTokens> {
|
||||
if (!token) throw new UnauthorizedException("نشست شما منقضی شده است");
|
||||
|
||||
const stored = await this.prisma.refreshToken.findUnique({
|
||||
where: { tokenHash: sha256(token) },
|
||||
include: { user: true },
|
||||
});
|
||||
if (!stored) throw new UnauthorizedException("نشست شما منقضی شده است");
|
||||
|
||||
if (stored.revokedAt) {
|
||||
// a revoked token was replayed: assume it leaked and end every session of this user
|
||||
await this.revokeAll(stored.userId);
|
||||
throw new UnauthorizedException("نشست شما منقضی شده است");
|
||||
}
|
||||
if (stored.expiresAt < new Date() || !stored.user.isActive) {
|
||||
throw new UnauthorizedException("نشست شما منقضی شده است");
|
||||
}
|
||||
|
||||
await this.prisma.refreshToken.update({
|
||||
where: { id: stored.id },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
return this.issue(stored.user, userAgent);
|
||||
}
|
||||
|
||||
async logout(token: string | undefined) {
|
||||
if (!token) return;
|
||||
await this.prisma.refreshToken.updateMany({
|
||||
where: { tokenHash: sha256(token), revokedAt: null },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
async changePassword(userId: number, input: ChangePasswordInput) {
|
||||
const user = await this.prisma.adminUser.findUniqueOrThrow({ where: { id: userId } });
|
||||
if (!(await verify(user.passwordHash, input.currentPassword).catch(() => false))) {
|
||||
throw new BadRequestException("رمز عبور فعلی اشتباه است");
|
||||
}
|
||||
await this.prisma.adminUser.update({
|
||||
where: { id: userId },
|
||||
data: { passwordHash: await hashPassword(input.newPassword) },
|
||||
});
|
||||
this.audit.log(userId, "change-password", "user", userId, user.email);
|
||||
}
|
||||
|
||||
async me(userId: number) {
|
||||
return toUserDto(await this.prisma.adminUser.findUniqueOrThrow({ where: { id: userId } }));
|
||||
}
|
||||
|
||||
revokeAll(userId: number) {
|
||||
return this.prisma.refreshToken.updateMany({
|
||||
where: { userId, revokedAt: null },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
private async issue(user: AdminUser, userAgent: string): Promise<IssuedTokens> {
|
||||
const payload: JwtPayload = { sub: user.id, role: user.role };
|
||||
const accessToken = await this.jwt.signAsync(payload);
|
||||
|
||||
const refreshToken = randomBytes(48).toString("base64url");
|
||||
const refreshExpiresAt = new Date(Date.now() + env.REFRESH_TTL_DAYS * 86_400_000);
|
||||
await this.prisma.refreshToken.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
tokenHash: sha256(refreshToken),
|
||||
expiresAt: refreshExpiresAt,
|
||||
userAgent: userAgent.slice(0, 300),
|
||||
},
|
||||
});
|
||||
|
||||
// keep the table small: drop tokens that expired more than a day ago
|
||||
void this.prisma.refreshToken
|
||||
.deleteMany({ where: { expiresAt: { lt: new Date(Date.now() - 86_400_000) } } })
|
||||
.catch(() => undefined);
|
||||
|
||||
return { accessToken, refreshToken, refreshExpiresAt, user: toUserDto(user) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
Module,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
type CategoryInput,
|
||||
categoryInputSchema,
|
||||
type ProductInput,
|
||||
productInputSchema,
|
||||
type ProductListQuery,
|
||||
productListQuerySchema,
|
||||
type ReorderInput,
|
||||
reorderSchema,
|
||||
} from "@imen/shared";
|
||||
import { z } from "zod";
|
||||
import { AdminAuth, type AuthUser, CurrentUser } 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";
|
||||
import { ProductsService } from "./products.service.js";
|
||||
|
||||
const publishSchema = z.object({ isPublished: z.boolean() });
|
||||
|
||||
@AdminAuth()
|
||||
@Controller("admin/categories")
|
||||
export class CategoriesController {
|
||||
constructor(private readonly categories: CategoriesService) {}
|
||||
|
||||
@Get()
|
||||
list() {
|
||||
return this.categories.list();
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
get(@Param("id", IdPipe) id: number) {
|
||||
return this.categories.get(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(
|
||||
@Body(new ZodPipe(categoryInputSchema)) body: CategoryInput,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.categories.create(body, user.id);
|
||||
}
|
||||
|
||||
@Post("reorder")
|
||||
@HttpCode(204)
|
||||
async reorder(@Body(new ZodPipe(reorderSchema)) body: ReorderInput, @CurrentUser() user: AuthUser) {
|
||||
await this.categories.reorder(body.ids, user.id);
|
||||
}
|
||||
|
||||
@Put(":id")
|
||||
update(
|
||||
@Param("id", IdPipe) id: number,
|
||||
@Body(new ZodPipe(categoryInputSchema)) body: CategoryInput,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.categories.update(id, body, user.id);
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
@HttpCode(204)
|
||||
async remove(@Param("id", IdPipe) id: number, @CurrentUser() user: AuthUser) {
|
||||
await this.categories.remove(id, user.id);
|
||||
}
|
||||
}
|
||||
|
||||
@AdminAuth()
|
||||
@Controller("admin/products")
|
||||
export class ProductsController {
|
||||
constructor(private readonly products: ProductsService) {}
|
||||
|
||||
@Get()
|
||||
list(@Query(new ZodPipe(productListQuerySchema)) q: ProductListQuery) {
|
||||
return this.products.list(q);
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
get(@Param("id", IdPipe) id: number) {
|
||||
return this.products.get(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@Body(new ZodPipe(productInputSchema)) body: ProductInput, @CurrentUser() user: AuthUser) {
|
||||
return this.products.create(body, user.id);
|
||||
}
|
||||
|
||||
@Post("reorder")
|
||||
@HttpCode(204)
|
||||
async reorder(@Body(new ZodPipe(reorderSchema)) body: ReorderInput, @CurrentUser() user: AuthUser) {
|
||||
await this.products.reorder(body.ids, user.id);
|
||||
}
|
||||
|
||||
@Post(":id/duplicate")
|
||||
duplicate(@Param("id", IdPipe) id: number, @CurrentUser() user: AuthUser) {
|
||||
return this.products.duplicate(id, user.id);
|
||||
}
|
||||
|
||||
@Patch(":id/publish")
|
||||
publish(
|
||||
@Param("id", IdPipe) id: number,
|
||||
@Body(new ZodPipe(publishSchema)) body: z.output<typeof publishSchema>,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.products.setPublished(id, body.isPublished, user.id);
|
||||
}
|
||||
|
||||
@Put(":id")
|
||||
update(
|
||||
@Param("id", IdPipe) id: number,
|
||||
@Body(new ZodPipe(productInputSchema)) body: ProductInput,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.products.update(id, body, user.id);
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
@HttpCode(204)
|
||||
async remove(@Param("id", IdPipe) id: number, @CurrentUser() user: AuthUser) {
|
||||
await this.products.remove(id, user.id);
|
||||
}
|
||||
}
|
||||
|
||||
@Module({
|
||||
imports: [MediaModule],
|
||||
controllers: [CategoriesController, ProductsController],
|
||||
providers: [CategoriesService, ProductsService],
|
||||
})
|
||||
export class CatalogModule {}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { ConflictException, Injectable } from "@nestjs/common";
|
||||
import type { CategoryInput } from "@imen/shared";
|
||||
import { AuditService, RevalidateService } from "../common/common.module.js";
|
||||
import { MediaService } from "../media/media.service.js";
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
|
||||
const withCover = { coverImage: true } as const;
|
||||
|
||||
@Injectable()
|
||||
export class CategoriesService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly media: MediaService,
|
||||
private readonly audit: AuditService,
|
||||
private readonly revalidate: RevalidateService,
|
||||
) {}
|
||||
|
||||
async list() {
|
||||
const rows = await this.prisma.category.findMany({
|
||||
orderBy: [{ sortOrder: "asc" }, { id: "asc" }],
|
||||
include: { ...withCover, _count: { select: { products: true, groups: true } } },
|
||||
});
|
||||
return rows.map(({ coverImage, _count, ...c }) => ({
|
||||
...c,
|
||||
coverImage: coverImage && this.media.toDto(coverImage),
|
||||
productCount: _count.products,
|
||||
groupCount: _count.groups,
|
||||
}));
|
||||
}
|
||||
|
||||
async get(id: number) {
|
||||
const { coverImage, ...c } = await this.prisma.category.findUniqueOrThrow({
|
||||
where: { id },
|
||||
include: {
|
||||
...withCover,
|
||||
groups: {
|
||||
orderBy: { sortOrder: "asc" },
|
||||
include: { _count: { select: { products: true } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
return {
|
||||
...c,
|
||||
coverImage: coverImage && this.media.toDto(coverImage),
|
||||
groups: c.groups.map(({ _count, ...g }) => ({ ...g, productCount: _count.products })),
|
||||
};
|
||||
}
|
||||
|
||||
async create(input: CategoryInput, userId: number) {
|
||||
this.assertUniqueGroupSlugs(input);
|
||||
const { groups, ...data } = input;
|
||||
const last = await this.prisma.category.aggregate({ _max: { sortOrder: true } });
|
||||
|
||||
const category = await this.prisma.category.create({
|
||||
data: {
|
||||
...data,
|
||||
sortOrder: (last._max.sortOrder ?? -1) + 1,
|
||||
groups: {
|
||||
create: groups.map((g, i) => ({ slug: g.slug, name: g.name, desc: g.desc, sortOrder: i })),
|
||||
},
|
||||
},
|
||||
});
|
||||
this.audit.log(userId, "create", "category", category.id, category.name);
|
||||
this.revalidate.trigger();
|
||||
return this.get(category.id);
|
||||
}
|
||||
|
||||
async update(id: number, input: CategoryInput, userId: number) {
|
||||
this.assertUniqueGroupSlugs(input);
|
||||
const { groups, ...data } = input;
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
const category = await tx.category.update({ where: { id }, data });
|
||||
|
||||
// sync groups: keep/update listed ids, delete missing ones, create new ones
|
||||
const keepIds = groups.flatMap((g) => (g.id ? [g.id] : []));
|
||||
await tx.productGroup.deleteMany({ where: { categoryId: id, id: { notIn: keepIds } } });
|
||||
|
||||
// temporary slugs avoid unique clashes when two groups swap slugs
|
||||
for (const g of groups) {
|
||||
if (g.id) {
|
||||
await tx.productGroup.update({
|
||||
where: { id: g.id, categoryId: id },
|
||||
data: { slug: `__tmp-${g.id}` },
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const [i, g] of groups.entries()) {
|
||||
const fields = { slug: g.slug, name: g.name, desc: g.desc, sortOrder: i };
|
||||
if (g.id) {
|
||||
await tx.productGroup.update({ where: { id: g.id, categoryId: id }, data: fields });
|
||||
} else {
|
||||
await tx.productGroup.create({ data: { ...fields, categoryId: id } });
|
||||
}
|
||||
}
|
||||
this.audit.log(userId, "update", "category", id, category.name);
|
||||
});
|
||||
|
||||
this.revalidate.trigger();
|
||||
return this.get(id);
|
||||
}
|
||||
|
||||
async remove(id: number, userId: number) {
|
||||
const category = await this.prisma.category.findUniqueOrThrow({
|
||||
where: { id },
|
||||
include: { _count: { select: { products: true } } },
|
||||
});
|
||||
if (category._count.products > 0) {
|
||||
throw new ConflictException(
|
||||
`این دسته ${category._count.products} محصول دارد؛ ابتدا محصولات را حذف یا جابهجا کنید`,
|
||||
);
|
||||
}
|
||||
await this.prisma.category.delete({ where: { id } });
|
||||
this.audit.log(userId, "delete", "category", id, category.name);
|
||||
this.revalidate.trigger();
|
||||
}
|
||||
|
||||
async reorder(ids: number[], userId: number) {
|
||||
await this.prisma.$transaction(
|
||||
ids.map((id, i) => this.prisma.category.update({ where: { id }, data: { sortOrder: i } })),
|
||||
);
|
||||
this.audit.log(userId, "reorder", "category", "", `${ids.length} دسته`);
|
||||
this.revalidate.trigger();
|
||||
}
|
||||
|
||||
private assertUniqueGroupSlugs(input: CategoryInput) {
|
||||
const slugs = input.groups.map((g) => g.slug);
|
||||
if (new Set(slugs).size !== slugs.length) {
|
||||
throw new ConflictException("اسلاگ زیرگروهها باید یکتا باشد");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { BadRequestException, Injectable } from "@nestjs/common";
|
||||
import type { ProductInput, ProductListQuery } from "@imen/shared";
|
||||
import { AuditService, RevalidateService } from "../common/common.module.js";
|
||||
import type { Prisma } from "../generated/prisma/client.js";
|
||||
import { MediaService } from "../media/media.service.js";
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
|
||||
@Injectable()
|
||||
export class ProductsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly media: MediaService,
|
||||
private readonly audit: AuditService,
|
||||
private readonly revalidate: RevalidateService,
|
||||
) {}
|
||||
|
||||
async list(q: ProductListQuery) {
|
||||
const where: Prisma.ProductWhereInput = {
|
||||
...(q.categoryId && { categoryId: q.categoryId }),
|
||||
...(q.groupId && { groupId: q.groupId }),
|
||||
...(q.status && { isPublished: q.status === "published" }),
|
||||
...(q.q && {
|
||||
OR: [
|
||||
{ name: { contains: q.q, mode: "insensitive" } },
|
||||
{ nameEn: { contains: q.q, mode: "insensitive" } },
|
||||
{ code: { contains: q.q, mode: "insensitive" } },
|
||||
],
|
||||
}),
|
||||
};
|
||||
const [rows, total] = await this.prisma.$transaction([
|
||||
this.prisma.product.findMany({
|
||||
where,
|
||||
orderBy: [{ category: { sortOrder: "asc" } }, { sortOrder: "asc" }, { id: "asc" }],
|
||||
skip: (q.page - 1) * q.pageSize,
|
||||
take: q.pageSize,
|
||||
include: {
|
||||
image: true,
|
||||
category: { select: { id: true, name: true } },
|
||||
group: { select: { id: true, name: true } },
|
||||
},
|
||||
}),
|
||||
this.prisma.product.count({ where }),
|
||||
]);
|
||||
return {
|
||||
items: rows.map(({ image, ...p }) => ({ ...p, image: image && this.media.toDto(image) })),
|
||||
total,
|
||||
page: q.page,
|
||||
pageSize: q.pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
async get(id: number) {
|
||||
const { image, gallery, ...p } = await this.prisma.product.findUniqueOrThrow({
|
||||
where: { id },
|
||||
include: { image: true, gallery: { orderBy: { sortOrder: "asc" }, include: { media: true } } },
|
||||
});
|
||||
return {
|
||||
...p,
|
||||
image: image && this.media.toDto(image),
|
||||
gallery: gallery.map((g) => this.media.toDto(g.media)),
|
||||
};
|
||||
}
|
||||
|
||||
async create(input: ProductInput, userId: number) {
|
||||
await this.assertGroup(input);
|
||||
const { galleryIds, ...data } = input;
|
||||
const last = await this.prisma.product.aggregate({
|
||||
where: { categoryId: input.categoryId },
|
||||
_max: { sortOrder: true },
|
||||
});
|
||||
const product = await this.prisma.product.create({
|
||||
data: {
|
||||
...data,
|
||||
sortOrder: (last._max.sortOrder ?? -1) + 1,
|
||||
gallery: { create: unique(galleryIds).map((mediaId, i) => ({ mediaId, sortOrder: i })) },
|
||||
},
|
||||
});
|
||||
this.audit.log(userId, "create", "product", product.id, product.name);
|
||||
this.revalidate.trigger();
|
||||
return this.get(product.id);
|
||||
}
|
||||
|
||||
async update(id: number, input: ProductInput, userId: number) {
|
||||
await this.assertGroup(input);
|
||||
const { galleryIds, ...data } = input;
|
||||
const product = await this.prisma.product.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...data,
|
||||
gallery: {
|
||||
deleteMany: {},
|
||||
create: unique(galleryIds).map((mediaId, i) => ({ mediaId, sortOrder: i })),
|
||||
},
|
||||
},
|
||||
});
|
||||
this.audit.log(userId, "update", "product", id, product.name);
|
||||
this.revalidate.trigger();
|
||||
return this.get(id);
|
||||
}
|
||||
|
||||
async setPublished(id: number, isPublished: boolean, userId: number) {
|
||||
const product = await this.prisma.product.update({ where: { id }, data: { isPublished } });
|
||||
this.audit.log(userId, isPublished ? "publish" : "unpublish", "product", id, product.name);
|
||||
this.revalidate.trigger();
|
||||
return { id, isPublished };
|
||||
}
|
||||
|
||||
/** Copies a product as an unpublished draft with unique code/slug */
|
||||
async duplicate(id: number, userId: number) {
|
||||
const src = await this.prisma.product.findUniqueOrThrow({
|
||||
where: { id },
|
||||
include: { gallery: true },
|
||||
});
|
||||
let n = 1;
|
||||
const taken = async (code: string, slug: string) =>
|
||||
(await this.prisma.product.count({ where: { OR: [{ code }, { slug }] } })) > 0;
|
||||
while (await taken(`${src.code}-${n}`, `${src.slug}-copy-${n}`)) n++;
|
||||
|
||||
const copy = await this.prisma.product.create({
|
||||
data: {
|
||||
categoryId: src.categoryId,
|
||||
groupId: src.groupId,
|
||||
code: `${src.code}-${n}`,
|
||||
slug: `${src.slug}-copy-${n}`,
|
||||
name: `${src.name} (کپی)`,
|
||||
nameEn: src.nameEn,
|
||||
desc: src.desc,
|
||||
specs: src.specs,
|
||||
variants: src.variants,
|
||||
imageId: src.imageId,
|
||||
sortOrder: src.sortOrder + 1,
|
||||
isPublished: false,
|
||||
gallery: {
|
||||
create: src.gallery.map((g) => ({ mediaId: g.mediaId, sortOrder: g.sortOrder })),
|
||||
},
|
||||
},
|
||||
});
|
||||
this.audit.log(userId, "duplicate", "product", copy.id, copy.name);
|
||||
return this.get(copy.id);
|
||||
}
|
||||
|
||||
async remove(id: number, userId: number) {
|
||||
const product = await this.prisma.product.delete({ where: { id } });
|
||||
this.audit.log(userId, "delete", "product", id, product.name);
|
||||
this.revalidate.trigger();
|
||||
}
|
||||
|
||||
async reorder(ids: number[], userId: number) {
|
||||
await this.prisma.$transaction(
|
||||
ids.map((id, i) => this.prisma.product.update({ where: { id }, data: { sortOrder: i } })),
|
||||
);
|
||||
this.audit.log(userId, "reorder", "product", "", `${ids.length} محصول`);
|
||||
this.revalidate.trigger();
|
||||
}
|
||||
|
||||
private async assertGroup(input: ProductInput) {
|
||||
if (!input.groupId) return;
|
||||
const group = await this.prisma.productGroup.findUnique({ where: { id: input.groupId } });
|
||||
if (group?.categoryId !== input.categoryId) {
|
||||
throw new BadRequestException("زیرگروه انتخابشده متعلق به این دسته نیست");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const unique = (ids: number[]) => [...new Set(ids)];
|
||||
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
type CanActivate,
|
||||
createParamDecorator,
|
||||
type ExecutionContext,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
SetMetadata,
|
||||
UnauthorizedException,
|
||||
UseGuards,
|
||||
applyDecorators,
|
||||
} from "@nestjs/common";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import type { Role } from "@imen/shared";
|
||||
import type { Request } from "express";
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
|
||||
export interface JwtPayload {
|
||||
sub: number;
|
||||
role: Role;
|
||||
}
|
||||
|
||||
export interface AuthUser {
|
||||
id: number;
|
||||
email: string;
|
||||
name: string;
|
||||
role: Role;
|
||||
}
|
||||
|
||||
type AuthedRequest = Request & { user?: AuthUser };
|
||||
|
||||
const ROLES_KEY = "roles";
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly jwt: JwtService,
|
||||
private readonly reflector: Reflector,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
async canActivate(ctx: ExecutionContext): Promise<boolean> {
|
||||
const req = ctx.switchToHttp().getRequest<AuthedRequest>();
|
||||
const [scheme, token] = (req.headers.authorization ?? "").split(" ");
|
||||
if (scheme !== "Bearer" || !token) throw new UnauthorizedException("ابتدا وارد شوید");
|
||||
|
||||
let payload: JwtPayload;
|
||||
try {
|
||||
payload = await this.jwt.verifyAsync<JwtPayload>(token);
|
||||
} catch {
|
||||
throw new UnauthorizedException("نشست شما منقضی شده است");
|
||||
}
|
||||
|
||||
// re-check the user so deactivation and role changes apply immediately
|
||||
const user = await this.prisma.adminUser.findUnique({ where: { id: payload.sub } });
|
||||
if (!user?.isActive) throw new UnauthorizedException("حساب کاربری غیرفعال است");
|
||||
req.user = { id: user.id, email: user.email, name: user.name, role: user.role };
|
||||
|
||||
const roles = this.reflector.getAllAndOverride<Role[] | undefined>(ROLES_KEY, [
|
||||
ctx.getHandler(),
|
||||
ctx.getClass(),
|
||||
]);
|
||||
if (roles?.length && !roles.includes(user.role)) {
|
||||
throw new ForbiddenException("برای این کار دسترسی ندارید");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** Protects a controller (or route) with a valid access token; optionally limits it to roles */
|
||||
export const AdminAuth = (...roles: Role[]) =>
|
||||
applyDecorators(SetMetadata(ROLES_KEY, roles), UseGuards(JwtAuthGuard));
|
||||
|
||||
/** Narrows the roles of one route inside an `@AdminAuth()` controller */
|
||||
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
|
||||
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(_: unknown, ctx: ExecutionContext): AuthUser =>
|
||||
ctx.switchToHttp().getRequest<AuthedRequest>().user!,
|
||||
);
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Global, Injectable, Logger, Module } from "@nestjs/common";
|
||||
import { env } from "../config/env.js";
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
|
||||
/** Records who changed what; failures never break the request */
|
||||
@Injectable()
|
||||
export class AuditService {
|
||||
private readonly logger = new Logger(AuditService.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
log(
|
||||
userId: number | null,
|
||||
action: string,
|
||||
entity: string,
|
||||
entityId: string | number = "",
|
||||
summary = "",
|
||||
) {
|
||||
this.prisma.auditLog
|
||||
.create({ data: { userId, action, entity, entityId: String(entityId), summary } })
|
||||
.catch((e: unknown) => this.logger.warn(`audit log failed: ${String(e)}`));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells the Nuxt site to drop its page cache after content changes.
|
||||
* Calls are debounced so a burst of edits triggers one revalidation.
|
||||
*/
|
||||
@Injectable()
|
||||
export class RevalidateService {
|
||||
private readonly logger = new Logger(RevalidateService.name);
|
||||
private timer: NodeJS.Timeout | undefined;
|
||||
|
||||
trigger() {
|
||||
if (!env.WEB_REVALIDATE_URL) return;
|
||||
clearTimeout(this.timer);
|
||||
this.timer = setTimeout(() => void this.run(), 1500);
|
||||
this.timer.unref();
|
||||
}
|
||||
|
||||
private async run() {
|
||||
try {
|
||||
const res = await fetch(env.WEB_REVALIDATE_URL!, {
|
||||
method: "POST",
|
||||
headers: { "x-revalidate-secret": env.REVALIDATE_SECRET ?? "" },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!res.ok) this.logger.warn(`site revalidation returned ${res.status}`);
|
||||
} catch (e) {
|
||||
this.logger.warn(`site revalidation failed: ${String(e)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [AuditService, RevalidateService],
|
||||
exports: [AuditService, RevalidateService],
|
||||
})
|
||||
export class CommonModule {}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { type ArgumentsHost, Catch, type ExceptionFilter, HttpStatus } from "@nestjs/common";
|
||||
import type { Response } from "express";
|
||||
import { Prisma } from "../generated/prisma/client.js";
|
||||
|
||||
const FIELD_LABELS: Record<string, string> = {
|
||||
slug: "اسلاگ",
|
||||
code: "کد",
|
||||
email: "ایمیل",
|
||||
};
|
||||
|
||||
interface UniqueMeta {
|
||||
target?: string[] | string;
|
||||
driverAdapterError?: { cause?: { constraint?: { fields?: string[]; index?: string } } };
|
||||
}
|
||||
|
||||
/** Fields of a violated unique constraint; with driver adapters only the index name is given */
|
||||
function uniqueFields(meta: UniqueMeta | undefined): string[] {
|
||||
if (meta?.target) return Array.isArray(meta.target) ? meta.target : [meta.target];
|
||||
const constraint = meta?.driverAdapterError?.cause?.constraint;
|
||||
if (constraint?.fields) return constraint.fields;
|
||||
// "Product_code_key" → ["code"], "ProductGroup_categoryId_slug_key" → ["categoryId", "slug"]
|
||||
const match = constraint?.index?.match(/^[^_]+_(.+)_key$/);
|
||||
return match ? match[1]!.split("_") : [];
|
||||
}
|
||||
|
||||
/** Maps common Prisma errors to clear HTTP responses instead of 500s */
|
||||
@Catch(Prisma.PrismaClientKnownRequestError)
|
||||
export class PrismaExceptionFilter implements ExceptionFilter {
|
||||
catch(error: Prisma.PrismaClientKnownRequestError, host: ArgumentsHost) {
|
||||
const res = host.switchToHttp().getResponse<Response>();
|
||||
|
||||
switch (error.code) {
|
||||
case "P2002": {
|
||||
const fields = uniqueFields(error.meta as UniqueMeta | undefined)
|
||||
.filter((f) => !f.endsWith("Id")) // scoping columns like categoryId
|
||||
.map((f) => FIELD_LABELS[f] ?? f)
|
||||
.join("، ");
|
||||
return res.status(HttpStatus.CONFLICT).json({
|
||||
statusCode: HttpStatus.CONFLICT,
|
||||
message: fields ? `مقدار «${fields}» تکراری است` : "مقدار تکراری است",
|
||||
});
|
||||
}
|
||||
case "P2003":
|
||||
return res.status(HttpStatus.CONFLICT).json({
|
||||
statusCode: HttpStatus.CONFLICT,
|
||||
message: "این مورد به دادههای دیگری وابسته است",
|
||||
});
|
||||
case "P2025":
|
||||
return res
|
||||
.status(HttpStatus.NOT_FOUND)
|
||||
.json({ statusCode: HttpStatus.NOT_FOUND, message: "مورد پیدا نشد" });
|
||||
default:
|
||||
return res.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
|
||||
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
message: "خطای پایگاه داده",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { BadRequestException, type PipeTransform } from "@nestjs/common";
|
||||
import { toIssues } from "@imen/shared";
|
||||
import { z } from "zod";
|
||||
|
||||
// Persian default messages for every schema that has no custom message
|
||||
z.config(z.locales.fa());
|
||||
|
||||
/** Validates and parses a request part with a zod schema: `@Body(new ZodPipe(schema))` */
|
||||
export class ZodPipe<T extends z.ZodType> implements PipeTransform<unknown, z.output<T>> {
|
||||
constructor(private readonly schema: T) {}
|
||||
|
||||
transform(value: unknown): z.output<T> {
|
||||
const result = this.schema.safeParse(value);
|
||||
if (!result.success) {
|
||||
throw new BadRequestException({
|
||||
statusCode: 400,
|
||||
message: "اطلاعات واردشده معتبر نیست",
|
||||
issues: toIssues(result.error),
|
||||
});
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
}
|
||||
|
||||
/** Parses an `:id` route param */
|
||||
export const IdPipe = new ZodPipe(z.coerce.number().int().positive());
|
||||
@@ -0,0 +1,53 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { z } from "zod";
|
||||
|
||||
if (existsSync(".env")) process.loadEnvFile(".env");
|
||||
|
||||
const bool = (fallback: boolean) =>
|
||||
z
|
||||
.enum(["true", "false", "1", "0"])
|
||||
.optional()
|
||||
.transform((v) => (v === undefined ? fallback : v === "true" || v === "1"));
|
||||
|
||||
const schema = z.object({
|
||||
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
|
||||
PORT: z.coerce.number().int().default(4000),
|
||||
CORS_ORIGINS: z.string().default(""),
|
||||
/** number of reverse proxies in front of the API (gateway nginx = 1, + host TLS proxy = 2) */
|
||||
TRUST_PROXY: z.coerce.number().int().min(0).default(1),
|
||||
|
||||
DATABASE_URL: z.string().min(1),
|
||||
|
||||
JWT_SECRET: z.string().min(32, "JWT_SECRET must be at least 32 characters"),
|
||||
JWT_ACCESS_TTL: z.string().default("15m"),
|
||||
REFRESH_TTL_DAYS: z.coerce.number().int().positive().default(30),
|
||||
COOKIE_SECURE: bool(true),
|
||||
|
||||
S3_ENDPOINT: z.string().optional(),
|
||||
S3_REGION: z.string().default("us-east-1"),
|
||||
S3_BUCKET: z.string().default("imen-media"),
|
||||
S3_ACCESS_KEY: z.string().default(""),
|
||||
S3_SECRET_KEY: z.string().default(""),
|
||||
S3_FORCE_PATH_STYLE: bool(true),
|
||||
S3_ENSURE_BUCKET: bool(false),
|
||||
MEDIA_PUBLIC_URL: z
|
||||
.string()
|
||||
.default("/media")
|
||||
.transform((v) => v.replace(/\/+$/, "")),
|
||||
|
||||
WEB_REVALIDATE_URL: z.string().optional(),
|
||||
REVALIDATE_SECRET: z.string().optional(),
|
||||
|
||||
ADMIN_EMAIL: z.string().optional(),
|
||||
ADMIN_PASSWORD: z.string().optional(),
|
||||
ADMIN_NAME: z.string().default("مدیر سایت"),
|
||||
});
|
||||
|
||||
const parsed = schema.safeParse(process.env);
|
||||
if (!parsed.success) {
|
||||
console.error("Invalid environment variables:\n" + z.prettifyError(parsed.error));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
export const env = parsed.data;
|
||||
export const isProd = env.NODE_ENV === "production";
|
||||
@@ -0,0 +1,137 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
Injectable,
|
||||
Module,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
} from "@nestjs/common";
|
||||
import { Throttle } from "@nestjs/throttler";
|
||||
import {
|
||||
type ContactInput,
|
||||
contactInputSchema,
|
||||
type ContactMessageDto,
|
||||
MESSAGE_STATUSES,
|
||||
type MessageStatus,
|
||||
messageUpdateSchema,
|
||||
type Paginated,
|
||||
} from "@imen/shared";
|
||||
import type { Request } from "express";
|
||||
import { z } from "zod";
|
||||
import { AdminAuth, type AuthUser, CurrentUser } 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";
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
|
||||
const listQuery = z.object({
|
||||
status: z.enum(MESSAGE_STATUSES).optional(),
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).default(20),
|
||||
});
|
||||
|
||||
export const toMessageDto = (m: ContactMessage): ContactMessageDto => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
org: m.org,
|
||||
phone: m.phone,
|
||||
message: m.message,
|
||||
status: m.status,
|
||||
createdAt: m.createdAt.toISOString(),
|
||||
});
|
||||
|
||||
@Injectable()
|
||||
export class ContactService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
async submit(input: ContactInput, ip: string, userAgent: string) {
|
||||
// honeypot filled → pretend success so bots learn nothing
|
||||
if (input.website) return;
|
||||
await this.prisma.contactMessage.create({
|
||||
data: {
|
||||
name: input.name,
|
||||
org: input.org,
|
||||
phone: input.phone,
|
||||
message: input.message,
|
||||
ip: ip.slice(0, 64),
|
||||
userAgent: userAgent.slice(0, 300),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async list(q: z.output<typeof listQuery>): Promise<Paginated<ContactMessageDto>> {
|
||||
const where = q.status ? { status: q.status } : {};
|
||||
const [items, total] = await this.prisma.$transaction([
|
||||
this.prisma.contactMessage.findMany({
|
||||
where,
|
||||
orderBy: { id: "desc" },
|
||||
skip: (q.page - 1) * q.pageSize,
|
||||
take: q.pageSize,
|
||||
}),
|
||||
this.prisma.contactMessage.count({ where }),
|
||||
]);
|
||||
return { items: items.map(toMessageDto), total, page: q.page, pageSize: q.pageSize };
|
||||
}
|
||||
|
||||
async setStatus(id: number, status: MessageStatus) {
|
||||
return toMessageDto(await this.prisma.contactMessage.update({ where: { id }, data: { status } }));
|
||||
}
|
||||
|
||||
async remove(id: number, userId: number) {
|
||||
const m = await this.prisma.contactMessage.delete({ where: { id } });
|
||||
this.audit.log(userId, "delete", "message", id, m.name);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller("public/contact")
|
||||
export class PublicContactController {
|
||||
constructor(private readonly contact: ContactService) {}
|
||||
|
||||
@Post()
|
||||
@HttpCode(201)
|
||||
@Throttle({ default: { limit: 5, ttl: 10 * 60_000 } })
|
||||
async submit(@Body(new ZodPipe(contactInputSchema)) body: ContactInput, @Req() req: Request) {
|
||||
await this.contact.submit(body, req.ip ?? "", req.headers["user-agent"] ?? "");
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
|
||||
@AdminAuth()
|
||||
@Controller("admin/messages")
|
||||
export class MessagesController {
|
||||
constructor(private readonly contact: ContactService) {}
|
||||
|
||||
@Get()
|
||||
list(@Query(new ZodPipe(listQuery)) q: z.output<typeof listQuery>) {
|
||||
return this.contact.list(q);
|
||||
}
|
||||
|
||||
@Patch(":id")
|
||||
update(
|
||||
@Param("id", IdPipe) id: number,
|
||||
@Body(new ZodPipe(messageUpdateSchema)) body: z.output<typeof messageUpdateSchema>,
|
||||
) {
|
||||
return this.contact.setStatus(id, body.status);
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
@HttpCode(204)
|
||||
async remove(@Param("id", IdPipe) id: number, @CurrentUser() user: AuthUser) {
|
||||
await this.contact.remove(id, user.id);
|
||||
}
|
||||
}
|
||||
|
||||
@Module({
|
||||
controllers: [PublicContactController, MessagesController],
|
||||
providers: [ContactService],
|
||||
})
|
||||
export class ContactModule {}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Controller, Get, Module } from "@nestjs/common";
|
||||
import type { DashboardDto } from "@imen/shared";
|
||||
import { AdminAuth } from "../common/auth.js";
|
||||
import { toMessageDto } from "../contact/contact.module.js";
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
|
||||
@AdminAuth()
|
||||
@Controller("admin/dashboard")
|
||||
export class DashboardController {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
@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 } } },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
categories,
|
||||
products,
|
||||
draftProducts,
|
||||
newMessages,
|
||||
media,
|
||||
recentMessages: messages.map(toMessageDto),
|
||||
recentActivity: activity.map((a) => ({
|
||||
id: a.id,
|
||||
action: a.action,
|
||||
entity: a.entity,
|
||||
summary: a.summary,
|
||||
user: a.user?.name ?? null,
|
||||
createdAt: a.createdAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@Module({ controllers: [DashboardController] })
|
||||
export class DashboardModule {}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { env } from "./config/env.js";
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import type { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import cookieParser from "cookie-parser";
|
||||
import helmet from "helmet";
|
||||
import { AppModule } from "./app.module.js";
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||
|
||||
// behind nginx: take the client IP from X-Forwarded-For (rate limiting, contact form)
|
||||
app.set("trust proxy", env.TRUST_PROXY);
|
||||
app.setGlobalPrefix("api");
|
||||
app.use(helmet());
|
||||
app.use(cookieParser());
|
||||
|
||||
const origins = env.CORS_ORIGINS.split(",")
|
||||
.map((o) => o.trim())
|
||||
.filter(Boolean);
|
||||
if (origins.length) app.enableCors({ origin: origins, credentials: true });
|
||||
|
||||
app.enableShutdownHooks();
|
||||
await app.listen(env.PORT);
|
||||
}
|
||||
|
||||
await bootstrap();
|
||||
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
Module,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
} from "@nestjs/common";
|
||||
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 { IdPipe, ZodPipe } from "../common/zod.pipe.js";
|
||||
import { MediaService } from "./media.service.js";
|
||||
|
||||
const listQuery = z.object({
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).default(40),
|
||||
q: z.string().trim().max(100).optional(),
|
||||
});
|
||||
|
||||
@AdminAuth()
|
||||
@Controller("admin/media")
|
||||
export class MediaController {
|
||||
constructor(private readonly media: MediaService) {}
|
||||
|
||||
@Get()
|
||||
list(@Query(new ZodPipe(listQuery)) q: z.output<typeof listQuery>) {
|
||||
return this.media.list(q.page, q.pageSize, q.q);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseInterceptors(FileInterceptor("file", { limits: { fileSize: 15 * 1024 * 1024 } }))
|
||||
upload(
|
||||
@UploadedFile() file: Express.Multer.File | undefined,
|
||||
@Body("alt") alt: unknown,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.media.upload(file, typeof alt === "string" ? alt : "", user.id);
|
||||
}
|
||||
|
||||
@Patch(":id")
|
||||
update(
|
||||
@Param("id", IdPipe) id: number,
|
||||
@Body(new ZodPipe(mediaUpdateSchema)) body: z.output<typeof mediaUpdateSchema>,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.media.updateAlt(id, body.alt, user.id);
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
@HttpCode(204)
|
||||
async remove(@Param("id", IdPipe) id: number, @CurrentUser() user: AuthUser) {
|
||||
await this.media.remove(id, user.id);
|
||||
}
|
||||
}
|
||||
|
||||
@Module({
|
||||
controllers: [MediaController],
|
||||
providers: [MediaService],
|
||||
exports: [MediaService],
|
||||
})
|
||||
export class MediaModule {}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
type OnModuleInit,
|
||||
} from "@nestjs/common";
|
||||
import type { MediaDto, Paginated } from "@imen/shared";
|
||||
import { env } from "../config/env.js";
|
||||
import { AuditService, RevalidateService } from "../common/common.module.js";
|
||||
import type { Media } from "../generated/prisma/client.js";
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { ACCEPTED_IMAGE_TYPES, S3Storage, toWebp } from "./s3-storage.js";
|
||||
|
||||
@Injectable()
|
||||
export class MediaService implements OnModuleInit {
|
||||
private readonly logger = new Logger(MediaService.name);
|
||||
readonly storage = new S3Storage();
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly audit: AuditService,
|
||||
private readonly revalidate: RevalidateService,
|
||||
) {}
|
||||
|
||||
async onModuleInit() {
|
||||
if (!env.S3_ENSURE_BUCKET) return;
|
||||
try {
|
||||
await this.storage.ensureBucket();
|
||||
} catch (e) {
|
||||
this.logger.error(`could not prepare bucket "${env.S3_BUCKET}": ${String(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
url(key: string) {
|
||||
return this.storage.publicUrl(key);
|
||||
}
|
||||
|
||||
toDto(m: Media): MediaDto {
|
||||
return {
|
||||
id: m.id,
|
||||
url: this.url(m.key),
|
||||
alt: m.alt,
|
||||
mime: m.mime,
|
||||
size: m.size,
|
||||
width: m.width,
|
||||
height: m.height,
|
||||
originalName: m.originalName,
|
||||
createdAt: m.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async list(page: number, pageSize: number, q?: string): Promise<Paginated<MediaDto>> {
|
||||
const where = q
|
||||
? {
|
||||
OR: [
|
||||
{ originalName: { contains: q, mode: "insensitive" as const } },
|
||||
{ alt: { contains: q, mode: "insensitive" as const } },
|
||||
],
|
||||
}
|
||||
: {};
|
||||
const [items, total] = await this.prisma.$transaction([
|
||||
this.prisma.media.findMany({
|
||||
where,
|
||||
orderBy: { id: "desc" },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.media.count({ where }),
|
||||
]);
|
||||
return { items: items.map((m) => this.toDto(m)), total, page, pageSize };
|
||||
}
|
||||
|
||||
async upload(file: Express.Multer.File | undefined, alt: string, userId: number) {
|
||||
if (!file) throw new BadRequestException("فایلی ارسال نشده است");
|
||||
if (!ACCEPTED_IMAGE_TYPES.includes(file.mimetype)) {
|
||||
throw new BadRequestException("فقط تصاویر JPG، PNG، WebP، AVIF و GIF پذیرفته میشوند");
|
||||
}
|
||||
|
||||
let image: Awaited<ReturnType<typeof toWebp>>;
|
||||
try {
|
||||
image = await toWebp(file.buffer);
|
||||
} catch {
|
||||
throw new BadRequestException("فایل تصویر قابل پردازش نیست");
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||||
const key = `uploads/${now.getFullYear()}/${month}/${randomUUID()}.webp`;
|
||||
await this.storage.put(key, image.data, "image/webp");
|
||||
|
||||
const media = await this.prisma.media.create({
|
||||
data: {
|
||||
key,
|
||||
mime: "image/webp",
|
||||
size: image.size,
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
alt: alt.trim().slice(0, 200),
|
||||
// multer decodes the filename as latin1
|
||||
originalName: Buffer.from(file.originalname, "latin1").toString("utf8").slice(0, 200),
|
||||
},
|
||||
});
|
||||
this.audit.log(userId, "upload", "media", media.id, media.originalName);
|
||||
return this.toDto(media);
|
||||
}
|
||||
|
||||
async updateAlt(id: number, alt: string, userId: number) {
|
||||
const media = await this.prisma.media.update({ where: { id }, data: { alt } });
|
||||
this.audit.log(userId, "update", "media", id, media.originalName);
|
||||
this.revalidate.trigger();
|
||||
return this.toDto(media);
|
||||
}
|
||||
|
||||
async remove(id: number, userId: number) {
|
||||
const media = await this.prisma.media.findUnique({
|
||||
where: { id },
|
||||
include: { _count: { select: { categoryCovers: true, productImages: true, productGallery: true } } },
|
||||
});
|
||||
if (!media) throw new NotFoundException("فایل پیدا نشد");
|
||||
|
||||
const { categoryCovers, productImages, productGallery } = media._count;
|
||||
const sections = await this.prisma.$queryRaw<{ key: string }[]>`
|
||||
SELECT key FROM "SiteSection" WHERE data::text LIKE ${"%" + media.key + "%"}`;
|
||||
if (categoryCovers + productImages + productGallery + sections.length > 0) {
|
||||
throw new ConflictException(
|
||||
"این تصویر در محصولات، دستهها یا بخشهای سایت استفاده شده و قابل حذف نیست",
|
||||
);
|
||||
}
|
||||
|
||||
await this.prisma.media.delete({ where: { id } });
|
||||
await this.storage.remove(media.key).catch((e: unknown) => {
|
||||
this.logger.warn(`could not delete object ${media.key}: ${String(e)}`);
|
||||
});
|
||||
this.audit.log(userId, "delete", "media", id, media.originalName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import {
|
||||
CreateBucketCommand,
|
||||
DeleteObjectCommand,
|
||||
HeadBucketCommand,
|
||||
PutBucketPolicyCommand,
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
} from "@aws-sdk/client-s3";
|
||||
import sharp from "sharp";
|
||||
import { env } from "../config/env.js";
|
||||
|
||||
/**
|
||||
* Thin wrapper over any S3-compatible storage (MinIO, ArvanCloud Object Storage, AWS ...).
|
||||
* Plain class (no Nest decorators) so the seed script can use it too.
|
||||
*/
|
||||
export class S3Storage {
|
||||
private readonly client = new S3Client({
|
||||
endpoint: env.S3_ENDPOINT,
|
||||
region: env.S3_REGION,
|
||||
forcePathStyle: env.S3_FORCE_PATH_STYLE,
|
||||
credentials: { accessKeyId: env.S3_ACCESS_KEY, secretAccessKey: env.S3_SECRET_KEY },
|
||||
});
|
||||
private readonly bucket = env.S3_BUCKET;
|
||||
|
||||
publicUrl(key: string) {
|
||||
return `${env.MEDIA_PUBLIC_URL}/${key}`;
|
||||
}
|
||||
|
||||
async put(key: string, body: Buffer, contentType: string) {
|
||||
await this.client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: key,
|
||||
Body: body,
|
||||
ContentType: contentType,
|
||||
// keys are unique per upload, so files never change
|
||||
CacheControl: "public, max-age=31536000, immutable",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async remove(key: string) {
|
||||
await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: key }));
|
||||
}
|
||||
|
||||
/** Creates the bucket if needed and allows anonymous reads (used with MinIO) */
|
||||
async ensureBucket() {
|
||||
try {
|
||||
await this.client.send(new HeadBucketCommand({ Bucket: this.bucket }));
|
||||
} 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}/*`],
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const ACCEPTED_IMAGE_TYPES = [
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
"image/avif",
|
||||
"image/gif",
|
||||
];
|
||||
|
||||
/** Normalizes an uploaded image: fixes EXIF rotation, caps the size and converts to WebP */
|
||||
export async function toWebp(input: Buffer) {
|
||||
const { data, info } = await sharp(input)
|
||||
.rotate()
|
||||
.resize({ width: 2000, height: 2000, fit: "inside", withoutEnlargement: true })
|
||||
.webp({ quality: 82 })
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
return { data, width: info.width, height: info.height, size: info.size };
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Global, Injectable, Module, type OnModuleDestroy } from "@nestjs/common";
|
||||
import { PrismaPg } from "@prisma/adapter-pg";
|
||||
import { env } from "../config/env.js";
|
||||
import { PrismaClient } from "../generated/prisma/client.js";
|
||||
|
||||
export const createPrismaClient = () =>
|
||||
new PrismaClient({ adapter: new PrismaPg({ connectionString: env.DATABASE_URL }) });
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient implements OnModuleDestroy {
|
||||
constructor() {
|
||||
super({ adapter: new PrismaPg({ connectionString: env.DATABASE_URL }) });
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
@Global()
|
||||
@Module({ providers: [PrismaService], exports: [PrismaService] })
|
||||
export class PrismaModule {}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { Controller, Get, Injectable, Module, NotFoundException, Param } from "@nestjs/common";
|
||||
import type {
|
||||
PublicCategory,
|
||||
PublicCategorySummary,
|
||||
PublicProduct,
|
||||
PublicSite,
|
||||
} from "@imen/shared";
|
||||
import { MediaModule } from "../media/media.controller.js";
|
||||
import { MediaService } from "../media/media.service.js";
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { SectionsModule, SectionsService } from "../sections/sections.module.js";
|
||||
|
||||
/** Read-only data for the website. Only published categories and products are exposed. */
|
||||
@Injectable()
|
||||
export class PublicService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly media: MediaService,
|
||||
private readonly sections: SectionsService,
|
||||
) {}
|
||||
|
||||
async site(): Promise<PublicSite> {
|
||||
const [sections, categories] = await Promise.all([this.sections.getAll(), this.categories()]);
|
||||
return { sections, categories };
|
||||
}
|
||||
|
||||
async categories(): Promise<PublicCategorySummary[]> {
|
||||
const rows = await this.prisma.category.findMany({
|
||||
where: { isPublished: true },
|
||||
orderBy: [{ sortOrder: "asc" }, { id: "asc" }],
|
||||
include: {
|
||||
coverImage: true,
|
||||
_count: { select: { products: { where: { isPublished: true } } } },
|
||||
},
|
||||
});
|
||||
return rows.map((c) => ({
|
||||
slug: c.slug,
|
||||
code: c.code,
|
||||
name: c.name,
|
||||
nameEn: c.nameEn,
|
||||
shortDesc: c.shortDesc,
|
||||
icon: c.icon,
|
||||
coverImage: c.coverImage && this.media.url(c.coverImage.key),
|
||||
productCount: c._count.products,
|
||||
}));
|
||||
}
|
||||
|
||||
async category(slug: string): Promise<PublicCategory> {
|
||||
const c = await this.prisma.category.findFirst({
|
||||
where: { slug, isPublished: true },
|
||||
include: {
|
||||
coverImage: true,
|
||||
groups: { orderBy: { sortOrder: "asc" } },
|
||||
products: {
|
||||
where: { isPublished: true },
|
||||
orderBy: [{ sortOrder: "asc" }, { id: "asc" }],
|
||||
include: { image: true, group: { select: { slug: true } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!c) throw new NotFoundException("دستهبندی یافت نشد");
|
||||
|
||||
const products: PublicProduct[] = c.products.map((p) => ({
|
||||
code: p.code,
|
||||
slug: p.slug,
|
||||
name: p.name,
|
||||
nameEn: p.nameEn,
|
||||
desc: p.desc,
|
||||
specs: p.specs,
|
||||
variants: p.variants,
|
||||
image: p.image && this.media.url(p.image.key),
|
||||
group: p.group?.slug ?? null,
|
||||
}));
|
||||
// hide groups that have no published products
|
||||
const usedGroups = new Set(products.map((p) => p.group));
|
||||
|
||||
return {
|
||||
slug: c.slug,
|
||||
code: c.code,
|
||||
name: c.name,
|
||||
nameEn: c.nameEn,
|
||||
shortDesc: c.shortDesc,
|
||||
fullDesc: c.fullDesc,
|
||||
icon: c.icon,
|
||||
seoTitle: c.seoTitle,
|
||||
seoDescription: c.seoDescription,
|
||||
coverImage: c.coverImage && this.media.url(c.coverImage.key),
|
||||
productCount: products.length,
|
||||
groups: c.groups
|
||||
.filter((g) => usedGroups.has(g.slug))
|
||||
.map((g) => ({ slug: g.slug, name: g.name, desc: g.desc })),
|
||||
products,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@Controller("public")
|
||||
export class PublicController {
|
||||
constructor(private readonly publicData: PublicService) {}
|
||||
|
||||
@Get("site")
|
||||
site() {
|
||||
return this.publicData.site();
|
||||
}
|
||||
|
||||
@Get("categories")
|
||||
categories() {
|
||||
return this.publicData.categories();
|
||||
}
|
||||
|
||||
@Get("categories/:slug")
|
||||
category(@Param("slug") slug: string) {
|
||||
return this.publicData.category(slug);
|
||||
}
|
||||
}
|
||||
|
||||
@Module({
|
||||
imports: [MediaModule, SectionsModule],
|
||||
controllers: [PublicController],
|
||||
providers: [PublicService],
|
||||
})
|
||||
export class PublicModule {}
|
||||
@@ -0,0 +1,101 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
Injectable,
|
||||
Module,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Put,
|
||||
ServiceUnavailableException,
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
isSectionKey,
|
||||
SECTION_KEYS,
|
||||
sectionSchemas,
|
||||
type SiteSections,
|
||||
toIssues,
|
||||
} from "@imen/shared";
|
||||
import { AdminAuth, type AuthUser, CurrentUser } from "../common/auth.js";
|
||||
import { AuditService, RevalidateService } from "../common/common.module.js";
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
|
||||
@Injectable()
|
||||
export class SectionsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly audit: AuditService,
|
||||
private readonly revalidate: RevalidateService,
|
||||
) {}
|
||||
|
||||
/** All sections, parsed through their schemas so missing optional fields get defaults */
|
||||
async getAll(): Promise<SiteSections> {
|
||||
const rows = await this.prisma.siteSection.findMany();
|
||||
const byKey = new Map(rows.map((r) => [r.key, r.data]));
|
||||
const missing = SECTION_KEYS.filter((k) => !byKey.has(k));
|
||||
if (missing.length) {
|
||||
throw new ServiceUnavailableException(
|
||||
`محتوای سایت مقداردهی نشده است (${missing.join(", ")}) — pnpm db:seed را اجرا کنید`,
|
||||
);
|
||||
}
|
||||
return Object.fromEntries(
|
||||
SECTION_KEYS.map((k) => [k, sectionSchemas[k].parse(byKey.get(k))]),
|
||||
) as SiteSections;
|
||||
}
|
||||
|
||||
async get(key: string) {
|
||||
if (!isSectionKey(key)) throw new NotFoundException("بخش پیدا نشد");
|
||||
const row = await this.prisma.siteSection.findUnique({ where: { key } });
|
||||
if (!row) throw new NotFoundException("بخش پیدا نشد");
|
||||
return { key, data: sectionSchemas[key].parse(row.data), updatedAt: row.updatedAt };
|
||||
}
|
||||
|
||||
async update(key: string, body: unknown, user: AuthUser) {
|
||||
if (!isSectionKey(key)) throw new NotFoundException("بخش پیدا نشد");
|
||||
// site-wide settings (contact info, SEO defaults) are limited to admins
|
||||
if (key === "settings" && user.role !== "ADMIN") {
|
||||
throw new ForbiddenException("فقط مدیر کل میتواند تنظیمات سایت را تغییر دهد");
|
||||
}
|
||||
const parsed = sectionSchemas[key].safeParse(body);
|
||||
if (!parsed.success) {
|
||||
throw new BadRequestException({
|
||||
statusCode: 400,
|
||||
message: "اطلاعات واردشده معتبر نیست",
|
||||
issues: toIssues(parsed.error),
|
||||
});
|
||||
}
|
||||
const row = await this.prisma.siteSection.upsert({
|
||||
where: { key },
|
||||
create: { key, data: parsed.data, updatedById: user.id },
|
||||
update: { data: parsed.data, updatedById: user.id },
|
||||
});
|
||||
this.audit.log(user.id, "update", "section", key, key);
|
||||
this.revalidate.trigger();
|
||||
return { key, data: parsed.data, updatedAt: row.updatedAt };
|
||||
}
|
||||
}
|
||||
|
||||
@AdminAuth()
|
||||
@Controller("admin/sections")
|
||||
export class SectionsController {
|
||||
constructor(private readonly sections: SectionsService) {}
|
||||
|
||||
@Get(":key")
|
||||
get(@Param("key") key: string) {
|
||||
return this.sections.get(key);
|
||||
}
|
||||
|
||||
@Put(":key")
|
||||
update(@Param("key") key: string, @Body() body: unknown, @CurrentUser() user: AuthUser) {
|
||||
return this.sections.update(key, body, user);
|
||||
}
|
||||
}
|
||||
|
||||
@Module({
|
||||
controllers: [SectionsController],
|
||||
providers: [SectionsService],
|
||||
exports: [SectionsService],
|
||||
})
|
||||
export class SectionsModule {}
|
||||
@@ -0,0 +1,954 @@
|
||||
/**
|
||||
* Initial catalog, imported by the seed script. After seeding, the database (admin panel) is the
|
||||
* source of truth — edits here only affect a fresh database.
|
||||
* `image` paths are relative to apps/api/seed-assets ("/images/" prefix is stripped).
|
||||
*/
|
||||
export interface Product {
|
||||
code: string;
|
||||
name: string;
|
||||
en: string;
|
||||
desc: string;
|
||||
specs: string[];
|
||||
variants?: string[];
|
||||
image?: string;
|
||||
/** slug of a `Category.groups` entry */
|
||||
group?: string;
|
||||
}
|
||||
|
||||
export interface ProductGroup {
|
||||
slug: string;
|
||||
name: string;
|
||||
desc: string;
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
slug: string;
|
||||
code: string;
|
||||
name: string;
|
||||
en: string;
|
||||
shortDesc: string;
|
||||
fullDesc: string;
|
||||
icon: string;
|
||||
groups?: ProductGroup[];
|
||||
products: Product[];
|
||||
}
|
||||
|
||||
export const CATEGORIES: readonly Category[] = [
|
||||
{
|
||||
slug: "workwear",
|
||||
code: "IP-C01",
|
||||
name: "پوشاک کار و البسه صنعتی",
|
||||
en: "INDUSTRIAL WORKWEAR",
|
||||
shortDesc:
|
||||
"لباسکار صنعتی با پارچه فلامنت کجراه اعلا، دوخت سه سوزنه و آستر عرقگیر؛ مناسب محیطهای کارگاهی و صنعتی.",
|
||||
fullDesc:
|
||||
"مجموعه کامل پوشاک کار صنعتی شامل کاپشن و شلوار مهندسی، لباس جوشکاری، پیشبند دوبنده و تیشرت کار. تمامی مدلها با پارچه فلامنت کجراه اعلا و کتان مقاوم، دوخت صنعتی سه سوزنه پیوسته برای جلوگیری از شکافتگی درزها و آستر عرقگیر اختصاصی تولید میشوند. موجود در سایزهای M تا 4XL.",
|
||||
icon: "Shirt",
|
||||
products: [
|
||||
{
|
||||
code: "IP-W01",
|
||||
name: "لباسکار مدل آلفا",
|
||||
en: "ALPHA WORKWEAR",
|
||||
desc: "کاپشن و شلوار مهندسی با جیبهای پاکتی و زیپدار، نوار مغزی تزئینی و طراحی ارگونومیک برای تیمهای فنی و مهندسی.",
|
||||
specs: ["فلامنت کجراه اعلا", "دوخت سه سوزنه", "آستر عرقگیر", "سایز M تا 4XL"],
|
||||
variants: [
|
||||
"طوسی/آبی",
|
||||
"مشکی/قرمز",
|
||||
"مشکی/زرد",
|
||||
"کرم/سرمهای",
|
||||
],
|
||||
},
|
||||
{
|
||||
code: "IP-W02",
|
||||
name: "لباسکار مدل اورجینال",
|
||||
en: "ORIGINAL WORKWEAR",
|
||||
desc: "کاپشن و شلوار استاندارد با دوام بالا، مناسب کادر فنی کارخانجات و خطوط تولید با ترکیبهای صنعتی دورنگ.",
|
||||
specs: ["کتان مقاوم", "دوخت سه سوزنه", "آستر عرقگیر", "سایز M تا 4XL"],
|
||||
variants: ["ترکیبهای صنعتی دورنگ"],
|
||||
},
|
||||
{
|
||||
code: "IP-W03",
|
||||
name: "لباسکار مدل بوفالو",
|
||||
en: "BUFFALO WORKWEAR",
|
||||
desc: "طراحی اسپرت با برشهای تقویتی روی شانه و بازو، زیپ مخفی محافظدار؛ مناسب محیطهای صنعتی فعال.",
|
||||
specs: ["فلامنت کجراه اعلا", "زیپ مخفی محافظدار", "برش تقویتی شانه", "سایز M تا 4XL"],
|
||||
variants: [
|
||||
"سرمهای/زرد",
|
||||
"طوسی/قرمز",
|
||||
"طوسی/خردلی",
|
||||
"آبی/مشکی",
|
||||
],
|
||||
},
|
||||
{
|
||||
code: "IP-W04",
|
||||
name: "لباسکار ست ورک",
|
||||
en: "SET WORK",
|
||||
desc: "ست اداری/کارگاهی با انعطاف بالا جهت فعالیتهای فنی و تعمیراتی؛ قابل استفاده در محیطهای ستادی و عملیاتی.",
|
||||
specs: ["کتان مقاوم", "دوخت سه سوزنه", "طراحی منعطف", "سایز M تا 4XL"],
|
||||
variants: ["ترکیبهای چندگانه کارگاهی"],
|
||||
},
|
||||
{
|
||||
code: "IP-W05",
|
||||
name: "لباسکار جوشکاری",
|
||||
en: "WELDING WORKWEAR",
|
||||
desc: "متریال سنگین کتان یا چرم جهت مقاومت در برابر پاشش جرقههای مذاب؛ طراحیشده برای جوشکاران صنعتی.",
|
||||
specs: ["مقاوم به جرقه مذاب", "کتان سنگین / چرم", "دوخت صنعتی", "سایز M تا 4XL"],
|
||||
},
|
||||
{
|
||||
code: "IP-W06",
|
||||
name: "پیشبند دوبنده",
|
||||
en: "BIB OVERALLS",
|
||||
desc: "آزادی عمل کامل کتف و شانه با جیبهای چندمنظوره ابزار بر روی پیشسینه؛ ایدهآل برای کارهای تعمیراتی و مونتاژ.",
|
||||
specs: ["آزادی حرکت شانه", "جیب ابزار پیشسینه", "دوخت سه سوزنه", "سایز M تا 4XL"],
|
||||
variants: ["سرمهای", "خاکستری"],
|
||||
},
|
||||
{
|
||||
code: "IP-W07",
|
||||
name: "تیشرت صنعتی",
|
||||
en: "INDUSTRIAL T-SHIRT",
|
||||
desc: "تیشرت یقهدار جودون / پنبهای مناسب فصول گرم و زیرپوش کاپشن کارگاهی در سولههای سرپوشیده.",
|
||||
specs: ["پنبه / جودون", "یقهدار", "مناسب فصول گرم", "سایز M تا 4XL"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "safety-footwear",
|
||||
code: "IP-C02",
|
||||
name: "پایپوش و کفش ایمنی",
|
||||
en: "SAFETY FOOTWEAR",
|
||||
shortDesc:
|
||||
"کفشهای اداری و کفشهای ایمنی کارگری؛ راحت و شکیل برای محیط اداری، مقاوم و ضد لغزش برای کارگاه.",
|
||||
fullDesc:
|
||||
"پایپوش ایمنی شامل کفشهای اداری برای محیطهای اداری و سبک صنعتی و کفشهای ایمنی کارگری برای کارگاهها و پروژههای ساختمانی؛ با زیره مقاوم و مواد مقاوم در برابر سایش و مواد شیمیایی سبک.",
|
||||
icon: "Footprints",
|
||||
groups: [
|
||||
{ slug: "office", name: "کفش اداری", desc: "مناسب محیطهای اداری و سبک صنعتی، راحت، سبک و شکیل با زیره مقاوم." },
|
||||
{ slug: "worker", name: "کفش ایمنی کارگری", desc: "مناسب کارگاهها و پروژههای ساختمانی، مقاوم در برابر سایش و مواد شیمیایی سبک." },
|
||||
],
|
||||
products: [
|
||||
{
|
||||
code: "IP-F01",
|
||||
group: "office",
|
||||
name: "کفش اداری مدل ۱۱۷۴",
|
||||
en: "OFFICE SHOE 1174",
|
||||
desc: "مناسب محیطهای اداری و سبک صنعتی، راحت، سبک و شکیل با زیره مقاوم.",
|
||||
specs: ["چرم نبوک", "بنددار", "زیره مقاوم"],
|
||||
image: "/images/products/shoes/office/1.webp",
|
||||
},
|
||||
{
|
||||
code: "IP-F02",
|
||||
group: "office",
|
||||
name: "کفش اداری بیبند مدل ۶",
|
||||
en: "SLIP-ON OFFICE SHOE",
|
||||
desc: "مناسب محیطهای اداری و سبک صنعتی، راحت، سبک و شکیل با زیره مقاوم.",
|
||||
specs: ["چرم طبیعی", "بدون بند", "زیره مقاوم"],
|
||||
image: "/images/products/shoes/office/2.webp",
|
||||
},
|
||||
{
|
||||
code: "IP-F03",
|
||||
group: "office",
|
||||
name: "کفش اداری بنددار مشکی",
|
||||
en: "LACE-UP OFFICE SHOE",
|
||||
desc: "مناسب محیطهای اداری و سبک صنعتی، راحت، سبک و شکیل با زیره مقاوم.",
|
||||
specs: ["چرم طبیعی", "بنددار", "زیره مقاوم"],
|
||||
image: "/images/products/shoes/office/3.webp",
|
||||
},
|
||||
{
|
||||
code: "IP-F04",
|
||||
group: "office",
|
||||
name: "کفش اداری بیبند AAEA075",
|
||||
en: "SLIP-ON OFFICE SHOE AAEA075",
|
||||
desc: "مناسب محیطهای اداری و سبک صنعتی، راحت، سبک و شکیل با زیره مقاوم.",
|
||||
specs: ["چرم طبیعی", "بدون بند", "زیره مقاوم"],
|
||||
image: "/images/products/shoes/office/4.webp",
|
||||
},
|
||||
{
|
||||
code: "IP-F05",
|
||||
group: "office",
|
||||
name: "کفش اداری شبرو",
|
||||
en: "SHEBRO OFFICE SHOE",
|
||||
desc: "مناسب محیطهای اداری و سبک صنعتی، راحت، سبک و شکیل با زیره مقاوم.",
|
||||
specs: ["چرم طبیعی", "بنددار", "زیره مقاوم"],
|
||||
image: "/images/products/shoes/office/5.webp",
|
||||
},
|
||||
{
|
||||
code: "IP-F06",
|
||||
group: "office",
|
||||
name: "کفش اداری بیبند کلاسیک",
|
||||
en: "CLASSIC SLIP-ON OFFICE SHOE",
|
||||
desc: "مناسب محیطهای اداری و سبک صنعتی، راحت، سبک و شکیل با زیره مقاوم.",
|
||||
specs: ["چرم طبیعی", "بدون بند", "زیره مقاوم"],
|
||||
image: "/images/products/shoes/office/6.webp",
|
||||
},
|
||||
{
|
||||
code: "IP-F07",
|
||||
group: "office",
|
||||
name: "کفش اداری کالج",
|
||||
en: "LOAFER OFFICE SHOE",
|
||||
desc: "مناسب محیطهای اداری و سبک صنعتی، راحت، سبک و شکیل با زیره مقاوم.",
|
||||
specs: ["چرم طبیعی", "کالج", "زیره مقاوم"],
|
||||
image: "/images/products/shoes/office/7.webp",
|
||||
},
|
||||
{
|
||||
code: "IP-F08",
|
||||
group: "office",
|
||||
name: "کفش اداری دیپلمات",
|
||||
en: "DIPLOMAT OFFICE SHOE",
|
||||
desc: "مناسب محیطهای اداری و سبک صنعتی، راحت، سبک و شکیل با زیره مقاوم.",
|
||||
specs: ["چرم طبیعی", "بنددار", "طراحی رسمی"],
|
||||
image: "/images/products/shoes/office/8.webp",
|
||||
},
|
||||
{
|
||||
code: "IP-F09",
|
||||
group: "worker",
|
||||
name: "کفش ایمنی کارگری مدل ۱",
|
||||
en: "WORKER SAFETY SHOE",
|
||||
desc: "مناسب کارگاهها و پروژههای ساختمانی، مقاوم در برابر سایش و مواد شیمیایی سبک.",
|
||||
specs: ["مقاوم به سایش", "زیره ضد لغزش"],
|
||||
image: "/images/products/shoes/worker/1.png",
|
||||
},
|
||||
{
|
||||
code: "IP-F10",
|
||||
group: "worker",
|
||||
name: "کفش ایمنی کارگری مدل ۲",
|
||||
en: "WORKER SAFETY SHOE",
|
||||
desc: "مناسب کارگاهها و پروژههای ساختمانی، مقاوم در برابر سایش و مواد شیمیایی سبک.",
|
||||
specs: ["مقاوم به سایش", "زیره ضد لغزش"],
|
||||
image: "/images/products/shoes/worker/2.png",
|
||||
},
|
||||
{
|
||||
code: "IP-F11",
|
||||
group: "worker",
|
||||
name: "کفش ایمنی کارگری مدل ۹۰",
|
||||
en: "WORKER SAFETY SHOE 90",
|
||||
desc: "مناسب کارگاهها و پروژههای ساختمانی، مقاوم در برابر سایش و مواد شیمیایی سبک.",
|
||||
specs: ["مقاوم به سایش", "زیره ضد لغزش"],
|
||||
image: "/images/products/shoes/worker/3.png",
|
||||
},
|
||||
{
|
||||
code: "IP-F12",
|
||||
group: "worker",
|
||||
name: "کفش ایمنی پاتن پدیده تبریز مدل رایکا S1017",
|
||||
en: "PATEN PADIDEH RAYKA S1017",
|
||||
desc: "مناسب کارگاهها و پروژههای ساختمانی، مقاوم در برابر سایش و مواد شیمیایی سبک.",
|
||||
specs: ["پاتن پدیده تبریز", "مقاوم به سایش", "زیره ضد لغزش"],
|
||||
image: "/images/products/shoes/worker/4.png",
|
||||
},
|
||||
{
|
||||
code: "IP-F13",
|
||||
group: "worker",
|
||||
name: "کفش ایمنی پیما مدل دنا",
|
||||
en: "PAYMA DENA SAFETY SHOE",
|
||||
desc: "مناسب کارگاهها و پروژههای ساختمانی، مقاوم در برابر سایش و مواد شیمیایی سبک.",
|
||||
specs: ["پیما", "مقاوم به سایش", "زیره ضد لغزش"],
|
||||
image: "/images/products/shoes/worker/5.png",
|
||||
},
|
||||
{
|
||||
code: "IP-F14",
|
||||
group: "worker",
|
||||
name: "کفش کار کارگری ساقدار",
|
||||
en: "WORKER SAFETY BOOT",
|
||||
desc: "مناسب کارگاهها و پروژههای ساختمانی، مقاوم در برابر سایش و مواد شیمیایی سبک.",
|
||||
specs: ["ساقدار", "مقاوم به سایش", "زیره ضد لغزش"],
|
||||
image: "/images/products/shoes/worker/6.png",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "protective-gloves",
|
||||
code: "IP-C03",
|
||||
name: "دستکشهای حفاظتی",
|
||||
en: "PROTECTIVE GLOVES",
|
||||
shortDesc:
|
||||
"دستکشهای ضد برش، چرم، ضد اسید و ضد حلال برای کارهای سنگین صنعتی، جوشکاری و تماس با مواد شیمیایی.",
|
||||
fullDesc:
|
||||
"دستکشهای حفاظت فردی شامل مدلهای ضد برش، چرم، ضد اسید و ضد حلال؛ از برندهای استادکار، امریکن سیفتی، ماکسی درای و NM Safety. هر مدل متناسب با نوع خطر محیط کار انتخاب شده است.",
|
||||
icon: "Hand",
|
||||
groups: [
|
||||
{ slug: "cut", name: "دستکش ضد برش", desc: "ایمن در برابر تیغهها و فلزات تیز." },
|
||||
{ slug: "leather", name: "دستکش چرم", desc: "محافظ دست در برابر حرارت، جرقه و سایش در کارهای سنگین و جوشکاری." },
|
||||
{ slug: "acid", name: "دستکش ضد اسید", desc: "محافظت کامل در برابر اسیدها و بازها." },
|
||||
{ slug: "solvent", name: "دستکش ضد حلال", desc: "مقاومت بالا در برابر تماس با مواد شیمیایی." },
|
||||
],
|
||||
products: [
|
||||
{
|
||||
code: "IP-G01",
|
||||
group: "cut",
|
||||
name: "دستکش ضد برش لاتکسی",
|
||||
en: "LATEX CUT-RESISTANT GLOVE",
|
||||
desc: "ایمن در برابر تیغهها و فلزات تیز.",
|
||||
specs: ["ضد برش", "پوشش لاتکس"],
|
||||
image: "/images/products/gloves/cut/1.webp",
|
||||
},
|
||||
{
|
||||
code: "IP-G02",
|
||||
group: "cut",
|
||||
name: "دستکش ضد برش مدل ۲",
|
||||
en: "CUT-RESISTANT GLOVE",
|
||||
desc: "ایمن در برابر تیغهها و فلزات تیز.",
|
||||
specs: ["ضد برش"],
|
||||
image: "/images/products/gloves/cut/2.webp",
|
||||
},
|
||||
{
|
||||
code: "IP-G03",
|
||||
group: "cut",
|
||||
name: "دستکش ضد برش مدل ۳",
|
||||
en: "CUT-RESISTANT GLOVE",
|
||||
desc: "ایمن در برابر تیغهها و فلزات تیز.",
|
||||
specs: ["ضد برش"],
|
||||
image: "/images/products/gloves/cut/3.webp",
|
||||
},
|
||||
{
|
||||
code: "IP-G04",
|
||||
group: "cut",
|
||||
name: "دستکش ضد برش استادکار ۱۲ جفتی",
|
||||
en: "OSTADKAR CUT-RESISTANT GLOVE",
|
||||
desc: "ایمن در برابر تیغهها و فلزات تیز.",
|
||||
specs: ["ضد برش", "استادکار", "بسته ۱۲ جفتی"],
|
||||
image: "/images/products/gloves/cut/4.webp",
|
||||
},
|
||||
{
|
||||
code: "IP-G05",
|
||||
group: "cut",
|
||||
name: "دستکش ضد برش تانگ وانگ",
|
||||
en: "TANG WANG CUT-RESISTANT GLOVE",
|
||||
desc: "ایمن در برابر تیغهها و فلزات تیز.",
|
||||
specs: ["ضد برش", "تانگ وانگ"],
|
||||
image: "/images/products/gloves/cut/5.webp",
|
||||
},
|
||||
{
|
||||
code: "IP-G06",
|
||||
group: "leather",
|
||||
name: "دستکش چرم مدل ۰۷",
|
||||
en: "LEATHER GLOVE",
|
||||
desc: "محافظ دست در برابر حرارت، جرقه و سایش در کارهای سنگین و جوشکاری.",
|
||||
specs: ["چرم"],
|
||||
image: "/images/products/gloves/leather/1.webp",
|
||||
},
|
||||
{
|
||||
code: "IP-G07",
|
||||
group: "leather",
|
||||
name: "دستکش چرم BMW",
|
||||
en: "BMW LEATHER GLOVE",
|
||||
desc: "محافظ دست در برابر حرارت، جرقه و سایش در کارهای سنگین و جوشکاری.",
|
||||
specs: ["چرم", "ساق بلند"],
|
||||
image: "/images/products/gloves/leather/2.webp",
|
||||
},
|
||||
{
|
||||
code: "IP-G08",
|
||||
group: "leather",
|
||||
name: "دستکش کف چرم دوبل ساق پارچهای",
|
||||
en: "DOUBLE-PALM LEATHER GLOVE, FABRIC CUFF",
|
||||
desc: "محافظ دست در برابر حرارت، جرقه و سایش در کارهای سنگین و جوشکاری.",
|
||||
specs: ["کف چرم دوبل", "ساق پارچهای"],
|
||||
image: "/images/products/gloves/leather/3.webp",
|
||||
},
|
||||
{
|
||||
code: "IP-G09",
|
||||
group: "leather",
|
||||
name: "دستکش کف چرم دوبل ساق چرم",
|
||||
en: "DOUBLE-PALM LEATHER GLOVE, LEATHER CUFF",
|
||||
desc: "محافظ دست در برابر حرارت، جرقه و سایش در کارهای سنگین و جوشکاری.",
|
||||
specs: ["کف چرم دوبل", "ساق چرم"],
|
||||
image: "/images/products/gloves/leather/4.webp",
|
||||
},
|
||||
{
|
||||
code: "IP-G10",
|
||||
group: "leather",
|
||||
name: "دستکش هوبارت امریکن سیفتی",
|
||||
en: "AMERICAN SAFETY HOBART GLOVE",
|
||||
desc: "محافظ دست در برابر حرارت، جرقه و سایش در کارهای سنگین و جوشکاری.",
|
||||
specs: ["چرم", "جوشکاری", "EN 388 / EN 407"],
|
||||
image: "/images/products/gloves/leather/5.webp",
|
||||
},
|
||||
{
|
||||
code: "IP-G11",
|
||||
group: "leather",
|
||||
name: "دستکش هوبارت طرح فرانسه",
|
||||
en: "FRENCH-STYLE HOBART GLOVE",
|
||||
desc: "محافظ دست در برابر حرارت، جرقه و سایش در کارهای سنگین و جوشکاری.",
|
||||
specs: ["چرم", "جوشکاری"],
|
||||
image: "/images/products/gloves/leather/6.webp",
|
||||
},
|
||||
{
|
||||
code: "IP-G12",
|
||||
group: "acid",
|
||||
name: "دستکش ضد اسید مدل ۱",
|
||||
en: "ACID-RESISTANT GLOVE",
|
||||
desc: "محافظت کامل در برابر اسیدها و بازها.",
|
||||
specs: ["ضد اسید"],
|
||||
image: "/images/products/gloves/acid/1.webp",
|
||||
},
|
||||
{
|
||||
code: "IP-G13",
|
||||
group: "acid",
|
||||
name: "دستکش ضد اسید مدل ۲",
|
||||
en: "ACID-RESISTANT GLOVE",
|
||||
desc: "محافظت کامل در برابر اسیدها و بازها.",
|
||||
specs: ["ضد اسید"],
|
||||
image: "/images/products/gloves/acid/2.webp",
|
||||
},
|
||||
{
|
||||
code: "IP-G14",
|
||||
group: "acid",
|
||||
name: "دستکش ضد اسید مدل ۳",
|
||||
en: "ACID-RESISTANT GLOVE",
|
||||
desc: "محافظت کامل در برابر اسیدها و بازها.",
|
||||
specs: ["ضد اسید"],
|
||||
image: "/images/products/gloves/acid/3.webp",
|
||||
},
|
||||
{
|
||||
code: "IP-G15",
|
||||
group: "acid",
|
||||
name: "دستکش ضد اسید مقاوم شیمیایی",
|
||||
en: "CHEMICAL-RESISTANT GLOVE",
|
||||
desc: "محافظت کامل در برابر اسیدها و بازها.",
|
||||
specs: ["ضد اسید", "مقاوم شیمیایی", "ساق بلند"],
|
||||
image: "/images/products/gloves/acid/4.webp",
|
||||
},
|
||||
{
|
||||
code: "IP-G16",
|
||||
group: "solvent",
|
||||
name: "دستکش ضد حلال مدل ۱۳",
|
||||
en: "SOLVENT-RESISTANT GLOVE",
|
||||
desc: "مقاومت بالا در برابر تماس با مواد شیمیایی.",
|
||||
specs: ["ضد حلال"],
|
||||
image: "/images/products/gloves/solvent/1.webp",
|
||||
},
|
||||
{
|
||||
code: "IP-G17",
|
||||
group: "solvent",
|
||||
name: "دستکش ماکسی درای پلاس 56-530",
|
||||
en: "MAXIDRY PLUS 56-530",
|
||||
desc: "مقاومت بالا در برابر تماس با مواد شیمیایی.",
|
||||
specs: ["ضد حلال", "MaxiDry Plus"],
|
||||
image: "/images/products/gloves/solvent/2.webp",
|
||||
},
|
||||
{
|
||||
code: "IP-G18",
|
||||
group: "solvent",
|
||||
name: "دستکش ضد حلال نیتریلی",
|
||||
en: "NITRILE SOLVENT-RESISTANT GLOVE",
|
||||
desc: "مقاومت بالا در برابر تماس با مواد شیمیایی.",
|
||||
specs: ["ضد حلال", "نیتریل"],
|
||||
image: "/images/products/gloves/solvent/3.webp",
|
||||
},
|
||||
{
|
||||
code: "IP-G19",
|
||||
group: "solvent",
|
||||
name: "دستکش نیتریل آزمایشگاهی ضد حلال NM Safety",
|
||||
en: "NM SAFETY NITRILE LAB GLOVE",
|
||||
desc: "مقاومت بالا در برابر تماس با مواد شیمیایی.",
|
||||
specs: ["ضد حلال", "نیتریل", "آزمایشگاهی"],
|
||||
image: "/images/products/gloves/solvent/4.webp",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "safety-helmets",
|
||||
code: "IP-C04",
|
||||
name: "کلاه ایمنی",
|
||||
en: "SAFETY HELMETS",
|
||||
shortDesc:
|
||||
"کلاههای ایمنی کارگری، مهندسی، کار در ارتفاع و کپ ایمنی؛ مقاوم در برابر ضربه و سقوط اجسام.",
|
||||
fullDesc:
|
||||
"انواع کلاه ایمنی صنعتی شامل مدلهای کارگری، مهندسی، کار در ارتفاع با بند چانه قابل تنظیم و کپ ایمنی سبک. با پوسته ضربهگیر مقاوم، رگلاژ داخلی قابل تنظیم و تنوع رنگی استاندارد HSE.",
|
||||
icon: "HardHat",
|
||||
groups: [
|
||||
{ slug: "worker", name: "کلاه کارگری", desc: "محافظت از سر در محیطهای صنعتی و کارگاهها، مقاوم در برابر ضربه و سقوط اجسام." },
|
||||
{ slug: "engineering", name: "کلاه مهندسی", desc: "مناسب محیطهای اداری و صنعتی سبک، راحت و شکیل با تهویه مناسب." },
|
||||
{ slug: "height", name: "کار در ارتفاع", desc: "دارای بند چانه قابل تنظیم برای ثبات بیشتر هنگام کار در ارتفاع." },
|
||||
{ slug: "cap", name: "کپ ایمنی", desc: "سبک و راحت، مناسب استفاده روزمره در محیطهای صنعتی و اداری." },
|
||||
],
|
||||
products: [
|
||||
{
|
||||
code: "IP-H01",
|
||||
group: "worker",
|
||||
name: "کلاه ایمنی کارگری آبی",
|
||||
en: "WORKER SAFETY HELMET",
|
||||
desc: "محافظت از سر در محیطهای صنعتی و کارگاهها، مقاوم در برابر ضربه و سقوط اجسام.",
|
||||
specs: ["پوسته ضربهگیر", "رگلاژ قابل تنظیم"],
|
||||
image: "/images/products/helmets/2.webp",
|
||||
},
|
||||
{
|
||||
code: "IP-H02",
|
||||
group: "engineering",
|
||||
name: "کلاه ایمنی مهندسی پادکس",
|
||||
en: "PADEX ENGINEERING HELMET",
|
||||
desc: "مناسب محیطهای اداری و صنعتی سبک، راحت و شکیل با تهویه مناسب.",
|
||||
specs: ["پادکس", "تهویه مناسب", "رگلاژ قابل تنظیم"],
|
||||
image: "/images/products/helmets/11.webp",
|
||||
},
|
||||
{
|
||||
code: "IP-H03",
|
||||
group: "engineering",
|
||||
name: "کلاه ایمنی مهندسی پادکس PADX",
|
||||
en: "PADEX PADX ENGINEERING HELMET",
|
||||
desc: "مناسب محیطهای اداری و صنعتی سبک، راحت و شکیل با تهویه مناسب.",
|
||||
specs: ["پادکس", "تهویه مناسب", "رگلاژ قابل تنظیم"],
|
||||
image: "/images/products/helmets/12.webp",
|
||||
},
|
||||
{
|
||||
code: "IP-H04",
|
||||
group: "engineering",
|
||||
name: "کلاه ایمنی مهندس پادکس (نمای داخلی)",
|
||||
en: "PADEX HELMET HARNESS",
|
||||
desc: "مناسب محیطهای اداری و صنعتی سبک، راحت و شکیل با تهویه مناسب.",
|
||||
specs: ["پادکس", "یراق داخلی", "رگلاژ قابل تنظیم"],
|
||||
image: "/images/products/helmets/10.webp",
|
||||
},
|
||||
{
|
||||
code: "IP-H05",
|
||||
group: "height",
|
||||
name: "کلاه ایمنی کار در ارتفاع با عینک",
|
||||
en: "HEIGHT HELMET WITH VISOR",
|
||||
desc: "دارای بند چانه قابل تنظیم برای ثبات بیشتر هنگام کار در ارتفاع.",
|
||||
specs: ["بند چانه", "عینک داخلی", "رگلاژ قابل تنظیم"],
|
||||
image: "/images/products/helmets/1.webp",
|
||||
},
|
||||
{
|
||||
code: "IP-H06",
|
||||
group: "height",
|
||||
name: "کلاه ایمنی کار در ارتفاع uvex",
|
||||
en: "UVEX HEIGHT HELMET",
|
||||
desc: "دارای بند چانه قابل تنظیم برای ثبات بیشتر هنگام کار در ارتفاع.",
|
||||
specs: ["uvex", "بند چانه", "تهویه مناسب"],
|
||||
image: "/images/products/helmets/3.webp",
|
||||
},
|
||||
{
|
||||
code: "IP-H07",
|
||||
group: "height",
|
||||
name: "کلاه ایمنی کار در ارتفاع سبز",
|
||||
en: "HEIGHT SAFETY HELMET",
|
||||
desc: "دارای بند چانه قابل تنظیم برای ثبات بیشتر هنگام کار در ارتفاع.",
|
||||
specs: ["بند چانه", "رگلاژ قابل تنظیم"],
|
||||
image: "/images/products/helmets/4.webp",
|
||||
},
|
||||
{
|
||||
code: "IP-H08",
|
||||
group: "height",
|
||||
name: "کلاه ایمنی صنعتی MK8",
|
||||
en: "MK8 INDUSTRIAL HELMET",
|
||||
desc: "دارای بند چانه قابل تنظیم برای ثبات بیشتر هنگام کار در ارتفاع.",
|
||||
specs: ["MK8", "بند چانه", "تهویه مناسب"],
|
||||
image: "/images/products/helmets/9.webp",
|
||||
},
|
||||
{
|
||||
code: "IP-H09",
|
||||
group: "cap",
|
||||
name: "کپ ایمنی ضربهگیر",
|
||||
en: "SAFETY BUMP CAP",
|
||||
desc: "سبک و راحت، مناسب استفاده روزمره در محیطهای صنعتی و اداری.",
|
||||
specs: ["نقابدار", "ضربهگیر داخلی", "سبکوزن"],
|
||||
image: "/images/products/helmets/5.webp",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "eye-protection",
|
||||
code: "IP-C05",
|
||||
name: "عینک ایمنی و صنعتی",
|
||||
en: "EYE PROTECTION",
|
||||
shortDesc:
|
||||
"عینکهای شفاف و دودی و گاگلهای محافظتی با ویژگیهای ضد ضربه، ضد خش، ضد بخار و UV.",
|
||||
fullDesc:
|
||||
"عینکهای ایمنی صنعتی شامل عینکهای شفاف، دودی و UV و گاگلهای کشدار از برندهای uvex، توتاص، کاناسیف و تک پلاست. ضد ضربه، ضد خش، ضد بخار و قابل تنظیم برای انواع سایز صورت.",
|
||||
icon: "Glasses",
|
||||
groups: [
|
||||
{ slug: "clear", name: "عینک شفاف", desc: "محافظت از چشمها در برابر برخورد اجسام و ابزار؛ ضد خش با دید واضح." },
|
||||
{ slug: "tinted", name: "عینک دودی و UV", desc: "محافظت در برابر اشعه مضر خورشید و نور شدید." },
|
||||
{ slug: "goggle", name: "گاگل", desc: "ضد بخار و ضد ضربه؛ جلوگیری از ایجاد مه در محیطهای گرم یا مرطوب." },
|
||||
],
|
||||
products: [
|
||||
{
|
||||
code: "IP-E01",
|
||||
group: "clear",
|
||||
name: "عینک ایمنی شفاف دسته قابل تنظیم",
|
||||
en: "ADJUSTABLE CLEAR SAFETY GLASSES",
|
||||
desc: "محافظت از چشمها در برابر برخورد اجسام و ابزار؛ قابل تنظیم برای انواع سایز صورت.",
|
||||
specs: ["ضد ضربه", "قابل تنظیم"],
|
||||
image: "/images/products/eyewear/1.webp",
|
||||
},
|
||||
{
|
||||
code: "IP-E02",
|
||||
group: "clear",
|
||||
name: "عینک ایمنی روطبی",
|
||||
en: "OVER-SPEC SAFETY GLASSES",
|
||||
desc: "محافظت از چشمها در برابر برخورد اجسام و ابزار؛ قابل استفاده روی عینک طبی.",
|
||||
specs: ["ضد ضربه", "روطبی"],
|
||||
image: "/images/products/eyewear/4.webp",
|
||||
},
|
||||
{
|
||||
code: "IP-E03",
|
||||
group: "clear",
|
||||
name: "عینک ایمنی uvex i-5",
|
||||
en: "UVEX I-5 SAFETY GLASSES",
|
||||
desc: "محافظت از چشمها در برابر برخورد اجسام و ابزار؛ ضد خش با دید واضح.",
|
||||
specs: ["uvex", "ضد ضربه", "ضد خش"],
|
||||
image: "/images/products/eyewear/5.webp",
|
||||
},
|
||||
{
|
||||
code: "IP-E04",
|
||||
group: "clear",
|
||||
name: "عینک شیشه سفید تک پلاست",
|
||||
en: "TAK PLAST CLEAR GLASSES",
|
||||
desc: "محافظت از چشمها در برابر برخورد اجسام و ابزار؛ دارای محافظ کناری.",
|
||||
specs: ["تک پلاست", "محافظ کناری"],
|
||||
image: "/images/products/eyewear/11.webp",
|
||||
},
|
||||
{
|
||||
code: "IP-E05",
|
||||
group: "tinted",
|
||||
name: "عینک ایمنی uvex دودی",
|
||||
en: "UVEX TINTED SAFETY GLASSES",
|
||||
desc: "محافظت در برابر اشعه مضر خورشید و نور شدید.",
|
||||
specs: ["uvex", "شیشه UV", "ضد ضربه"],
|
||||
image: "/images/products/eyewear/6.webp",
|
||||
},
|
||||
{
|
||||
code: "IP-E06",
|
||||
group: "tinted",
|
||||
name: "عینک ایمنی توتاص AT113",
|
||||
en: "TOTAS AT113",
|
||||
desc: "محافظت در برابر اشعه مضر خورشید و نور شدید.",
|
||||
specs: ["توتاص", "شیشه UV", "ضد ضربه"],
|
||||
image: "/images/products/eyewear/7.webp",
|
||||
},
|
||||
{
|
||||
code: "IP-E07",
|
||||
group: "tinted",
|
||||
name: "عینک ایمنی کاناسیف 20105",
|
||||
en: "CANASAFE 20105",
|
||||
desc: "محافظت در برابر اشعه مضر و نور شدید جوشکاری.",
|
||||
specs: ["کاناسیف", "شیشه UV", "قابل تنظیم"],
|
||||
image: "/images/products/eyewear/9.webp",
|
||||
},
|
||||
{
|
||||
code: "IP-E08",
|
||||
group: "tinted",
|
||||
name: "عینک توتاص AT116",
|
||||
en: "TOTAS AT116",
|
||||
desc: "محافظت در برابر اشعه مضر خورشید و نور شدید.",
|
||||
specs: ["توتاص", "شیشه UV", "روطبی"],
|
||||
image: "/images/products/eyewear/10.webp",
|
||||
},
|
||||
{
|
||||
code: "IP-E09",
|
||||
group: "goggle",
|
||||
name: "گاگل uvex ultravision",
|
||||
en: "UVEX ULTRAVISION GOGGLE",
|
||||
desc: "ضد بخار و ضد ضربه؛ جلوگیری از ایجاد مه در محیطهای گرم یا مرطوب.",
|
||||
specs: ["uvex", "ضد بخار", "کشدار"],
|
||||
image: "/images/products/eyewear/2.webp",
|
||||
},
|
||||
{
|
||||
code: "IP-E10",
|
||||
group: "goggle",
|
||||
name: "گاگل ایمنی کشدار",
|
||||
en: "SAFETY GOGGLE",
|
||||
desc: "ضد بخار و ضد ضربه؛ جلوگیری از ایجاد مه در محیطهای گرم یا مرطوب.",
|
||||
specs: ["ضد بخار", "کشدار"],
|
||||
image: "/images/products/eyewear/3.webp",
|
||||
},
|
||||
{
|
||||
code: "IP-E11",
|
||||
group: "goggle",
|
||||
name: "گاگل ایمنی توتاص ATBM",
|
||||
en: "TOTAS ATBM GOGGLE",
|
||||
desc: "ضد بخار و ضد ضربه؛ جلوگیری از ایجاد مه در محیطهای گرم یا مرطوب.",
|
||||
specs: ["توتاص", "ضد بخار", "کشدار"],
|
||||
image: "/images/products/eyewear/8.webp",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "hearing-protection",
|
||||
code: "IP-C06",
|
||||
name: "حفاظت شنوایی",
|
||||
en: "HEARING PROTECTION",
|
||||
shortDesc:
|
||||
"ایرماف روگوشی با کاهش ۲۸ دسیبل و ایرپلاگهای اسفنجی و سیلیکونی برای محیطهای پرسروصدا.",
|
||||
fullDesc:
|
||||
"تجهیزات حفاظت شنوایی شامل ایرماف (محافظ روگوشی) با کاهش تراز صدا تا ۲۸ دسیبل و ایرپلاگ (داخلگوشی) در مدلهای اسفنجی فرمگیر و پلهای سیلیکونی. مناسب محیطهای با آلودگی صوتی بالای ۸۵ دسیبل، کمپرسورخانهها و خطوط تولید سنگین.",
|
||||
icon: "Ear",
|
||||
groups: [
|
||||
{ slug: "earmuff", name: "ایرماف", desc: "کاهش صدا تا ۲۸ دسیبل، مناسب محیطهای صنعتی و کارگاهی، قابل تنظیم و راحت برای استفاده طولانی." },
|
||||
{ slug: "earplug", name: "ایرپلاگ", desc: "محافظت در برابر صداهای شدید و نویز محیط، سبک و راحت، مدلهای یکبار مصرف و چندبار مصرف." },
|
||||
],
|
||||
products: [
|
||||
{
|
||||
code: "IP-HP01",
|
||||
group: "earmuff",
|
||||
name: "ایرماف هلبرگ",
|
||||
en: "HELLBERG EARMUFF",
|
||||
desc: "کاهش صدا تا ۲۸ دسیبل، مناسب محیطهای صنعتی و کارگاهی، قابل تنظیم و راحت برای استفاده طولانی.",
|
||||
specs: ["Hellberg", "هدبند قابل تنظیم", "بالشتک نرم"],
|
||||
image: "/images/products/hearing/1.webp",
|
||||
},
|
||||
{
|
||||
code: "IP-HP02",
|
||||
group: "earmuff",
|
||||
name: "ایرماف uvex K2",
|
||||
en: "UVEX K2 EARMUFF",
|
||||
desc: "کاهش صدا تا ۲۸ دسیبل، مناسب محیطهای صنعتی و کارگاهی، قابل تنظیم و راحت برای استفاده طولانی.",
|
||||
specs: ["uvex", "هدبند قابل تنظیم", "بالشتک نرم"],
|
||||
image: "/images/products/hearing/2.webp",
|
||||
},
|
||||
{
|
||||
code: "IP-HP03",
|
||||
group: "earmuff",
|
||||
name: "ایرماف بوفالو 110",
|
||||
en: "BUFFALO 110 EARMUFF",
|
||||
desc: "کاهش صدا تا ۲۸ دسیبل، مناسب محیطهای صنعتی و کارگاهی، قابل تنظیم و راحت برای استفاده طولانی.",
|
||||
specs: ["بوفالو", "هدبند قابل تنظیم", "بالشتک نرم"],
|
||||
image: "/images/products/hearing/4.webp",
|
||||
},
|
||||
{
|
||||
code: "IP-HP04",
|
||||
group: "earmuff",
|
||||
name: "گوشی صداگیر 3M",
|
||||
en: "3M EARMUFF",
|
||||
desc: "کاهش صدا تا ۲۸ دسیبل، مناسب محیطهای صنعتی و کارگاهی، قابل تنظیم و راحت برای استفاده طولانی.",
|
||||
specs: ["3M", "هدبند قابل تنظیم", "بالشتک نرم"],
|
||||
image: "/images/products/hearing/8.webp",
|
||||
},
|
||||
{
|
||||
code: "IP-HP05",
|
||||
group: "earplug",
|
||||
name: "ایرپلاگ کاستا مکس فیت MAXFIT",
|
||||
en: "COSTA MAXFIT EARPLUG",
|
||||
desc: "محافظت در برابر صداهای شدید و نویز محیط، سبک و راحت.",
|
||||
specs: ["سیلیکونی پلهای", "بنددار", "چندبار مصرف"],
|
||||
image: "/images/products/hearing/3.webp",
|
||||
},
|
||||
{
|
||||
code: "IP-HP06",
|
||||
group: "earplug",
|
||||
name: "ایرپلاگ اسفنجی Bilsom 303L",
|
||||
en: "BILSOM 303L FOAM EARPLUG",
|
||||
desc: "محافظت در برابر صداهای شدید و نویز محیط، سبک و راحت.",
|
||||
specs: ["اسفنجی", "بنددار / بدون بند", "یکبار مصرف"],
|
||||
image: "/images/products/hearing/5.webp",
|
||||
},
|
||||
{
|
||||
code: "IP-HP07",
|
||||
group: "earplug",
|
||||
name: "ایرپلاگ اسفنجی ماگما",
|
||||
en: "MAGMA FOAM EARPLUG",
|
||||
desc: "محافظت در برابر صداهای شدید و نویز محیط، سبک و راحت.",
|
||||
specs: ["اسفنجی", "یکبار مصرف"],
|
||||
image: "/images/products/hearing/6.webp",
|
||||
},
|
||||
{
|
||||
code: "IP-HP08",
|
||||
group: "earplug",
|
||||
name: "ایرپلاگ مکس فیت با قاب",
|
||||
en: "MAXFIT EARPLUG WITH CASE",
|
||||
desc: "محافظت در برابر صداهای شدید و نویز محیط، سبک و راحت.",
|
||||
specs: ["سیلیکونی پلهای", "بنددار", "قاب نگهداری"],
|
||||
image: "/images/products/hearing/7.webp",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "respiratory-protection",
|
||||
code: "IP-C07",
|
||||
name: "حفاظت تنفسی",
|
||||
en: "RESPIRATORY PROTECTION",
|
||||
shortDesc:
|
||||
"ماسکهای نیمصورت، تمامصورت، شیمیایی، P2/P3 و فیلتردار برای محافظت در برابر گازها و ذرات.",
|
||||
fullDesc:
|
||||
"تجهیزات حفاظت تنفسی شامل ماسکهای نیمصورت، تمامصورت، شیمیایی با کارتریج تخصصی، ماسکهای فیلتردار P2/P3 برای ذرات میکرونی، ماسکهای با فیلتر تعویضی اقتصادی و ماسکهای سهلایه بهداشتی. مناسب کار با حلالهای فرار، غبارهای معدنی، رنگپاشی و خطوط شیمیایی.",
|
||||
icon: "ShieldPlus",
|
||||
groups: [
|
||||
{ slug: "half", name: "ماسک نیمصورت", desc: "پوششدهی بینی و دهان با فیلتر قابل تعویض؛ مناسب محیطهای صنعتی با آلودگی متوسط و بخارات شیمیایی." },
|
||||
{ slug: "full", name: "ماسک تمامصورت", desc: "محافظت کامل از صورت و چشمها، مناسب کار با مواد شیمیایی و محیطهای پرخطر." },
|
||||
{ slug: "particulate", name: "ماسک فیلتردار P2/P3", desc: "محافظت در برابر گرد و غبار و ذرات معلق در هوا." },
|
||||
{ slug: "medical", name: "ماسک سهلایه", desc: "ماسک سبک و راحت، مناسب استفاده روزمره و محیطهای با آلودگی کم." },
|
||||
],
|
||||
products: [
|
||||
{
|
||||
code: "IP-R01",
|
||||
group: "half",
|
||||
name: "ماسک نیمصورت با فیلتر تعویضی",
|
||||
en: "HALF-FACE RESPIRATOR WITH FILTERS",
|
||||
desc: "پوششدهی بینی و دهان با فیلتر قابل تعویض برای استفاده طولانیمدت.",
|
||||
specs: ["نیمصورت", "فیلتر تعویضی", "دو کارتریج"],
|
||||
image: "/images/products/respiratory/5.webp",
|
||||
},
|
||||
{
|
||||
code: "IP-R02",
|
||||
group: "half",
|
||||
name: "ماسک نیمصورت نورث 5500",
|
||||
en: "NORTH 5500 HALF MASK",
|
||||
desc: "پوششدهی بینی و دهان، مناسب محیطهای صنعتی با آلودگی متوسط.",
|
||||
specs: ["North", "نیمصورت", "فیلتر تعویضی"],
|
||||
image: "/images/products/respiratory/6.webp",
|
||||
},
|
||||
{
|
||||
code: "IP-R03",
|
||||
group: "half",
|
||||
name: "ماسک شیمیایی نیمصورت 3M",
|
||||
en: "3M HALF-FACE RESPIRATOR",
|
||||
desc: "محافظت در برابر بخارات و گازهای شیمیایی با کارتریج و فیلتر قابل تعویض.",
|
||||
specs: ["3M", "کارتریج شیمیایی", "فیلتر تعویضی"],
|
||||
image: "/images/products/respiratory/7.webp",
|
||||
},
|
||||
{
|
||||
code: "IP-R04",
|
||||
group: "half",
|
||||
name: "ماسک نیمصورت نورث 7700",
|
||||
en: "NORTH 7700 HALF MASK",
|
||||
desc: "پوششدهی بینی و دهان با بدنه سیلیکونی، مناسب استفاده طولانیمدت.",
|
||||
specs: ["North", "سیلیکونی", "فیلتر تعویضی"],
|
||||
image: "/images/products/respiratory/11.webp",
|
||||
},
|
||||
{
|
||||
code: "IP-R05",
|
||||
group: "half",
|
||||
name: "ماسک نیمصورت نورث 7700 (نمای جانبی)",
|
||||
en: "NORTH 7700 HALF MASK",
|
||||
desc: "پوششدهی بینی و دهان با بدنه سیلیکونی، مناسب استفاده طولانیمدت.",
|
||||
specs: ["North", "سیلیکونی", "فیلتر تعویضی"],
|
||||
image: "/images/products/respiratory/10.webp",
|
||||
},
|
||||
{
|
||||
code: "IP-R06",
|
||||
group: "full",
|
||||
name: "ماسک تمامصورت",
|
||||
en: "FULL-FACE RESPIRATOR",
|
||||
desc: "محافظت کامل از صورت و چشمها، مناسب کار با مواد شیمیایی و محیطهای پرخطر.",
|
||||
specs: ["تمامصورت", "شیشه ضد ضربه", "فیلتر تعویضی"],
|
||||
image: "/images/products/respiratory/4.webp",
|
||||
},
|
||||
{
|
||||
code: "IP-R07",
|
||||
group: "full",
|
||||
name: "ماسک تنفسی دراگر",
|
||||
en: "DRÄGER FULL-FACE RESPIRATOR",
|
||||
desc: "محافظت کامل از صورت و چشمها، مناسب کار با مواد شیمیایی و محیطهای پرخطر.",
|
||||
specs: ["Dräger", "تمامصورت", "فیلتر چندمنظوره"],
|
||||
image: "/images/products/respiratory/8.webp",
|
||||
},
|
||||
{
|
||||
code: "IP-R08",
|
||||
group: "particulate",
|
||||
name: "ماسک فیلتردار تاشو",
|
||||
en: "FOLDABLE PARTICULATE MASK",
|
||||
desc: "محافظت در برابر گرد و غبار و ذرات معلق در هوا.",
|
||||
specs: ["P2", "تاشو", "یکبار مصرف"],
|
||||
image: "/images/products/respiratory/1.webp",
|
||||
},
|
||||
{
|
||||
code: "IP-R09",
|
||||
group: "particulate",
|
||||
name: "ماسک فیلتردار سوپاپدار",
|
||||
en: "VALVED PARTICULATE MASK",
|
||||
desc: "محافظت در برابر گرد و غبار و ذرات معلق در هوا.",
|
||||
specs: ["P2 / P3", "سوپاپ بازدمی", "یکبار مصرف"],
|
||||
image: "/images/products/respiratory/2.webp",
|
||||
},
|
||||
{
|
||||
code: "IP-R10",
|
||||
group: "particulate",
|
||||
name: "ماسک فیلتردار دراگر سوپاپدار",
|
||||
en: "DRÄGER VALVED PARTICULATE MASK",
|
||||
desc: "محافظت در برابر گرد و غبار و ذرات معلق در هوا.",
|
||||
specs: ["Dräger", "سوپاپ بازدمی", "P2 / P3"],
|
||||
image: "/images/products/respiratory/3.webp",
|
||||
},
|
||||
{
|
||||
code: "IP-R11",
|
||||
group: "medical",
|
||||
name: "ماسک سهلایه پزشکی",
|
||||
en: "THREE-LAYER MEDICAL MASK",
|
||||
desc: "ماسک سبک و راحت، مناسب استفاده روزمره و محیطهای با آلودگی کم.",
|
||||
specs: ["سهلایه", "یکبار مصرف", "بسته ۵۰ عددی"],
|
||||
image: "/images/products/respiratory/9.webp",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "traffic-equipment",
|
||||
code: "IP-C08",
|
||||
name: "تجهیزات ترافیکی و ایمنی محیطی",
|
||||
en: "TRAFFIC & SITE SAFETY",
|
||||
shortDesc:
|
||||
"مخروط، نیوجرسی، استوانه، سرعتگیر، آینه محدب و سایر تجهیزات ایمنسازی محوطه صنعتی.",
|
||||
fullDesc:
|
||||
"تجهیزات ترافیکی و ایمنی محیطی شامل مخروط ترافیکی، نیوجرسی پلاستیکی، استوانه و بولارد، چراغ و تابلوهای هشداردهنده، باتوم ترافیکی، نوارهای خطر، سرعتگیر لاستیکی، آینههای محدب، محافظ گوشه ستون و گلمیخ خورشیدی. مناسب ایمنسازی محوطه کارخانه، انبارها، رمپها و پارکینگها.",
|
||||
icon: "TriangleAlert",
|
||||
products: [
|
||||
{
|
||||
code: "IP-T01",
|
||||
name: "مخروط ترافیکی",
|
||||
en: "TRAFFIC CONE",
|
||||
desc: "PVC انعطافپذیر با شبرنگهای استاندارد روز و شب؛ مقاوم در برابر واژگونی و ضربه خودرو.",
|
||||
specs: ["PVC انعطافپذیر", "شبرنگ استاندارد", "مقاوم به واژگونی", "قابل رؤیت شبانه"],
|
||||
},
|
||||
{
|
||||
code: "IP-T02",
|
||||
name: "نیوجرسی پلاستیکی",
|
||||
en: "NEW JERSEY BARRIER",
|
||||
desc: "بدنه پلیاتیلنی مدولار قابل اتصال با قابلیت پرشوندگی با آب یا ماسه جهت تثبیت و ایجاد حائل.",
|
||||
specs: ["پلیاتیلن مدولار", "قابل اتصال", "پرشدنی با آب/ماسه", "مقاوم UV"],
|
||||
},
|
||||
{
|
||||
code: "IP-T03",
|
||||
name: "استوانه ترافیکی",
|
||||
en: "TRAFFIC BOLLARD",
|
||||
desc: "پلیاورتان ارتجاعی انعطافپذیر پس از ضربه؛ مجهز به حلقههای شبرنگ هشدار برای تفکیک مسیر.",
|
||||
specs: ["پلیاورتان ارتجاعی", "بازگشت پس از ضربه", "شبرنگ هشدار", "نصب آسان"],
|
||||
},
|
||||
{
|
||||
code: "IP-T04",
|
||||
name: "چراغ و تابلوی هشدار",
|
||||
en: "WARNING LIGHT & SIGN",
|
||||
desc: "چراغهای چشمکزن سولار، تابلوی دستی ایست/حرکت و تابلوهای علائم هشداردهنده ترافیکی.",
|
||||
specs: ["سولار / باتری", "LED چشمکزن", "تابلوی Stop/Go", "علائم استاندارد"],
|
||||
},
|
||||
{
|
||||
code: "IP-T05",
|
||||
name: "باتوم ترافیکی",
|
||||
en: "TRAFFIC BATON",
|
||||
desc: "باتوم LED دار دستی جهت هدایت ترافیک در شب و محیطهای کمنور؛ سبک و ارگونومیک.",
|
||||
specs: ["LED داخلی", "قابل رؤیت شبانه", "سبکوزن", "ارگونومیک"],
|
||||
},
|
||||
{
|
||||
code: "IP-T06",
|
||||
name: "نوار خطر",
|
||||
en: "CAUTION TAPE",
|
||||
desc: "نوار شطرنجی و خطکشی زرد/قرمز هشدار محوطه گودبرداری و مناطق خطرناک با رولهای بازتابنده نور.",
|
||||
specs: ["زرد / قرمز هشدار", "بازتابنده نور", "رول بلند", "مقاوم به پارگی"],
|
||||
},
|
||||
{
|
||||
code: "IP-T07",
|
||||
name: "سرعتگیر لاستیکی",
|
||||
en: "RUBBER SPEED BUMP",
|
||||
desc: "قطعات لاستیکی سنگین مدولار با مقاومت وزنی بالا و المانهای بازتابش شبانه؛ نصب آسان.",
|
||||
specs: ["لاستیکی مدولار", "مقاومت وزنی بالا", "بازتاب شبانه", "نصب پیچی"],
|
||||
},
|
||||
{
|
||||
code: "IP-T08",
|
||||
name: "آینه محدب ترافیکی",
|
||||
en: "CONVEX TRAFFIC MIRROR",
|
||||
desc: "آینه نشکن فریمدار و کلاهدار جهت پوشش نقاط کور پیچها، تقاطعها و درب انبار.",
|
||||
specs: ["نشکن", "فریمدار و کلاهدار", "پوشش نقاط کور", "مقاوم به آب و باد"],
|
||||
},
|
||||
{
|
||||
code: "IP-T09",
|
||||
name: "محافظ گوشه ستون",
|
||||
en: "CORNER GUARD",
|
||||
desc: "ضربهگیر لاستیکی شبرنگدار جهت نصب روی ستونها برای ممانعت از آسیب لیفتراک و خودروهای سنگین.",
|
||||
specs: ["لاستیکی ضربهگیر", "شبرنگدار", "نصب دیواری", "محافظ ستون"],
|
||||
},
|
||||
{
|
||||
code: "IP-T10",
|
||||
name: "گلمیخ ترافیکی خورشیدی",
|
||||
en: "SOLAR ROAD STUD",
|
||||
desc: "بدنه آلومینیومی مستحکم مجهز به پنل خورشیدی و LED جهت تعیین مسیر و خطکشی شبانه.",
|
||||
specs: ["آلومینیوم مستحکم", "پنل خورشیدی", "LED", "مقاوم به عبور خودرو"],
|
||||
},
|
||||
],
|
||||
},
|
||||
] as const;
|
||||
@@ -0,0 +1,279 @@
|
||||
import type { SiteSections } from "@imen/shared";
|
||||
|
||||
/**
|
||||
* Initial website content, copied from the original hard-coded components.
|
||||
* Image URLs starting with "asset:" are uploaded from apps/api/seed-assets by the seed script.
|
||||
*/
|
||||
export const SECTIONS: SiteSections = {
|
||||
settings: {
|
||||
name: "ایمن پیشگامان",
|
||||
nameEn: "Imen Pishgaman Safety",
|
||||
title: "ایمن پیشگامان | تجهیزات ایمنی و حفاظت فردی صنعتی",
|
||||
description:
|
||||
"ایمن پیشگامان — تأمینکننده تخصصی تجهیزات حفاظت فردی (PPE)، بازرسی، آموزش و راهکارهای ایمنی صنعتی برای سازمانهایی که جان انسانها را جدی میگیرند.",
|
||||
phone: "+98-21-91008800",
|
||||
phoneDisplay: "۰۲۱ - ۹۱ ۰۰ ۸۸ ۰۰",
|
||||
email: "info@imenpishgaman.ir",
|
||||
address: {
|
||||
street: "خیابان کارگر شمالی، خیابان فر، کوچه دوم، پلاک ۱۲",
|
||||
locality: "تهران",
|
||||
country: "IR",
|
||||
},
|
||||
workingHours: "شنبه تا پنجشنبه — ۸:۰۰ تا ۱۷:۰۰",
|
||||
openingHours: {
|
||||
days: ["Saturday", "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday"],
|
||||
opens: "08:00",
|
||||
closes: "17:00",
|
||||
},
|
||||
foundingDate: "2007",
|
||||
socials: [
|
||||
{ type: "website", url: "/" },
|
||||
{ type: "email", url: "mailto:info@imenpishgaman.ir" },
|
||||
{ type: "phone", url: "tel:+982191008800" },
|
||||
],
|
||||
ogImage: {
|
||||
url: "asset:hero.jpg",
|
||||
alt: "کارگر صنعتی با تجهیزات ایمنی کامل",
|
||||
width: 1792,
|
||||
height: 1008,
|
||||
},
|
||||
},
|
||||
|
||||
hero: {
|
||||
badge: "تأمینکننده رسمی تجهیزات حفاظت فردی (PPE)",
|
||||
title: "ایمنی یعنی",
|
||||
titleHighlight: "بازگشت به خانه",
|
||||
intro:
|
||||
"ایمن پیشگامان با بیش از ۱۸ سال تجربه، تأمینکننده تخصصی تجهیزات ایمنی و حفاظت فردی برای سازمانهایی است که جان انسانها را جدی میگیرند؛ از کلاه ایمنی تا هارنس نجات، از مشاوره تا بازرسی و آموزش.",
|
||||
primaryCta: { label: "درخواست مشاوره رایگان", href: "#contact" },
|
||||
secondaryCta: { label: "مشاهده محصولات", href: "#products" },
|
||||
chips: [
|
||||
{ icon: "ShieldCheck", label: "ISO 9001:2015" },
|
||||
{ icon: "BadgeCheck", label: "CE CERTIFIED" },
|
||||
{ icon: "Award", label: "EN STANDARDS" },
|
||||
],
|
||||
miniStats: [
|
||||
{ value: "۱۸+", label: "سال تجربه در صنعت ایمنی" },
|
||||
{ value: "۸۵۰+", label: "سازمان تحت پوشش" },
|
||||
{ value: "۹۸٪", label: "رضایت مشتریان" },
|
||||
],
|
||||
image: { url: "asset:hero.jpg", alt: "کارگر صنعتی با تجهیزات ایمنی کامل" },
|
||||
lastInspection: "۱۴۰۴/۰۸/۱۲",
|
||||
},
|
||||
|
||||
tape: {
|
||||
items: [
|
||||
"ISO 9001:2015",
|
||||
"CE CERTIFIED",
|
||||
"گارانتی اصالت کالا",
|
||||
"تحویل سراسری کشوری",
|
||||
"مشاوره و بازرسی تخصصی رایگان",
|
||||
"EN STANDARDS",
|
||||
"پشتیبانی ۲۴/۷ قراردادهای سازمانی",
|
||||
"تست نمونه قبل از خرید",
|
||||
],
|
||||
},
|
||||
|
||||
stats: {
|
||||
items: [
|
||||
{ end: 18, suffix: "+", label: "سال تجربه در صنعت ایمنی", note: "SINCE 2007" },
|
||||
{ end: 2400, suffix: "+", label: "کد کالای ایمنی", note: "PPE CATALOG" },
|
||||
{ end: 850, suffix: "+", label: "مشتری سازمانی فعال", note: "B2B CLIENTS" },
|
||||
{ end: 98, suffix: "٪", label: "رضایت مشتریان", note: "SATISFACTION" },
|
||||
],
|
||||
},
|
||||
|
||||
productsIntro: {
|
||||
head: {
|
||||
tag: "۰۱ — محصولات",
|
||||
title: "خط اول دفاع،\nتجهیزات ماست",
|
||||
highlight: "",
|
||||
desc: "هر محصول پیش از عرضه، زیر ذرهبین کنترل کیفیت و تطبیق با استانداردهای جهانی قرار میگیرد.",
|
||||
},
|
||||
ctaLabel: "دریافت کاتالوگ کامل",
|
||||
trust: [
|
||||
{
|
||||
icon: "BadgeCheck",
|
||||
title: "گارانتی اصالت کالا",
|
||||
desc: "تمام محصولات با فاکتور رسمی و کد رهگیری اصالت",
|
||||
},
|
||||
{
|
||||
icon: "PackageCheck",
|
||||
title: "تست نمونه قبل از خرید",
|
||||
desc: "نمونههای نمایشی برای ارزیابی تیم HSE شما",
|
||||
},
|
||||
{
|
||||
icon: "ShieldCheck",
|
||||
title: "امکان قرارداد سالانه",
|
||||
desc: "قیمت ثابت و تأمین مستمر برای سازمانهای بزرگ",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
about: {
|
||||
head: {
|
||||
tag: "۰۲ — درباره ما",
|
||||
title: "ایمنی برای ما شعار نیست؛",
|
||||
highlight: "قرارداد است.",
|
||||
desc: "",
|
||||
},
|
||||
body: "از سال ۱۳۸۶، زمانی که اولین کلاه ایمنی را به یک کارگاه کوچک در جنوب تهران فروختیم، باور ما همین یک جمله بود: پشت هر تجهیز ایمنی، یک خانواده هست که منتظر بازگشت عزیزش است. امروز با بیش از ۸۵۰ سازمان، از پالایشگاهها تا معادن و سایتهای ساختمانی، در کنار کسانی هستیم که ایمنی را یک خط قرمز میدانند.",
|
||||
image: { url: "asset:about.jpg", alt: "انبار تجهیزات ایمنی ایمن پیشگامان" },
|
||||
badge: { value: "۱۸+", label: "سال حفاظت از\nجان نیروی کار" },
|
||||
timeline: [
|
||||
{ year: "۱۳۸۶", text: "تأسیس ایمن پیشگامان در تهران با تمرکز بر تجهیزات حفاظت فردی" },
|
||||
{ year: "۱۳۹۲", text: "توسعه شبکه توزیع به تمام ۳۱ استان کشور" },
|
||||
{ year: "۱۳۹۸", text: "راهاندازی آزمایشگاه کنترل کیفیت و تست تجهیزات" },
|
||||
{ year: "۱۴۰۲", text: "افتتاح مرکز تخصصی آموزش و بازرسی HSE" },
|
||||
],
|
||||
features: [
|
||||
{ icon: "Globe", label: "نماینده رسمی ۴۰+ برند جهانی" },
|
||||
{ icon: "Users", label: "تیم مهندسی و بازرسی HSE" },
|
||||
{ icon: "Headset", label: "پشتیبانی ۲۴/۷ قراردادها" },
|
||||
],
|
||||
},
|
||||
|
||||
services: {
|
||||
head: {
|
||||
tag: "۰۳ — خدمات",
|
||||
title: "فراتر از تأمین؛\nهمراهی کامل ایمنی",
|
||||
highlight: "",
|
||||
desc: "ما در ایمن پیشگامان، تجهیزات را تحویل میدهیم و کنار شما میمانیم؛ از نخستین ارزیابی ریسک تا آخرین تست دورهای.",
|
||||
},
|
||||
items: [
|
||||
{
|
||||
icon: "ClipboardCheck",
|
||||
title: "بازرسی و ارزیابی ریسک",
|
||||
desc: "تیم مهندسی ما با بازدید از سایت شما، خطرات را شناسایی، اولویتبندی و برای هر کدام راهکار اجرایی مشخص با برآورد هزینه ارائه میدهد.",
|
||||
items: [
|
||||
"ارزیابی HAZOP و HAZID",
|
||||
"شناسایی و اولویتبندی خطرات",
|
||||
"گزارش مدیریتی با راهکار اجرایی",
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: "GraduationCap",
|
||||
title: "آموزش تخصصی ایمنی",
|
||||
desc: "از دورههای عمومی تا تربیت مربی؛ آموزشهای عملی و میدانی که ایمنی را از دفتر کلانها به رفتار روزمره پرسنل تبدیل میکند.",
|
||||
items: [
|
||||
"دورههای عمومی و تخصصی HSE",
|
||||
"تربیت مربی ایمنی سایت",
|
||||
"مانور و شبیهسازی شرایط اضطراری",
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: "Gauge",
|
||||
title: "تست و کالیبراسیون تجهیزات",
|
||||
desc: "تجهیزات ایمنی فقط وقتی قابل اعتمادند که تست شده باشند. تست دورهای، سرویس و صدور گواهی معتبر را به تیم تخصصی ما بسپارید.",
|
||||
items: [
|
||||
"تست هارنس و لاین نجات",
|
||||
"کالیبراسیون آشکارسازهای گاز",
|
||||
"سرویس دورهای و صدور گواهی",
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: "Truck",
|
||||
title: "تأمین و لجستیک سازمانی",
|
||||
desc: "با قرارداد سالانه، قیمتگذاری شفاف و انبار محلی در سایت شما، نگرانی کمبود تجهیزات را از فهرست دغدغههایتان حذف کنید.",
|
||||
items: [
|
||||
"قرارداد سالانه با قیمت ثابت",
|
||||
"انبار محلی در سایت شما",
|
||||
"تحویل سراسری در ۴۸ ساعت",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
standards: {
|
||||
head: {
|
||||
tag: "۰۴ — استانداردها",
|
||||
title: "مهرتأیید جهانی\nروی هر محصول",
|
||||
highlight: "",
|
||||
desc: "ما فقط تجهیزاتی را عرضه میکنیم که تأییدیه معتبر بینالمللی و تست آزمایشگاهی داشته باشند؛ بدون استثنا.",
|
||||
},
|
||||
note: "شماره سریال تمام محصولات در پایگاه داده استانداردهای اروپا قابل استعلام است و گواهی تست هر بچ تحویل مشتری میشود.",
|
||||
ctaLabel: "درخواست گواهی تست محصولات",
|
||||
items: [
|
||||
{ code: "EN 397", label: "کلاه ایمنی صنعتی" },
|
||||
{ code: "EN 388", label: "دستکشهای مکانیکی" },
|
||||
{ code: "EN 166", label: "محافظ چشم و صورت" },
|
||||
{ code: "EN 361", label: "هارنس ضدسقوط" },
|
||||
{ code: "ISO 20345", label: "کفش ایمنی" },
|
||||
{ code: "ISO 9001", label: "سیستم مدیریت کیفیت" },
|
||||
],
|
||||
},
|
||||
|
||||
industries: {
|
||||
head: {
|
||||
tag: "۰۵ — صنایع",
|
||||
title: "حضور در سختترین\nمیدانهای کاری",
|
||||
highlight: "",
|
||||
desc: "جایی که دمای محیط از ۵۰ درجه گذر میکند یا ارتفاع کار به دهها متر میرسد، تجهیزات ما باید بینقص عمل کنند — و عمل میکنند.",
|
||||
},
|
||||
items: [
|
||||
{ name: "نفت و گاز", nameEn: "OIL & GAS", count: "۱۴۰+", image: { url: "asset:industries/oil-gas.jpg", alt: "نفت و گاز" } },
|
||||
{ name: "ساختمان و عمران", nameEn: "CONSTRUCTION", count: "۲۱۰+", image: { url: "asset:industries/construction.jpg", alt: "ساختمان و عمران" } },
|
||||
{ name: "پتروشیمی", nameEn: "PETROCHEMICAL", count: "۹۰+", image: { url: "asset:industries/petrochemical.jpg", alt: "پتروشیمی" } },
|
||||
{ name: "صنایع معدنی", nameEn: "MINING", count: "۷۵+", image: { url: "asset:industries/mining.jpg", alt: "صنایع معدنی" } },
|
||||
{ name: "برق و انرژی", nameEn: "POWER & ENERGY", count: "۶۰+", image: { url: "asset:industries/power-energy.jpg", alt: "برق و انرژی" } },
|
||||
{ name: "حملونقل و لجستیک", nameEn: "TRANSPORT", count: "۴۵+", image: { url: "asset:industries/transport.jpg", alt: "حملونقل و لجستیک" } },
|
||||
],
|
||||
},
|
||||
|
||||
testimonials: {
|
||||
head: { tag: "۰۶ — اعتماد", title: "آنچه مشتریان ما میگویند", highlight: "", desc: "" },
|
||||
items: [
|
||||
{
|
||||
text: "ایمن پیشگامان فقط تأمینکننده نیست؛ شریک ایمنی ماست. بازرسیهای دورهای و آموزشهای میدانیشان نرخ حوادث سایت را به کمترین رقم تاریخ مجموعه رساند.",
|
||||
name: "مهندس رضا کاظمی",
|
||||
role: "مدیر HSE — مجتمع پتروشیمی خلیج فارس",
|
||||
},
|
||||
{
|
||||
text: "کیفیت تجهیزات و سرعت پاسخگویی در سطحی بود که قرارداد سالانه را بدون هیچ تردیدی تمدید کردیم؛ از خط تولید تا دفتر مرکزی.",
|
||||
name: "سارا محمدی",
|
||||
role: "سرپرست تدارکات — هلدینگ ساختمانی آرکان",
|
||||
},
|
||||
{
|
||||
text: "۱۴۰ کیلومتر دورتر از سایت، در عرض ۲۴ ساعت تجهیزات حیاتی را تحویل گرفتیم. به این میگویند شریک واقعی کاری.",
|
||||
name: "امیرحسین راد",
|
||||
role: "مدیر پروژه — صنایع معدنی کاوه",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
contact: {
|
||||
head: {
|
||||
tag: "۰۷ — تماس",
|
||||
title: "بیایید ایمنی را\nبا هم جدی بگیریم",
|
||||
highlight: "",
|
||||
desc: "فرم را پر کنید تا کارشناسان ما برای هماهنگی جلسه بازدید رایگان سایت شما تماس بگیرند؛ یا مستقیم با ما در ارتباط باشید.",
|
||||
},
|
||||
emergencyTitle: "پاسخگویی اضطراری ۲۴/۷",
|
||||
emergencyText:
|
||||
"برای مشتریان دارای قرارداد سازمانی، خط ویژه حوادث و تأمین فوری تجهیزات در تمام ساعات شبانهروز فعال است.",
|
||||
formTag: "// فرم درخواست مشاوره",
|
||||
formTitle: "در کمتر از ۲۴ ساعت\nبا شما تماس میگیریم",
|
||||
successTitle: "درخواست شما ثبت شد",
|
||||
successText: "کارشناسان ما تا ۲۴ ساعت آینده برای هماهنگی جلسه بازدید با شما تماس میگیرند.",
|
||||
},
|
||||
|
||||
footer: {
|
||||
about:
|
||||
"تأمینکننده تخصصی تجهیزات حفاظت فردی و راهکارهای ایمنی صنعتی؛ همراه سازمانها از سال ۱۳۸۶ در مسیر صفر شدن حوادث کاری.",
|
||||
copyright: "© ۱۴۰۴ ایمن پیشگامان — تمامی حقوق محفوظ است.",
|
||||
certLine: "ISO 9001:2015 — CE CERTIFIED PPE SUPPLIER",
|
||||
emergencyNote: "پشتیبانی اضطراری: ۲۴/۷",
|
||||
},
|
||||
};
|
||||
|
||||
/** Cover image of each category (was CATEGORY_IMAGES in the components) */
|
||||
export const CATEGORY_COVERS: Record<string, string> = {
|
||||
workwear: "products/vest.jpg",
|
||||
"safety-footwear": "products/shoes.jpg",
|
||||
"protective-gloves": "products/gloves.jpg",
|
||||
"safety-helmets": "products/helmet.jpg",
|
||||
"eye-protection": "products/goggles.jpg",
|
||||
"hearing-protection": "products/hearing.jpg",
|
||||
"respiratory-protection": "products/respiratory.jpg",
|
||||
"traffic-equipment": "products/traffic.jpg",
|
||||
};
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Fills an empty database with the original website content.
|
||||
* Safe to re-run: existing categories, products, sections and users are left untouched.
|
||||
*
|
||||
* pnpm db:seed (dev, via tsx)
|
||||
* node dist/seed/seed.js (production container)
|
||||
* SEED_OVERWRITE_SECTIONS=1 ... (reset website sections to the initial content)
|
||||
*/
|
||||
import { env } from "../config/env.js";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { SECTION_KEYS, sectionSchemas } from "@imen/shared";
|
||||
import { hashPassword } from "../auth/auth.service.js";
|
||||
import { S3Storage, toWebp } from "../media/s3-storage.js";
|
||||
import { createPrismaClient } from "../prisma/prisma.service.js";
|
||||
import { CATEGORIES } from "./data/categories.js";
|
||||
import { CATEGORY_COVERS, SECTIONS } from "./data/sections.js";
|
||||
|
||||
const ASSETS_DIR =
|
||||
process.env.SEED_ASSETS_DIR ??
|
||||
path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../seed-assets");
|
||||
const OVERWRITE_SECTIONS = ["1", "true"].includes(process.env.SEED_OVERWRITE_SECTIONS ?? "");
|
||||
|
||||
const prisma = createPrismaClient();
|
||||
const storage = new S3Storage();
|
||||
const uploaded = new Map<string, { id: number; url: string }>();
|
||||
|
||||
/** Uploads a file from seed-assets once (as WebP) and returns its media record */
|
||||
async function asset(rel: string, alt = "") {
|
||||
const cached = uploaded.get(rel);
|
||||
if (cached) return cached;
|
||||
|
||||
const key = `seed/${rel.replace(/\.[^.]+$/, "")}.webp`;
|
||||
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");
|
||||
media = await prisma.media.create({
|
||||
data: {
|
||||
key,
|
||||
mime: "image/webp",
|
||||
size: image.size,
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
alt,
|
||||
originalName: path.basename(rel),
|
||||
},
|
||||
});
|
||||
}
|
||||
const result = { id: media.id, url: storage.publicUrl(key) };
|
||||
uploaded.set(rel, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Replaces every { url: "asset:<path>" } inside section data with the uploaded file URL */
|
||||
async function resolveAssets(value: unknown): Promise<unknown> {
|
||||
if (Array.isArray(value)) return Promise.all(value.map(resolveAssets));
|
||||
if (value && typeof value === "object") {
|
||||
const entries = await Promise.all(
|
||||
Object.entries(value).map(async ([k, v]) => {
|
||||
if (k === "url" && typeof v === "string" && v.startsWith("asset:")) {
|
||||
return [k, (await asset(v.slice("asset:".length))).url];
|
||||
}
|
||||
return [k, await resolveAssets(v)];
|
||||
}),
|
||||
);
|
||||
return Object.fromEntries(entries);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function seedSections() {
|
||||
for (const key of SECTION_KEYS) {
|
||||
const exists = await prisma.siteSection.findUnique({ where: { key } });
|
||||
if (exists && !OVERWRITE_SECTIONS) continue;
|
||||
const data = sectionSchemas[key].parse(await resolveAssets(SECTIONS[key]));
|
||||
await prisma.siteSection.upsert({ where: { key }, create: { key, data }, update: { data } });
|
||||
console.log(` section ${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function seedCatalog() {
|
||||
for (const [ci, c] of CATEGORIES.entries()) {
|
||||
if (await prisma.category.findUnique({ where: { slug: c.slug } })) continue;
|
||||
|
||||
const coverPath = CATEGORY_COVERS[c.slug];
|
||||
const cover = coverPath ? await asset(coverPath, c.name) : null;
|
||||
const category = await prisma.category.create({
|
||||
data: {
|
||||
slug: c.slug,
|
||||
code: c.code,
|
||||
name: c.name,
|
||||
nameEn: c.en,
|
||||
shortDesc: c.shortDesc,
|
||||
fullDesc: c.fullDesc,
|
||||
icon: c.icon,
|
||||
coverImageId: cover?.id ?? null,
|
||||
sortOrder: ci,
|
||||
groups: {
|
||||
create: (c.groups ?? []).map((g, i) => ({
|
||||
slug: g.slug,
|
||||
name: g.name,
|
||||
desc: g.desc,
|
||||
sortOrder: i,
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: { groups: true },
|
||||
});
|
||||
const groupIds = new Map(category.groups.map((g) => [g.slug, g.id]));
|
||||
|
||||
for (const [pi, p] of c.products.entries()) {
|
||||
const image = p.image ? await asset(p.image.replace(/^\/images\//, ""), p.name) : null;
|
||||
await prisma.product.create({
|
||||
data: {
|
||||
categoryId: category.id,
|
||||
groupId: p.group ? (groupIds.get(p.group) ?? null) : null,
|
||||
code: p.code,
|
||||
slug: p.code.toLowerCase(),
|
||||
name: p.name,
|
||||
nameEn: p.en,
|
||||
desc: p.desc,
|
||||
specs: [...p.specs],
|
||||
variants: [...(p.variants ?? [])],
|
||||
imageId: image?.id ?? null,
|
||||
sortOrder: pi,
|
||||
},
|
||||
});
|
||||
}
|
||||
console.log(` category ${c.slug} (${c.products.length} products)`);
|
||||
}
|
||||
}
|
||||
|
||||
async function seedAdmin() {
|
||||
if (!env.ADMIN_EMAIL || !env.ADMIN_PASSWORD) {
|
||||
console.log(" ADMIN_EMAIL / ADMIN_PASSWORD not set — skipping admin user");
|
||||
return;
|
||||
}
|
||||
const email = env.ADMIN_EMAIL.trim().toLowerCase();
|
||||
if (await prisma.adminUser.findUnique({ where: { email } })) return;
|
||||
await prisma.adminUser.create({
|
||||
data: {
|
||||
email,
|
||||
name: env.ADMIN_NAME,
|
||||
role: "ADMIN",
|
||||
passwordHash: await hashPassword(env.ADMIN_PASSWORD),
|
||||
},
|
||||
});
|
||||
console.log(` admin user ${email}`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (env.S3_ENSURE_BUCKET) await storage.ensureBucket();
|
||||
console.log("seeding sections…");
|
||||
await seedSections();
|
||||
console.log("seeding catalog…");
|
||||
await seedCatalog();
|
||||
console.log("seeding admin…");
|
||||
await seedAdmin();
|
||||
const [categories, products, media] = await Promise.all([
|
||||
prisma.category.count(),
|
||||
prisma.product.count(),
|
||||
prisma.media.count(),
|
||||
]);
|
||||
console.log(`done: ${categories} categories, ${products} products, ${media} media files`);
|
||||
}
|
||||
|
||||
try {
|
||||
await main();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
Injectable,
|
||||
Module,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
type UserCreateInput,
|
||||
userCreateSchema,
|
||||
type UserUpdateInput,
|
||||
userUpdateSchema,
|
||||
} from "@imen/shared";
|
||||
import { AdminAuth, type AuthUser, CurrentUser } from "../common/auth.js";
|
||||
import { AuditService } from "../common/common.module.js";
|
||||
import { IdPipe, ZodPipe } from "../common/zod.pipe.js";
|
||||
import { AuthModule } from "../auth/auth.controller.js";
|
||||
import { AuthService, hashPassword, toUserDto } from "../auth/auth.service.js";
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly auth: AuthService,
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
async list() {
|
||||
const users = await this.prisma.adminUser.findMany({ orderBy: { id: "asc" } });
|
||||
return users.map(toUserDto);
|
||||
}
|
||||
|
||||
async create(input: UserCreateInput, actor: AuthUser) {
|
||||
const user = await this.prisma.adminUser.create({
|
||||
data: {
|
||||
email: input.email,
|
||||
name: input.name,
|
||||
role: input.role,
|
||||
passwordHash: await hashPassword(input.password),
|
||||
},
|
||||
});
|
||||
this.audit.log(actor.id, "create", "user", user.id, user.email);
|
||||
return toUserDto(user);
|
||||
}
|
||||
|
||||
async update(id: number, input: UserUpdateInput, actor: AuthUser) {
|
||||
if (id === actor.id && (input.role !== "ADMIN" || !input.isActive)) {
|
||||
throw new BadRequestException("نمیتوانید نقش یا وضعیت حساب خودتان را تغییر دهید");
|
||||
}
|
||||
const user = await this.prisma.adminUser.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: input.name,
|
||||
role: input.role,
|
||||
isActive: input.isActive,
|
||||
...(input.password ? { passwordHash: await hashPassword(input.password) } : {}),
|
||||
},
|
||||
});
|
||||
// a password reset or deactivation ends the user's existing sessions
|
||||
if (input.password || !input.isActive) await this.auth.revokeAll(id);
|
||||
this.audit.log(actor.id, "update", "user", id, user.email);
|
||||
return toUserDto(user);
|
||||
}
|
||||
|
||||
async remove(id: number, actor: AuthUser) {
|
||||
if (id === actor.id) throw new BadRequestException("نمیتوانید حساب خودتان را حذف کنید");
|
||||
const user = await this.prisma.adminUser.delete({ where: { id } });
|
||||
this.audit.log(actor.id, "delete", "user", id, user.email);
|
||||
}
|
||||
}
|
||||
|
||||
@AdminAuth("ADMIN")
|
||||
@Controller("admin/users")
|
||||
export class UsersController {
|
||||
constructor(private readonly users: UsersService) {}
|
||||
|
||||
@Get()
|
||||
list() {
|
||||
return this.users.list();
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(
|
||||
@Body(new ZodPipe(userCreateSchema)) body: UserCreateInput,
|
||||
@CurrentUser() actor: AuthUser,
|
||||
) {
|
||||
return this.users.create(body, actor);
|
||||
}
|
||||
|
||||
@Put(":id")
|
||||
update(
|
||||
@Param("id", IdPipe) id: number,
|
||||
@Body(new ZodPipe(userUpdateSchema)) body: UserUpdateInput,
|
||||
@CurrentUser() actor: AuthUser,
|
||||
) {
|
||||
return this.users.update(id, body, actor);
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
@HttpCode(204)
|
||||
async remove(@Param("id", IdPipe) id: number, @CurrentUser() actor: AuthUser) {
|
||||
await this.users.remove(id, actor);
|
||||
}
|
||||
}
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [UsersController],
|
||||
providers: [UsersService],
|
||||
})
|
||||
export class UsersModule {}
|
||||
Reference in New Issue
Block a user