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,54 @@
|
||||
<script setup lang="ts">
|
||||
import type { SectionData } from "@imen/shared";
|
||||
import FormField from "@/components/FormField.vue";
|
||||
import IconSelect from "@/components/IconSelect.vue";
|
||||
import ImageRefField from "@/components/ImageRefField.vue";
|
||||
import ListEditor from "@/components/ListEditor.vue";
|
||||
import SectionHeadFields from "@/components/SectionHeadFields.vue";
|
||||
|
||||
const data = defineModel<SectionData<"about">>({ required: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid gap-6 xl:grid-cols-3">
|
||||
<div class="space-y-6 xl:col-span-2">
|
||||
<a-card title="عنوان و متن">
|
||||
<SectionHeadFields v-model="data.head" :with-desc="false" />
|
||||
<FormField path="body" label="متن درباره ما" required>
|
||||
<a-textarea v-model:value="data.body" :auto-size="{ minRows: 4, maxRows: 10 }" />
|
||||
</FormField>
|
||||
</a-card>
|
||||
<a-card title="تایملاین" :body-style="{ background: '#fafafa' }">
|
||||
<ListEditor v-model="data.timeline" :create="() => ({ year: '', text: '' })" :max="12" add-label="افزودن رویداد" :item-title="(t) => t.year">
|
||||
<template #default="{ item, index }">
|
||||
<div class="grid gap-x-4 md:grid-cols-4">
|
||||
<FormField :path="`timeline.${index}.year`" label="سال" required><a-input v-model:value="item.year" /></FormField>
|
||||
<FormField :path="`timeline.${index}.text`" label="رویداد" required class="md:col-span-3"><a-input v-model:value="item.text" /></FormField>
|
||||
</div>
|
||||
</template>
|
||||
</ListEditor>
|
||||
</a-card>
|
||||
<a-card title="ویژگیها" :body-style="{ background: '#fafafa' }">
|
||||
<ListEditor v-model="data.features" :create="() => ({ icon: 'ShieldCheck', label: '' })" :max="6" add-label="افزودن ویژگی" :item-title="(f) => f.label">
|
||||
<template #default="{ item, index }">
|
||||
<div class="grid gap-x-4 md:grid-cols-2">
|
||||
<FormField :path="`features.${index}.label`" label="متن" required><a-input v-model:value="item.label" /></FormField>
|
||||
<FormField :path="`features.${index}.icon`" label="آیکون"><IconSelect v-model="item.icon" /></FormField>
|
||||
</div>
|
||||
</template>
|
||||
</ListEditor>
|
||||
</a-card>
|
||||
</div>
|
||||
<div class="space-y-6">
|
||||
<a-card title="تصویر">
|
||||
<ImageRefField v-model="data.image" path="image" label="تصویر" />
|
||||
</a-card>
|
||||
<a-card title="نشان روی تصویر">
|
||||
<FormField path="badge.value" label="عدد" required><a-input v-model:value="data.badge.value" /></FormField>
|
||||
<FormField path="badge.label" label="متن" hint="برای شکستن خط Enter بزنید" required>
|
||||
<a-textarea v-model:value="data.badge.label" :auto-size="{ minRows: 2 }" />
|
||||
</FormField>
|
||||
</a-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import type { SectionData } from "@imen/shared";
|
||||
import FormField from "@/components/FormField.vue";
|
||||
import SectionHeadFields from "@/components/SectionHeadFields.vue";
|
||||
|
||||
const data = defineModel<SectionData<"contact">>({ required: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<a-card title="عنوان بخش">
|
||||
<SectionHeadFields v-model="data.head" />
|
||||
<p class="text-sm text-gray-500">نشانی، تلفن، ایمیل و ساعات کاری از «تنظیمات سایت» خوانده میشوند.</p>
|
||||
</a-card>
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
<a-card title="کادر پاسخگویی اضطراری">
|
||||
<FormField path="emergencyTitle" label="عنوان" required><a-input v-model:value="data.emergencyTitle" /></FormField>
|
||||
<FormField path="emergencyText" label="متن" required>
|
||||
<a-textarea v-model:value="data.emergencyText" :auto-size="{ minRows: 2, maxRows: 5 }" />
|
||||
</FormField>
|
||||
</a-card>
|
||||
<a-card title="فرم">
|
||||
<FormField path="formTag" label="برچسب بالای فرم"><a-input v-model:value="data.formTag" /></FormField>
|
||||
<FormField path="formTitle" label="عنوان فرم" hint="برای شکستن خط Enter بزنید" required>
|
||||
<a-textarea v-model:value="data.formTitle" :auto-size="{ minRows: 2 }" />
|
||||
</FormField>
|
||||
<FormField path="successTitle" label="عنوان پیام موفقیت" required><a-input v-model:value="data.successTitle" /></FormField>
|
||||
<FormField path="successText" label="متن پیام موفقیت" required>
|
||||
<a-textarea v-model:value="data.successText" :auto-size="{ minRows: 2 }" />
|
||||
</FormField>
|
||||
</a-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import type { SectionData } from "@imen/shared";
|
||||
import FormField from "@/components/FormField.vue";
|
||||
|
||||
const data = defineModel<SectionData<"footer">>({ required: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-card title="فوتر">
|
||||
<FormField path="about" label="متن معرفی" required>
|
||||
<a-textarea v-model:value="data.about" :auto-size="{ minRows: 2, maxRows: 5 }" />
|
||||
</FormField>
|
||||
<div class="grid gap-x-4 md:grid-cols-3">
|
||||
<FormField path="copyright" label="متن کپیرایت" required><a-input v-model:value="data.copyright" /></FormField>
|
||||
<FormField path="certLine" label="خط گواهینامهها"><a-input v-model:value="data.certLine" class="ltr-input" /></FormField>
|
||||
<FormField path="emergencyNote" label="یادداشت پشتیبانی"><a-input v-model:value="data.emergencyNote" /></FormField>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500">لینک دستهها و اطلاعات تماس فوتر بهصورت خودکار از دستهبندیها و تنظیمات سایت ساخته میشوند.</p>
|
||||
</a-card>
|
||||
</template>
|
||||
@@ -0,0 +1,78 @@
|
||||
<script setup lang="ts">
|
||||
import type { SectionData } from "@imen/shared";
|
||||
import FormField from "@/components/FormField.vue";
|
||||
import IconSelect from "@/components/IconSelect.vue";
|
||||
import ImageRefField from "@/components/ImageRefField.vue";
|
||||
import ListEditor from "@/components/ListEditor.vue";
|
||||
|
||||
const data = defineModel<SectionData<"hero">>({ required: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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="badge" label="نشان بالای تیتر" required class="md:col-span-2">
|
||||
<a-input v-model:value="data.badge" />
|
||||
</FormField>
|
||||
<FormField path="title" label="تیتر (خط اول)" required>
|
||||
<a-input v-model:value="data.title" />
|
||||
</FormField>
|
||||
<FormField path="titleHighlight" label="تیتر (خط زرد)" required>
|
||||
<a-input v-model:value="data.titleHighlight" />
|
||||
</FormField>
|
||||
<FormField path="intro" label="متن معرفی" required class="md:col-span-2">
|
||||
<a-textarea v-model:value="data.intro" :auto-size="{ minRows: 3, maxRows: 6 }" />
|
||||
</FormField>
|
||||
</div>
|
||||
</a-card>
|
||||
<a-card title="دکمهها">
|
||||
<div class="grid gap-x-4 md:grid-cols-2">
|
||||
<FormField path="primaryCta.label" label="دکمه اصلی — متن" required>
|
||||
<a-input v-model:value="data.primaryCta.label" />
|
||||
</FormField>
|
||||
<FormField path="primaryCta.href" label="دکمه اصلی — لینک" hint="مثلاً #contact یا /categories/..." required>
|
||||
<a-input v-model:value="data.primaryCta.href" class="ltr-input" />
|
||||
</FormField>
|
||||
<FormField path="secondaryCta.label" label="دکمه دوم — متن" required>
|
||||
<a-input v-model:value="data.secondaryCta.label" />
|
||||
</FormField>
|
||||
<FormField path="secondaryCta.href" label="دکمه دوم — لینک" required>
|
||||
<a-input v-model:value="data.secondaryCta.href" class="ltr-input" />
|
||||
</FormField>
|
||||
</div>
|
||||
</a-card>
|
||||
<a-card title="نشانهای گواهی" :body-style="{ background: '#fafafa' }">
|
||||
<ListEditor v-model="data.chips" :create="() => ({ icon: 'ShieldCheck', label: '' })" :max="6" add-label="افزودن نشان" :item-title="(c) => c.label">
|
||||
<template #default="{ item, index }">
|
||||
<div class="grid gap-x-4 md:grid-cols-2">
|
||||
<FormField :path="`chips.${index}.label`" label="متن" required><a-input v-model:value="item.label" /></FormField>
|
||||
<FormField :path="`chips.${index}.icon`" label="آیکون"><IconSelect v-model="item.icon" /></FormField>
|
||||
</div>
|
||||
</template>
|
||||
</ListEditor>
|
||||
</a-card>
|
||||
<a-card title="آمار پایین Hero" :body-style="{ background: '#fafafa' }">
|
||||
<ListEditor v-model="data.miniStats" :create="() => ({ value: '', label: '' })" :max="4" add-label="افزودن آمار" :item-title="(s) => s.value">
|
||||
<template #default="{ item, index }">
|
||||
<div class="grid gap-x-4 md:grid-cols-3">
|
||||
<FormField :path="`miniStats.${index}.value`" label="عدد" required><a-input v-model:value="item.value" placeholder="۱۸+" /></FormField>
|
||||
<FormField :path="`miniStats.${index}.label`" label="توضیح" required class="md:col-span-2"><a-input v-model:value="item.label" /></FormField>
|
||||
</div>
|
||||
</template>
|
||||
</ListEditor>
|
||||
</a-card>
|
||||
</div>
|
||||
<div class="space-y-6">
|
||||
<a-card title="تصویر پسزمینه">
|
||||
<ImageRefField v-model="data.image" path="image" label="تصویر" />
|
||||
</a-card>
|
||||
<a-card title="کارت وضعیت">
|
||||
<FormField path="lastInspection" label="تاریخ آخرین بازرسی" hint="خالی = نمایش داده نمیشود">
|
||||
<a-input v-model:value="data.lastInspection" placeholder="۱۴۰۴/۰۸/۱۲" />
|
||||
</FormField>
|
||||
</a-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import type { SectionData } from "@imen/shared";
|
||||
import FormField from "@/components/FormField.vue";
|
||||
import ImageRefField from "@/components/ImageRefField.vue";
|
||||
import ListEditor from "@/components/ListEditor.vue";
|
||||
import SectionHeadFields from "@/components/SectionHeadFields.vue";
|
||||
|
||||
const data = defineModel<SectionData<"industries">>({ required: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<a-card title="عنوان بخش">
|
||||
<SectionHeadFields v-model="data.head" />
|
||||
</a-card>
|
||||
<a-card title="صنایع" :body-style="{ background: '#fafafa' }">
|
||||
<ListEditor
|
||||
v-model="data.items"
|
||||
:create="() => ({ name: '', nameEn: '', count: '', image: { url: '', alt: '' } })"
|
||||
:max="12"
|
||||
add-label="افزودن صنعت"
|
||||
:item-title="(s) => s.name"
|
||||
>
|
||||
<template #default="{ item, index }">
|
||||
<div class="grid gap-x-6 lg:grid-cols-2">
|
||||
<div>
|
||||
<FormField :path="`items.${index}.name`" label="نام" required><a-input v-model:value="item.name" /></FormField>
|
||||
<FormField :path="`items.${index}.nameEn`" label="نام انگلیسی"><a-input v-model:value="item.nameEn" class="ltr-input" /></FormField>
|
||||
<FormField :path="`items.${index}.count`" label="تعداد پروژه" hint="مثلاً ۱۴۰+ — خالی = نمایش داده نمیشود"><a-input v-model:value="item.count" /></FormField>
|
||||
</div>
|
||||
<div>
|
||||
<ImageRefField v-model="item.image" :path="`items.${index}.image`" label="تصویر" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ListEditor>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import type { SectionData } from "@imen/shared";
|
||||
import FormField from "@/components/FormField.vue";
|
||||
import IconSelect from "@/components/IconSelect.vue";
|
||||
import ListEditor from "@/components/ListEditor.vue";
|
||||
import SectionHeadFields from "@/components/SectionHeadFields.vue";
|
||||
|
||||
const data = defineModel<SectionData<"productsIntro">>({ required: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<a-card title="عنوان بخش">
|
||||
<SectionHeadFields v-model="data.head" />
|
||||
<FormField path="ctaLabel" label="متن دکمه کاتالوگ" required>
|
||||
<a-input v-model:value="data.ctaLabel" />
|
||||
</FormField>
|
||||
<p class="text-sm text-gray-500">کارتهای دستهبندی از بخش «دستهبندیها» مدیریت میشوند.</p>
|
||||
</a-card>
|
||||
<a-card title="کارتهای اعتماد" :body-style="{ background: '#fafafa' }">
|
||||
<ListEditor v-model="data.trust" :create="() => ({ icon: 'BadgeCheck', title: '', desc: '' })" :max="6" add-label="افزودن کارت" :item-title="(t) => t.title">
|
||||
<template #default="{ item, index }">
|
||||
<div class="grid gap-x-4 md:grid-cols-3">
|
||||
<FormField :path="`trust.${index}.title`" label="عنوان" required><a-input v-model:value="item.title" /></FormField>
|
||||
<FormField :path="`trust.${index}.desc`" label="توضیح"><a-input v-model:value="item.desc" /></FormField>
|
||||
<FormField :path="`trust.${index}.icon`" label="آیکون"><IconSelect v-model="item.icon" /></FormField>
|
||||
</div>
|
||||
</template>
|
||||
</ListEditor>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import type { SectionData } from "@imen/shared";
|
||||
import FormField from "@/components/FormField.vue";
|
||||
import IconSelect from "@/components/IconSelect.vue";
|
||||
import ListEditor from "@/components/ListEditor.vue";
|
||||
import SectionHeadFields from "@/components/SectionHeadFields.vue";
|
||||
|
||||
type Service = SectionData<"services">["items"][number];
|
||||
|
||||
const data = defineModel<SectionData<"services">>({ required: true });
|
||||
const newService = (): Service => ({ icon: "ClipboardCheck", title: "", desc: "", items: [] });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<a-card title="عنوان بخش">
|
||||
<SectionHeadFields v-model="data.head" />
|
||||
</a-card>
|
||||
<a-card title="خدمات" :body-style="{ background: '#fafafa' }">
|
||||
<ListEditor v-model="data.items" :create="newService" :max="10" add-label="افزودن خدمت" :item-title="(s) => s.title">
|
||||
<template #default="{ item, index }">
|
||||
<div class="grid gap-x-4 md:grid-cols-2">
|
||||
<FormField :path="`items.${index}.title`" label="عنوان" required><a-input v-model:value="item.title" /></FormField>
|
||||
<FormField :path="`items.${index}.icon`" label="آیکون"><IconSelect v-model="item.icon" /></FormField>
|
||||
<FormField :path="`items.${index}.desc`" label="توضیح" required class="md:col-span-2">
|
||||
<a-textarea v-model:value="item.desc" :auto-size="{ minRows: 2, maxRows: 5 }" />
|
||||
</FormField>
|
||||
<FormField :path="`items.${index}.items`" label="موارد (فهرست)" hint="هر مورد را بنویسید و Enter بزنید" class="md:col-span-2">
|
||||
<a-select v-model:value="item.items" mode="tags" :open="false" />
|
||||
</FormField>
|
||||
</div>
|
||||
</template>
|
||||
</ListEditor>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,132 @@
|
||||
<script setup lang="ts">
|
||||
import { type MediaDto, type SectionData, SOCIAL_TYPES, type SocialType, WEEK_DAYS } from "@imen/shared";
|
||||
import FormField from "@/components/FormField.vue";
|
||||
import ImageField from "@/components/ImageField.vue";
|
||||
import ListEditor from "@/components/ListEditor.vue";
|
||||
|
||||
const data = defineModel<SectionData<"settings">>({ required: true });
|
||||
|
||||
const DAY_LABELS: Record<(typeof WEEK_DAYS)[number], string> = {
|
||||
Saturday: "شنبه",
|
||||
Sunday: "یکشنبه",
|
||||
Monday: "دوشنبه",
|
||||
Tuesday: "سهشنبه",
|
||||
Wednesday: "چهارشنبه",
|
||||
Thursday: "پنجشنبه",
|
||||
Friday: "جمعه",
|
||||
};
|
||||
const SOCIAL_LABELS: Record<SocialType, string> = {
|
||||
website: "وبسایت",
|
||||
telegram: "تلگرام",
|
||||
instagram: "اینستاگرام",
|
||||
linkedin: "لینکدین",
|
||||
whatsapp: "واتساپ",
|
||||
bale: "بله",
|
||||
eitaa: "ایتا",
|
||||
aparat: "آپارات",
|
||||
youtube: "یوتیوب",
|
||||
email: "ایمیل (mailto:)",
|
||||
phone: "تلفن (tel:)",
|
||||
};
|
||||
|
||||
type Social = SectionData<"settings">["socials"][number];
|
||||
const URL_PLACEHOLDER = "https://t.me/...";
|
||||
const newSocial = (): Social => ({ type: "telegram", url: "" });
|
||||
const socialTitle = (s: Social) => SOCIAL_LABELS[s.type];
|
||||
const SOCIAL_OPTIONS = SOCIAL_TYPES.map((t) => ({ value: t, label: SOCIAL_LABELS[t] }));
|
||||
const DAY_OPTIONS = WEEK_DAYS.map((d) => ({ value: d, label: DAY_LABELS[d] }));
|
||||
|
||||
function pickOgImage(m: MediaDto) {
|
||||
data.value.ogImage = { url: m.url, alt: data.value.ogImage.alt || m.alt, width: m.width, height: m.height };
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid gap-6 xl:grid-cols-2">
|
||||
<a-card title="هویت و سئوی پیشفرض">
|
||||
<div class="grid gap-x-4 md:grid-cols-2">
|
||||
<FormField path="name" label="نام شرکت" required><a-input v-model:value="data.name" /></FormField>
|
||||
<FormField path="nameEn" label="نام انگلیسی" required><a-input v-model:value="data.nameEn" class="ltr-input" /></FormField>
|
||||
<FormField path="title" label="عنوان پیشفرض صفحات (title)" required class="md:col-span-2">
|
||||
<a-input v-model:value="data.title" :maxlength="120" show-count />
|
||||
</FormField>
|
||||
<FormField path="description" label="توضیحات متا (description)" required class="md:col-span-2">
|
||||
<a-textarea v-model:value="data.description" :maxlength="400" show-count :auto-size="{ minRows: 3 }" />
|
||||
</FormField>
|
||||
<FormField path="foundingDate" label="سال تأسیس (میلادی)" required>
|
||||
<a-input v-model:value="data.foundingDate" class="ltr-input" placeholder="2007" />
|
||||
</FormField>
|
||||
</div>
|
||||
<FormField path="ogImage.url" label="تصویر اشتراکگذاری (Open Graph)" hint="ترجیحاً ۱۲۰۰×۶۳۰ پیکسل" required>
|
||||
<ImageField :url="data.ogImage.url" @pick="pickOgImage" />
|
||||
</FormField>
|
||||
<FormField path="ogImage.alt" label="متن جایگزین تصویر">
|
||||
<a-input v-model:value="data.ogImage.alt" />
|
||||
</FormField>
|
||||
</a-card>
|
||||
|
||||
<a-card title="اطلاعات تماس">
|
||||
<div class="grid gap-x-4 md:grid-cols-2">
|
||||
<FormField path="phoneDisplay" label="تلفن (نمایشی)" required>
|
||||
<a-input v-model:value="data.phoneDisplay" />
|
||||
</FormField>
|
||||
<FormField path="phone" label="تلفن (برای لینک و گوگل)" hint="با کد کشور، مثلاً +98-21-91008800" required>
|
||||
<a-input v-model:value="data.phone" class="ltr-input" />
|
||||
</FormField>
|
||||
<FormField path="email" label="ایمیل" required class="md:col-span-2">
|
||||
<a-input v-model:value="data.email" class="ltr-input" />
|
||||
</FormField>
|
||||
<FormField path="address.locality" label="شهر" required>
|
||||
<a-input v-model:value="data.address.locality" />
|
||||
</FormField>
|
||||
<FormField path="address.country" label="کد کشور" required>
|
||||
<a-input v-model:value="data.address.country" class="ltr-input" :maxlength="2" />
|
||||
</FormField>
|
||||
<FormField path="address.street" label="نشانی" required class="md:col-span-2">
|
||||
<a-input v-model:value="data.address.street" />
|
||||
</FormField>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<a-card title="ساعات کاری">
|
||||
<FormField path="workingHours" label="متن نمایشی" required>
|
||||
<a-input v-model:value="data.workingHours" />
|
||||
</FormField>
|
||||
<FormField path="openingHours.days" label="روزهای کاری (برای گوگل)">
|
||||
<a-checkbox-group
|
||||
v-model:value="data.openingHours.days"
|
||||
:options="DAY_OPTIONS"
|
||||
/>
|
||||
</FormField>
|
||||
<div class="grid gap-x-4 md:grid-cols-2">
|
||||
<FormField path="openingHours.opens" label="ساعت شروع" required>
|
||||
<a-input v-model:value="data.openingHours.opens" class="ltr-input" placeholder="08:00" />
|
||||
</FormField>
|
||||
<FormField path="openingHours.closes" label="ساعت پایان" required>
|
||||
<a-input v-model:value="data.openingHours.closes" class="ltr-input" placeholder="17:00" />
|
||||
</FormField>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<a-card title="شبکههای اجتماعی و لینکها" :body-style="{ background: '#fafafa' }">
|
||||
<ListEditor
|
||||
v-model="data.socials"
|
||||
:create="newSocial"
|
||||
:max="12"
|
||||
add-label="افزودن لینک"
|
||||
:item-title="socialTitle"
|
||||
>
|
||||
<template #default="{ item, index }">
|
||||
<div class="grid gap-x-4 md:grid-cols-3">
|
||||
<FormField :path="`socials.${index}.type`" label="نوع">
|
||||
<a-select v-model:value="item.type" :options="SOCIAL_OPTIONS" />
|
||||
</FormField>
|
||||
<FormField :path="`socials.${index}.url`" label="آدرس" required class="md:col-span-2">
|
||||
<a-input v-model:value="item.url" class="ltr-input" :placeholder="URL_PLACEHOLDER" />
|
||||
</FormField>
|
||||
</div>
|
||||
</template>
|
||||
</ListEditor>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import type { SectionData } from "@imen/shared";
|
||||
import FormField from "@/components/FormField.vue";
|
||||
import ListEditor from "@/components/ListEditor.vue";
|
||||
import SectionHeadFields from "@/components/SectionHeadFields.vue";
|
||||
|
||||
const data = defineModel<SectionData<"standards">>({ required: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<a-card title="عنوان و متن">
|
||||
<SectionHeadFields v-model="data.head" />
|
||||
<div class="grid gap-x-4 md:grid-cols-2">
|
||||
<FormField path="note" label="کادر توضیح (اختیاری)">
|
||||
<a-textarea v-model:value="data.note" :auto-size="{ minRows: 2, maxRows: 5 }" />
|
||||
</FormField>
|
||||
<FormField path="ctaLabel" label="متن دکمه" required>
|
||||
<a-input v-model:value="data.ctaLabel" />
|
||||
</FormField>
|
||||
</div>
|
||||
</a-card>
|
||||
<a-card title="استانداردها" :body-style="{ background: '#fafafa' }">
|
||||
<ListEditor v-model="data.items" :create="() => ({ code: '', label: '' })" :max="12" add-label="افزودن استاندارد" :item-title="(s) => s.code">
|
||||
<template #default="{ item, index }">
|
||||
<div class="grid gap-x-4 md:grid-cols-2">
|
||||
<FormField :path="`items.${index}.code`" label="کد استاندارد" required><a-input v-model:value="item.code" class="ltr-input" placeholder="EN 397" /></FormField>
|
||||
<FormField :path="`items.${index}.label`" label="عنوان" required><a-input v-model:value="item.label" /></FormField>
|
||||
</div>
|
||||
</template>
|
||||
</ListEditor>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import type { SectionData } from "@imen/shared";
|
||||
import FormField from "@/components/FormField.vue";
|
||||
import ListEditor from "@/components/ListEditor.vue";
|
||||
|
||||
const data = defineModel<SectionData<"stats">>({ required: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-card title="شمارندهها" :body-style="{ background: '#fafafa' }">
|
||||
<ListEditor v-model="data.items" :create="() => ({ end: 0, suffix: '+', label: '', note: '' })" :max="8" add-label="افزودن شمارنده" :item-title="(s) => s.label">
|
||||
<template #default="{ item, index }">
|
||||
<div class="grid gap-x-4 md:grid-cols-4">
|
||||
<FormField :path="`items.${index}.end`" label="عدد نهایی" required>
|
||||
<a-input-number v-model:value="item.end" :min="0" class="!w-full" />
|
||||
</FormField>
|
||||
<FormField :path="`items.${index}.suffix`" label="پسوند" hint="مثلاً + یا ٪">
|
||||
<a-input v-model:value="item.suffix" />
|
||||
</FormField>
|
||||
<FormField :path="`items.${index}.label`" label="عنوان" required>
|
||||
<a-input v-model:value="item.label" />
|
||||
</FormField>
|
||||
<FormField :path="`items.${index}.note`" label="یادداشت انگلیسی کوچک">
|
||||
<a-input v-model:value="item.note" class="ltr-input" />
|
||||
</FormField>
|
||||
</div>
|
||||
</template>
|
||||
</ListEditor>
|
||||
</a-card>
|
||||
</template>
|
||||
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import type { SectionData } from "@imen/shared";
|
||||
import FormField from "@/components/FormField.vue";
|
||||
|
||||
const data = defineModel<SectionData<"tape">>({ required: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-card title="عبارتهای نوار متحرک">
|
||||
<FormField path="items" label="عبارتها" hint="هر عبارت را بنویسید و Enter بزنید؛ برای حذف روی × کلیک کنید" required>
|
||||
<a-select v-model:value="data.items" mode="tags" :open="false" />
|
||||
</FormField>
|
||||
</a-card>
|
||||
</template>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import type { SectionData } from "@imen/shared";
|
||||
import FormField from "@/components/FormField.vue";
|
||||
import ListEditor from "@/components/ListEditor.vue";
|
||||
import SectionHeadFields from "@/components/SectionHeadFields.vue";
|
||||
|
||||
const data = defineModel<SectionData<"testimonials">>({ required: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<a-card title="عنوان بخش">
|
||||
<SectionHeadFields v-model="data.head" :with-highlight="false" :with-desc="false" />
|
||||
</a-card>
|
||||
<a-card title="نظرات" :body-style="{ background: '#fafafa' }">
|
||||
<ListEditor v-model="data.items" :create="() => ({ text: '', name: '', role: '' })" :max="12" add-label="افزودن نظر" :item-title="(q) => q.name">
|
||||
<template #default="{ item, index }">
|
||||
<div class="grid gap-x-4 md:grid-cols-2">
|
||||
<FormField :path="`items.${index}.text`" label="متن نظر" required class="md:col-span-2">
|
||||
<a-textarea v-model:value="item.text" :auto-size="{ minRows: 2, maxRows: 5 }" />
|
||||
</FormField>
|
||||
<FormField :path="`items.${index}.name`" label="نام" required><a-input v-model:value="item.name" /></FormField>
|
||||
<FormField :path="`items.${index}.role`" label="سمت و سازمان"><a-input v-model:value="item.role" /></FormField>
|
||||
</div>
|
||||
</template>
|
||||
</ListEditor>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user