Files
imen_pishghaman/apps/admin/src/pages/ProductEditPage.vue
T
S. EsmaeiliandClaude Opus 5.5 789d224ea0 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>
2026-10-21 20:31:05 +03:30

246 lines
9.3 KiB
Vue

<script setup lang="ts">
import { CloseOutlined, PlusOutlined, SaveOutlined } from "@ant-design/icons-vue";
import { type MediaDto, productInputSchema } from "@imen/shared";
import { message } from "ant-design-vue";
import { computed, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import type { z } from "zod";
import { api, errorMessage } from "@/api/http";
import type { AdminCategory, AdminCategoryDetail, AdminProductDetail } from "@/api/types";
import FormField from "@/components/FormField.vue";
import ImageField from "@/components/ImageField.vue";
import MediaPicker from "@/components/MediaPicker.vue";
import PageHeader from "@/components/PageHeader.vue";
import { useZodForm } from "@/composables/useZodForm";
import { SITE_URL } from "@/utils/format";
import { toSlug } from "@/utils/slug";
type Form = z.output<typeof productInputSchema>;
const route = useRoute();
const router = useRouter();
const id = computed(() => (route.params.id ? Number(route.params.id) : null));
const empty = (): Form => ({
categoryId: Number(route.query.categoryId) || (undefined as unknown as number),
groupId: null,
code: "",
slug: "",
name: "",
nameEn: "",
desc: "",
specs: [],
variants: [],
imageId: null,
galleryIds: [],
isPublished: true,
});
const { model, validate, handleError, reset } = useZodForm(productInputSchema, empty());
const image = ref<MediaDto | null>(null);
const gallery = ref<MediaDto[]>([]);
const galleryPicker = ref(false);
const categories = ref<AdminCategory[]>([]);
const groups = ref<AdminCategoryDetail["groups"]>([]);
const loading = ref(false);
const saving = ref(false);
const category = computed(() => categories.value.find((c) => c.id === model.value.categoryId));
/** a-select clears to undefined, the API expects null */
const groupId = computed({
get: () => model.value.groupId ?? undefined,
set: (v: number | undefined) => (model.value.groupId = v ?? null),
});
async function loadGroups(clear: boolean) {
if (clear) model.value.groupId = null;
groups.value = model.value.categoryId
? (await api<AdminCategoryDetail>(`/admin/categories/${model.value.categoryId}`)).groups
: [];
}
async function load() {
loading.value = true;
try {
categories.value = await api<AdminCategory[]>("/admin/categories");
if (id.value) {
const p = await api<AdminProductDetail>(`/admin/products/${id.value}`);
reset({
categoryId: p.categoryId,
groupId: p.groupId,
code: p.code,
slug: p.slug,
name: p.name,
nameEn: p.nameEn,
desc: p.desc,
specs: p.specs,
variants: p.variants,
imageId: p.imageId,
galleryIds: p.gallery.map((g) => g.id),
isPublished: p.isPublished,
});
image.value = p.image;
gallery.value = p.gallery;
} else {
reset(empty());
image.value = null;
gallery.value = [];
}
await loadGroups(false);
} catch (e) {
void message.error(errorMessage(e));
} finally {
loading.value = false;
}
}
function pickImage(m: MediaDto) {
image.value = m;
model.value.imageId = m.id;
}
function addToGallery(m: MediaDto) {
if (gallery.value.some((g) => g.id === m.id)) return;
gallery.value = [...gallery.value, m];
}
function removeFromGallery(mediaId: number) {
gallery.value = gallery.value.filter((g) => g.id !== mediaId);
}
watch(gallery, (g) => (model.value.galleryIds = g.map((m) => m.id)));
async function save() {
const data = validate();
if (!data) return;
saving.value = true;
try {
const saved = await api<AdminProductDetail>(id.value ? `/admin/products/${id.value}` : "/admin/products", {
method: id.value ? "PUT" : "POST",
body: data,
});
void message.success("محصول ذخیره شد");
if (!id.value) await router.replace({ name: "product-edit", params: { id: saved.id } });
else await load();
} catch (e) {
handleError(e);
} finally {
saving.value = false;
}
}
// /products/new and /products/:id share this component
watch(id, load);
onMounted(load);
</script>
<template>
<PageHeader :title="id ? `ویرایش محصول: ${model.name}` : 'محصول جدید'" back="/products">
<a
v-if="id && category && model.isPublished"
:href="`${SITE_URL}/categories/${category.slug}`"
target="_blank"
rel="noopener"
>
<a-button>مشاهده در سایت</a-button>
</a>
<a-button type="primary" :loading="saving" @click="save">
<template #icon><SaveOutlined /></template>
ذخیره
</a-button>
</PageHeader>
<a-spin :spinning="loading">
<a-form layout="vertical" @submit.prevent="save">
<div class="grid gap-6 xl:grid-cols-3">
<div class="space-y-6 xl:col-span-2">
<a-card title="اطلاعات محصول">
<div class="grid gap-x-4 md:grid-cols-2">
<FormField path="name" label="نام محصول" required>
<a-input v-model:value="model.name" />
</FormField>
<FormField path="nameEn" label="نام انگلیسی">
<a-input v-model:value="model.nameEn" class="ltr-input" />
</FormField>
<FormField path="code" label="کد محصول" required hint="یکتا، مثلاً IP-G20">
<a-input v-model:value="model.code" class="ltr-input" />
</FormField>
<FormField path="slug" label="اسلاگ" required>
<a-input v-model:value="model.slug" class="ltr-input">
<template #suffix>
<a class="text-xs" @click="model.slug = toSlug(model.code || model.nameEn || '')">ساخت از کد</a>
</template>
</a-input>
</FormField>
<FormField path="desc" label="توضیحات" class="md:col-span-2">
<a-textarea v-model:value="model.desc" :auto-size="{ minRows: 3, maxRows: 8 }" />
</FormField>
<FormField path="specs" label="ویژگی‌ها" hint="هر ویژگی را بنویسید و Enter بزنید" class="md:col-span-2">
<a-select v-model:value="model.specs" mode="tags" :open="false" placeholder="مثلاً: ضد برش" />
</FormField>
<FormField path="variants" label="مدل‌ها / رنگ‌ها" hint="اختیاری — هر مورد را بنویسید و Enter بزنید" class="md:col-span-2">
<a-select v-model:value="model.variants" mode="tags" :open="false" placeholder="مثلاً: مشکی/زرد" />
</FormField>
</div>
</a-card>
<a-card title="گالری تصاویر (اختیاری)">
<div class="flex flex-wrap gap-3">
<div v-for="m in gallery" :key="m.id" class="relative size-24 overflow-hidden rounded-lg border border-gray-200 bg-gray-50">
<img :src="m.url" alt="" class="size-full object-contain" />
<button
type="button"
class="absolute end-1 top-1 flex size-6 items-center justify-center rounded-full bg-white/90 text-red-500 shadow"
title="حذف از گالری"
@click="removeFromGallery(m.id)"
>
<CloseOutlined class="text-xs" />
</button>
</div>
<button
type="button"
class="flex size-24 items-center justify-center rounded-lg border border-dashed border-gray-300 text-gray-400 hover:border-[#ff5a00] hover:text-[#ff5a00]"
@click="galleryPicker = true"
>
<PlusOutlined />
</button>
</div>
<MediaPicker v-model:open="galleryPicker" @select="addToGallery" />
</a-card>
</div>
<div class="space-y-6">
<a-card title="انتشار">
<FormField path="isPublished">
<a-switch v-model:checked="model.isPublished" />
<span class="ms-2">{{ model.isPublished ? "در سایت نمایش داده می‌شود" : "پیش‌نویس (مخفی)" }}</span>
</FormField>
</a-card>
<a-card title="دسته‌بندی">
<FormField path="categoryId" label="دسته" required>
<a-select
v-model:value="model.categoryId"
placeholder="انتخاب دسته"
:options="categories.map((c) => ({ value: c.id, label: c.name }))"
@change="loadGroups(true)"
/>
</FormField>
<FormField path="groupId" label="زیرگروه">
<a-select
v-model:value="groupId"
allow-clear
:disabled="!groups.length"
:placeholder="groups.length ? 'بدون زیرگروه' : 'این دسته زیرگروه ندارد'"
:options="groups.map((g) => ({ value: g.id, label: g.name }))"
/>
</FormField>
</a-card>
<a-card title="تصویر اصلی">
<FormField path="imageId" hint="بدون تصویر، کاور دسته نمایش داده می‌شود">
<ImageField :url="image?.url" clearable @pick="pickImage" @clear="image = null; model.imageId = null" />
</FormField>
</a-card>
</div>
</div>
</a-form>
</a-spin>
</template>