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>
246 lines
9.3 KiB
Vue
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>
|