feat(admin): editable contact fields in sections and larger logos

Allow address, phone, map, and related site chrome to be edited from contact/settings for all editors, and enlarge brand marks on login, header, and preloader.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
S. Esmaeili
2026-11-12 18:42:59 +03:30
co-authored by Cursor
parent cad1a8652e
commit 9ad88af20f
26 changed files with 285 additions and 108 deletions
@@ -0,0 +1,53 @@
<script setup lang="ts">
import type { SectionData } from "@imen/shared";
import FormField from "@/components/FormField.vue";
/** Shared address / phone / email / hours fields from the settings section. */
const data = defineModel<SectionData<"settings">>({ required: true });
const props = withDefaults(
defineProps<{
/** Zod path prefix when nested under another form context */
pathPrefix?: string;
}>(),
{ pathPrefix: "" },
);
function path(field: string) {
return props.pathPrefix ? `${props.pathPrefix}.${field}` : field;
}
</script>
<template>
<div class="grid gap-x-4 md:grid-cols-2">
<FormField :path="path('phoneDisplay')" label="تلفن (نمایشی)" required>
<a-input v-model:value="data.phoneDisplay" />
</FormField>
<FormField :path="path('phone')" label="تلفن (برای لینک و گوگل)" hint="با کد کشور، مثلاً ‎+98-21-91008800" required>
<a-input v-model:value="data.phone" class="ltr-input" />
</FormField>
<FormField :path="path('email')" label="ایمیل" required class="md:col-span-2">
<a-input v-model:value="data.email" class="ltr-input" />
</FormField>
<FormField :path="path('address.locality')" label="شهر" required>
<a-input v-model:value="data.address.locality" />
</FormField>
<FormField :path="path('address.country')" label="کد کشور" required>
<a-input v-model:value="data.address.country" class="ltr-input" :maxlength="2" />
</FormField>
<FormField :path="path('address.street')" label="نشانی" required class="md:col-span-2">
<a-input v-model:value="data.address.street" />
</FormField>
<FormField
:path="path('mapUrl')"
label="لینک نقشه"
hint="اختیاری — لینک گوگل‌مپ، نشان و …"
class="md:col-span-2"
>
<a-input v-model:value="data.mapUrl" class="ltr-input" placeholder="https://maps.google.com/..." />
</FormField>
<FormField :path="path('workingHours')" label="ساعات کاری (متن نمایشی)" required class="md:col-span-2">
<a-input v-model:value="data.workingHours" />
</FormField>
</div>
</template>
+2 -2
View File
@@ -107,9 +107,9 @@ async function submit() {
<div class="mt-8 flex items-center gap-4">
<span
class="flex size-14 items-center justify-center rounded-xl bg-white shadow-[0_0_28px_rgba(10,30,92,0.45)]"
class="flex size-20 items-center justify-center overflow-hidden rounded-2xl bg-white shadow-[0_0_28px_rgba(10,30,92,0.45)]"
>
<img src="/logo.png" alt="ایمن پیشگامان" class="size-9 object-contain" />
<img src="/logo.png" alt="ایمن پیشگامان" class="size-full scale-110 object-contain object-center" />
</span>
<div class="leading-tight">
<div class="text-2xl font-black tracking-tight md:text-3xl">ایمن پیشگامان</div>
+58 -22
View File
@@ -1,5 +1,12 @@
<script setup lang="ts">
import { isSectionKey, SECTION_LABELS, type SectionKey, sectionSchemas } from "@imen/shared";
import {
isSectionKey,
SECTION_LABELS,
type SectionData,
type SectionKey,
sectionSchemas,
settingsSectionSchema,
} from "@imen/shared";
import { message } from "ant-design-vue";
import { type Component, computed, ref } from "vue";
import { useRoute } from "vue-router";
@@ -8,7 +15,6 @@ import VuesaxIcon from "@/components/VuesaxIcon.vue";
import type { SectionResponse } from "@/api/types";
import PageHeader from "@/components/PageHeader.vue";
import { useZodForm } from "@/composables/useZodForm";
import { useAuthStore } from "@/stores/auth";
import { jalali, SITE_URL } from "@/utils/format";
import AboutForm from "@/sections/AboutForm.vue";
import ContactForm from "@/sections/ContactForm.vue";
@@ -38,7 +44,6 @@ const FORMS: Record<SectionKey, Component> = {
footer: FooterForm,
};
const auth = useAuthStore();
const route = useRoute();
const key = String(route.params.key);
const valid = isSectionKey(key);
@@ -49,6 +54,14 @@ const { model, validate, handleError, reset } = useZodForm(
sectionSchemas[sectionKey] as (typeof sectionSchemas)["hero"],
{} as never,
);
/** Contact page also edits site-wide contact info from settings */
const settingsModel = ref<SectionData<"settings"> | null>(null);
const contactModel = computed({
get: () => model.value as unknown as SectionData<"contact">,
set: (v: SectionData<"contact">) => {
model.value = v as never;
},
});
const loaded = ref(false);
const saving = ref(false);
const updatedAt = ref<string | null>(null);
@@ -57,12 +70,20 @@ const label = computed(() => (valid ? SECTION_LABELS[sectionKey] : null));
async function load() {
try {
const res = await api<SectionResponse>(`/admin/sections/${sectionKey}`);
const data = res.data as any;
const data = res.data as Record<string, unknown>;
if (sectionKey !== "settings" && data.enabled === undefined) {
data.enabled = true;
}
reset(data as never);
updatedAt.value = res.updatedAt;
if (sectionKey === "contact") {
const settingsRes = await api<SectionResponse>("/admin/sections/settings");
settingsModel.value = settingsSectionSchema.parse(settingsRes.data);
} else {
settingsModel.value = null;
}
loaded.value = true;
} catch (e) {
void message.error(errorMessage(e));
@@ -72,8 +93,25 @@ async function load() {
async function save() {
const data = validate();
if (!data) return;
if (sectionKey === "contact" && settingsModel.value) {
const settingsParsed = settingsSectionSchema.safeParse(settingsModel.value);
if (!settingsParsed.success) {
void message.error("اطلاعات تماس معتبر نیست — نشانی، تلفن یا ایمیل را بررسی کنید");
return;
}
}
saving.value = true;
try {
if (sectionKey === "contact" && settingsModel.value) {
const settingsRes = await api<SectionResponse>("/admin/sections/settings", {
method: "PUT",
body: settingsSectionSchema.parse(settingsModel.value),
});
settingsModel.value = settingsSectionSchema.parse(settingsRes.data);
}
const res = await api<SectionResponse>(`/admin/sections/${sectionKey}`, { method: "PUT", body: data });
reset(res.data as never);
updatedAt.value = res.updatedAt;
@@ -87,9 +125,9 @@ async function save() {
const togglingVisibility = ref(false);
async function onVisibilityChange(val: any) {
async function onVisibilityChange(val: unknown) {
const nextVal = Boolean(val);
(model.value as any).enabled = nextVal;
(model.value as { enabled?: boolean }).enabled = nextVal;
togglingVisibility.value = true;
try {
const res = await api<{ key: string; enabled: boolean; updatedAt: string }>(
@@ -99,13 +137,13 @@ async function onVisibilityChange(val: any) {
body: { enabled: nextVal },
},
);
(model.value as any).enabled = res.enabled !== false;
(model.value as { enabled?: boolean }).enabled = res.enabled !== false;
updatedAt.value = res.updatedAt;
void message.success(
`وضعیت نمایش به ${nextVal ? "نمایش" : "عدم نمایش"} تغییر یافت`,
);
} catch (e) {
(model.value as any).enabled = !nextVal;
(model.value as { enabled?: boolean }).enabled = !nextVal;
void message.error(errorMessage(e));
} finally {
togglingVisibility.value = false;
@@ -126,7 +164,7 @@ if (valid) void load();
<div v-if="sectionKey !== 'settings'" class="max-lg:hidden flex items-center gap-2 me-2">
<span class="text-sm font-medium text-gray-700">نمایش در سایت:</span>
<a-switch
:checked="(model as any)?.enabled !== false"
:checked="(model as { enabled?: boolean })?.enabled !== false"
:loading="togglingVisibility"
checked-children="نمایش"
un-checked-children="مخفی"
@@ -138,7 +176,7 @@ if (valid) void load();
type="primary"
class="max-lg:hidden"
:loading="saving"
:disabled="!loaded || (sectionKey === 'settings' && !auth.isAdmin)"
:disabled="!loaded"
@click="save"
>
<template #icon><VuesaxIcon name="save-2" :size="16" /></template>
@@ -147,21 +185,19 @@ if (valid) void load();
</PageHeader>
<a-spin :spinning="!loaded">
<a-alert
v-if="loaded && sectionKey === 'settings' && !auth.isAdmin"
type="error"
show-icon
message="فقط مدیر کل می‌تواند تنظیمات سایت را تغییر دهد."
class="mb-4"
/>
<a-alert
v-else-if="loaded && sectionKey !== 'settings' && (model as any).enabled === false"
v-if="loaded && sectionKey !== 'settings' && (model as { enabled?: boolean }).enabled === false"
type="warning"
show-icon
message="این بخش در حال حاضر در سایت نمایش داده نمی‌شود (مخفی است)."
class="mb-4"
/>
<a-form v-if="loaded" layout="vertical" class="pb-20 lg:pb-0" @submit.prevent="save">
<component :is="FORMS[sectionKey]" v-model="model" />
<ContactForm
v-if="sectionKey === 'contact'"
v-model="contactModel"
v-model:settings="settingsModel"
/>
<component v-else :is="FORMS[sectionKey]" v-model="model" />
</a-form>
</a-spin>
@@ -169,13 +205,13 @@ if (valid) void load();
<div class="lg:hidden fixed bottom-0 inset-x-0 z-30 border-t border-gray-200 bg-white/95 px-4 py-2.5 backdrop-blur-md shadow-lg flex items-center justify-between gap-3">
<div v-if="sectionKey !== 'settings'" class="flex items-center gap-2">
<a-switch
:checked="(model as any)?.enabled !== false"
:checked="(model as { enabled?: boolean })?.enabled !== false"
:loading="togglingVisibility"
size="small"
@change="onVisibilityChange"
/>
<span class="text-xs font-medium text-gray-700">
{{ (model as any)?.enabled !== false ? 'نمایش در سایت' : 'مخفی' }}
{{ (model as { enabled?: boolean })?.enabled !== false ? 'نمایش در سایت' : 'مخفی' }}
</span>
</div>
<div v-else class="text-xs text-gray-500 font-medium">تنظیمات پایه</div>
@@ -191,7 +227,7 @@ if (valid) void load();
size="middle"
class="!font-bold !px-5"
:loading="saving"
:disabled="!loaded || (sectionKey === 'settings' && !auth.isAdmin)"
:disabled="!loaded"
@click="save"
>
<template #icon><VuesaxIcon name="save-2" :size="16" /></template>
+1 -4
View File
@@ -6,13 +6,10 @@ import { useRouter } from "vue-router";
import { api, errorMessage } from "@/api/http";
import type { AdminSectionSummary } from "@/api/types";
import PageHeader from "@/components/PageHeader.vue";
import { useAuthStore } from "@/stores/auth";
import { jalali } from "@/utils/format";
const router = useRouter();
const auth = useAuthStore();
// site-wide settings are editable by admins only
const keys = SECTION_KEYS.filter((k) => k !== "settings" || auth.isAdmin);
const keys = SECTION_KEYS;
const loading = ref(true);
const sectionEnabled = reactive<Record<string, boolean>>({
+4 -6
View File
@@ -103,7 +103,7 @@ onMounted(load);
</script>
<template>
<PageHeader title="کاربران پنل" subtitle="مدیر کل به همه بخش‌ها دسترسی دارد؛ ویرایشگر به جز کاربران و تنظیمات سایت">
<PageHeader title="کاربران پنل" subtitle="مدیر کل به همه بخش‌ها دسترسی دارد؛ ویرایشگر به جز مدیریت کاربران و حذف داده‌ها">
<a-button type="primary" @click="openNew">
<template #icon><VuesaxIcon name="add" :size="16" /></template>
کاربر جدید
@@ -241,7 +241,6 @@ onMounted(load);
</div>
<ul class="mt-2 space-y-1 text-[11px] text-gray-600 list-disc list-inside">
<li>مدیریت کاربران، نقش‌ها و تنظیمات امنیتی</li>
<li>تنظیمات پایه سایت، سئو، شبکه‌ها و ساعات کاری</li>
<li>حذف داده‌ها (محصولات، دسته‌ها، پیام‌ها، رسانه‌ها)</li>
</ul>
</div>
@@ -253,7 +252,7 @@ onMounted(load);
</div>
<ul class="mt-2 space-y-1 text-[11px] text-gray-600 list-disc list-inside">
<li>ایجاد، ویرایش و انتشار محصولات و دسته‌بندی‌ها</li>
<li>ویرایش محتوای بخش‌های صفحه اصلی</li>
<li>ویرایش بخش‌های سایت، نشانی، تماس، سئو و شبکه‌ها</li>
<li>آپلود تصاویر در رسانه و مشاهده پیام‌های تماس</li>
</ul>
</div>
@@ -269,7 +268,6 @@ onMounted(load);
</div>
<ul class="mt-2.5 space-y-1.5 text-xs text-gray-600 list-disc list-inside">
<li>مدیریت کاربران، افزودن کاربر و تغییر نقش‌ها و وضعیت حساب</li>
<li>دسترسی کامل به تنظیمات پایه سایت، ساعات کاری، شبکه‌ها و سئو</li>
<li>مجوز حذف محصولات، دسته‌بندی‌ها، رسانه‌ها و پیام‌های تماس</li>
<li>مشاهده لاگ‌های امنیتی و رویدادهای کامل سامانه</li>
</ul>
@@ -282,7 +280,7 @@ onMounted(load);
</div>
<ul class="mt-2.5 space-y-1.5 text-xs text-gray-600 list-disc list-inside">
<li>ایجاد، ویرایش، کپی و انتشار محصولات و گروه‌ها</li>
<li>ویرایش محتوای بخش‌های صفحه اصلی و فعال/مخفی‌سازی نمایش آنها</li>
<li>ویرایش بخش‌های سایت، نشانی، تماس، سئو، شبکه‌ها و نمایش/مخفی‌سازی</li>
<li>آپلود و ویرایش اطلاعات رسانه‌ها در کتابخانه رسانه</li>
<li>مشاهده پیام‌های تماس و به‌روزرسانی وضعیت (بدون دسترسی حذف یا مدیریت کاربران)</li>
</ul>
@@ -319,7 +317,7 @@ onMounted(load);
:options="ROLE_OPTIONS"
/>
<div class="mt-1.5 text-[11px] text-gray-500">
{{ model.role === 'ADMIN' ? 'مدیر کل: دسترسی کامل سیستمی، حذف داده‌ها، تنظیمات و مدیریت کاربران' : 'ویرایشگر: ویرایش محتوا، محصولات، بخش‌ها و پیام‌ها (بدون حذف)' }}
{{ model.role === 'ADMIN' ? 'مدیر کل: دسترسی کامل سیستمی، حذف داده‌ها و مدیریت کاربران' : 'ویرایشگر: ویرایش محتوا، محصولات، بخش‌های سایت (از جمله نشانی و تماس)، پیام‌ها (بدون حذف)' }}
</div>
</FormField>
<FormField v-if="!isNew" path="isActive">
+1 -1
View File
@@ -13,7 +13,7 @@ const data = defineModel<SectionData<"about">>({ required: true });
<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" />
<SectionHeadFields v-model="data.head" />
<FormField path="body" label="متن درباره ما" required>
<a-textarea v-model:value="data.body" :auto-size="{ minRows: 4, maxRows: 10 }" />
</FormField>
+21 -1
View File
@@ -1,17 +1,31 @@
<script setup lang="ts">
import type { SectionData } from "@imen/shared";
import ContactInfoFields from "@/components/ContactInfoFields.vue";
import FormField from "@/components/FormField.vue";
import SectionHeadFields from "@/components/SectionHeadFields.vue";
const data = defineModel<SectionData<"contact">>({ required: true });
/** Site-wide contact info (settings); saved together with this section. */
const settings = defineModel<SectionData<"settings"> | null>("settings", { default: null });
function onSettingsUpdate(value: SectionData<"settings">) {
settings.value = value;
}
</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>
<a-card v-if="settings" title="نشانی و اطلاعات تماس">
<p class="mb-4 text-sm text-gray-500">
این مقادیر در هدر، فوتر، بخش تماس و سئوی سایت مشترک‌اند و با ذخیرهٔ همین صفحه به‌روز می‌شوند.
</p>
<ContactInfoFields :model-value="settings" @update:model-value="onSettingsUpdate" />
</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>
@@ -24,10 +38,16 @@ const data = defineModel<SectionData<"contact">>({ required: true });
<FormField path="formTitle" label="عنوان فرم" hint="برای شکستن خط Enter بزنید" required>
<a-textarea v-model:value="data.formTitle" :auto-size="{ minRows: 2 }" />
</FormField>
<FormField path="submitLabel" label="متن دکمه ارسال" required>
<a-input v-model:value="data.submitLabel" />
</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>
<FormField path="resetLabel" label="متن دکمه بعد از موفقیت" required>
<a-input v-model:value="data.resetLabel" />
</FormField>
</a-card>
</div>
</div>
+26 -11
View File
@@ -6,15 +6,30 @@ 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>
<div class="space-y-6">
<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="linksTitle" label="عنوان ستون لینک‌ها" required>
<a-input v-model:value="data.linksTitle" />
</FormField>
<FormField path="productsTitle" label="عنوان ستون محصولات" required>
<a-input v-model:value="data.productsTitle" />
</FormField>
<FormField path="contactTitle" label="عنوان ستون ارتباط" required>
<a-input v-model:value="data.contactTitle" />
</FormField>
</div>
<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>
</div>
</template>
+11 -21
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { type MediaDto, type SectionData, SOCIAL_TYPES, type SocialType, WEEK_DAYS } from "@imen/shared";
import ContactInfoFields from "@/components/ContactInfoFields.vue";
import FormField from "@/components/FormField.vue";
import ImageField from "@/components/ImageField.vue";
import ListEditor from "@/components/ListEditor.vue";
@@ -66,33 +67,22 @@ function pickOgImage(m: MediaDto) {
</a-card>
<a-card title="اطلاعات تماس">
<ContactInfoFields v-model="data" />
</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 path="headerCta.label" label="متن دکمه" required>
<a-input v-model:value="data.headerCta.label" />
</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 path="headerCta.href" label="لینک" required>
<a-input v-model:value="data.headerCta.href" class="ltr-input" placeholder="/#contact" />
</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-card title="ساعات کاری (برای گوگل)">
<FormField path="openingHours.days" label="روزهای کاری">
<a-checkbox-group
v-model:value="data.openingHours.days"
:options="DAY_OPTIONS"
+1 -1
View File
@@ -10,7 +10,7 @@ const data = defineModel<SectionData<"testimonials">>({ required: true });
<template>
<div class="space-y-6">
<a-card title="عنوان بخش">
<SectionHeadFields v-model="data.head" :with-highlight="false" :with-desc="false" />
<SectionHeadFields v-model="data.head" />
</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">
+1 -4
View File
@@ -10,7 +10,7 @@ var __metadata = (this && this.__metadata) || function (k, v) {
var __param = (this && this.__param) || function (paramIndex, decorator) {
return function (target, key) { decorator(target, key, paramIndex); }
};
import { BadRequestException, Body, Controller, ForbiddenException, Get, Injectable, Module, NotFoundException, Param, Patch, Put, ServiceUnavailableException, } from "@nestjs/common";
import { BadRequestException, Body, Controller, Get, Injectable, Module, NotFoundException, Param, Patch, Put, ServiceUnavailableException, } from "@nestjs/common";
import { isSectionKey, SECTION_KEYS, sectionSchemas, toIssues, } from "@imen/shared";
import { AdminAuth, CurrentUser } from "../common/auth.js";
import { AuditService, RevalidateService } from "../common/common.module.js";
@@ -44,9 +44,6 @@ let SectionsService = class SectionsService {
async update(key, body, user) {
if (!isSectionKey(key))
throw new NotFoundException("بخش پیدا نشد");
if (key === "settings" && user.role !== "ADMIN") {
throw new ForbiddenException("فقط مدیر کل می‌تواند تنظیمات سایت را تغییر دهد");
}
const parsed = sectionSchemas[key].safeParse(body);
if (!parsed.success) {
throw new BadRequestException({
File diff suppressed because one or more lines are too long
+7
View File
@@ -12,6 +12,7 @@ export const SECTIONS = {
locality: "تهران",
country: "IR",
},
mapUrl: "",
workingHours: "شنبه تا پنجشنبه — ۸:۰۰ تا ۱۷:۰۰",
openingHours: {
days: ["Saturday", "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday"],
@@ -19,6 +20,7 @@ export const SECTIONS = {
closes: "17:00",
},
foundingDate: "2007",
headerCta: { label: "درخواست مشاوره", href: "/#contact" },
socials: [
{ type: "website", url: "/" },
{ type: "email", url: "mailto:info@imenpishgaman.ir" },
@@ -246,6 +248,8 @@ export const SECTIONS = {
formTitle: "در کمتر از ۲۴ ساعت\nبا شما تماس می‌گیریم",
successTitle: "درخواست شما ثبت شد",
successText: "کارشناسان ما تا ۲۴ ساعت آینده برای هماهنگی جلسه بازدید با شما تماس می‌گیرند.",
submitLabel: "ارسال درخواست مشاوره",
resetLabel: "ثبت درخواست جدید",
},
footer: {
enabled: true,
@@ -253,6 +257,9 @@ export const SECTIONS = {
copyright: "© ۱۴۰۴ ایمن پیشگامان — تمامی حقوق محفوظ است.",
certLine: "ISO 9001:2015 — CE CERTIFIED PPE SUPPLIER",
emergencyNote: "پشتیبانی اضطراری: ۲۴/۷",
linksTitle: "دسترسی",
productsTitle: "محصولات",
contactTitle: "ارتباط",
},
};
export const CATEGORY_COVERS = {
File diff suppressed because one or more lines are too long
-5
View File
@@ -2,7 +2,6 @@ import {
BadRequestException,
Body,
Controller,
ForbiddenException,
Get,
Injectable,
Module,
@@ -55,10 +54,6 @@ export class SectionsService {
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({
+7
View File
@@ -19,6 +19,7 @@ export const SECTIONS: SiteSections = {
locality: "تهران",
country: "IR",
},
mapUrl: "",
workingHours: "شنبه تا پنجشنبه — ۸:۰۰ تا ۱۷:۰۰",
openingHours: {
days: ["Saturday", "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday"],
@@ -26,6 +27,7 @@ export const SECTIONS: SiteSections = {
closes: "17:00",
},
foundingDate: "2007",
headerCta: { label: "درخواست مشاوره", href: "/#contact" },
socials: [
{ type: "website", url: "/" },
{ type: "email", url: "mailto:info@imenpishgaman.ir" },
@@ -265,6 +267,8 @@ export const SECTIONS: SiteSections = {
formTitle: "در کمتر از ۲۴ ساعت\nبا شما تماس می‌گیریم",
successTitle: "درخواست شما ثبت شد",
successText: "کارشناسان ما تا ۲۴ ساعت آینده برای هماهنگی جلسه بازدید با شما تماس می‌گیرند.",
submitLabel: "ارسال درخواست مشاوره",
resetLabel: "ثبت درخواست جدید",
},
footer: {
@@ -274,6 +278,9 @@ export const SECTIONS: SiteSections = {
copyright: "© ۱۴۰۴ ایمن پیشگامان — تمامی حقوق محفوظ است.",
certLine: "ISO 9001:2015 — CE CERTIFIED PPE SUPPLIER",
emergencyNote: "پشتیبانی اضطراری: ۲۴/۷",
linksTitle: "دسترسی",
productsTitle: "محصولات",
contactTitle: "ارتباط",
},
};
+12 -3
View File
@@ -79,7 +79,7 @@ const SOCIALS = computed(() =>
<!-- quick links -->
<div class="md:col-span-2">
<div class="font-mono text-xs tracking-[0.25em] text-accent">// دسترسی</div>
<div class="font-mono text-xs tracking-[0.25em] text-accent">// {{ footer.linksTitle }}</div>
<ul class="mt-5 space-y-3">
<li v-for="l in QUICK_LINKS" :key="l.href">
<a :href="l.href" class="text-sm text-muted transition-colors hover:text-ink">
@@ -91,7 +91,7 @@ const SOCIALS = computed(() =>
<!-- products -->
<div class="md:col-span-2">
<div class="font-mono text-xs tracking-[0.25em] text-accent">// محصولات</div>
<div class="font-mono text-xs tracking-[0.25em] text-accent">// {{ footer.productsTitle }}</div>
<ul class="mt-5 space-y-3">
<li v-for="p in PRODUCT_LINKS" :key="p.href">
<NuxtLink :to="p.href" class="text-sm text-muted transition-colors hover:text-ink">
@@ -103,10 +103,19 @@ const SOCIALS = computed(() =>
<!-- contact -->
<div class="md:col-span-3">
<div class="font-mono text-xs tracking-[0.25em] text-accent">// ارتباط</div>
<div class="font-mono text-xs tracking-[0.25em] text-accent">// {{ footer.contactTitle }}</div>
<address class="mt-5 space-y-3 text-sm text-muted not-italic">
<p class="leading-7">
{{ settings.address.locality }}، {{ settings.address.street }}
<a
v-if="settings.mapUrl"
:href="settings.mapUrl"
target="_blank"
rel="noopener"
class="ms-2 text-accent transition-colors hover:text-ink"
>
نقشه
</a>
</p>
<p class="font-mono tracking-wider" dir="ltr">
<a :href="`tel:${settings.phone}`" class="transition-colors hover:text-ink">{{ settings.phoneDisplay }}</a>
+4 -4
View File
@@ -72,15 +72,15 @@ onBeforeUnmount(() => {
<span class="size-1.5 animate-pulse rounded-full bg-emerald-400" />
<span class="font-mono">{{ time }}</span>
<span class="text-muted/50">|</span>
<span>تهران — شیفت {{ shift }}</span>
<span>{{ settings.address.locality }} — شیفت {{ shift }}</span>
</div>
<a
href="/#contact"
:href="settings.headerCta.href"
class="hidden items-center gap-2 rounded-full bg-accent px-5 py-2.5 text-sm font-bold text-bg transition-all duration-300 hover:bg-accent-soft hover:shadow-[0_0_30px_rgba(255,90,0,0.4)] md:flex"
>
<VuesaxIcon name="call" class="size-4" />
درخواست مشاوره
{{ settings.headerCta.label }}
</a>
<button
@@ -139,7 +139,7 @@ onBeforeUnmount(() => {
<VuesaxIcon name="call" class="size-4 text-accent" />
<a :href="`tel:${settings.phone}`" class="font-mono" dir="ltr">{{ settings.phoneDisplay }}</a>
</div>
<div class="text-xs text-muted">تهران — شیفت {{ shift }}، {{ time }}</div>
<div class="text-xs text-muted">{{ settings.address.locality }} — شیفت {{ shift }}، {{ time }}</div>
</div>
</motion.div>
</AnimatePresence>
+4 -4
View File
@@ -50,14 +50,14 @@ const step = computed(
<div class="flex items-center gap-4">
<span
class="flex size-12 items-center justify-center rounded-xl bg-white shadow-[0_0_28px_rgba(255,90,0,0.3)] ring-1 ring-white/10"
class="flex size-20 items-center justify-center overflow-hidden rounded-2xl bg-white shadow-[0_0_28px_rgba(255,90,0,0.3)] ring-1 ring-white/10"
>
<img
src="/images/logo.png"
alt="ایمن پیشگامان"
width="32"
height="32"
class="size-8 object-contain"
width="80"
height="80"
class="size-full scale-110 object-contain object-center"
/>
</span>
<span class="font-mono text-xs tracking-[0.35em] text-muted">
+11 -3
View File
@@ -12,7 +12,13 @@ const content = computed(() => site.sections.contact);
const INFO = computed(() => {
const s = site.settings;
return [
{ icon: "location", label: "نشانی", value: `${s.address.locality}، ${s.address.street}` },
{
icon: "location",
label: "نشانی",
value: `${s.address.locality}، ${s.address.street}`,
href: s.mapUrl || undefined,
external: Boolean(s.mapUrl),
},
{ icon: "call", label: "تلفن تماس", value: s.phoneDisplay, ltr: true, href: `tel:${s.phone}` },
{ icon: "sms", label: "ایمیل", value: s.email, ltr: true, href: `mailto:${s.email}` },
{ icon: "clock", label: "ساعات کاری", value: s.workingHours },
@@ -59,6 +65,8 @@ const contact = useContactStore();
<component
:is="item.href ? 'a' : 'div'"
:href="item.href"
:target="item.external ? '_blank' : undefined"
:rel="item.external ? 'noopener' : undefined"
:class="`mt-1 block text-sm font-medium ${item.ltr ? 'font-mono tracking-wider' : ''}`"
:dir="item.ltr ? 'ltr' : 'rtl'"
>
@@ -106,7 +114,7 @@ const contact = useContactStore();
class="mt-7 rounded-full border border-line px-7 py-3 text-sm transition-colors hover:border-accent hover:text-accent"
@click="contact.reset"
>
ثبت درخواست جدید
{{ content.resetLabel }}
</button>
</div>
@@ -172,7 +180,7 @@ const contact = useContactStore();
</template>
<template v-else>
<VuesaxIcon name="send-2" class="size-5" />
ارسال درخواست مشاوره
{{ content.submitLabel }}
</template>
</AButton>
</div>
+9 -8
View File
@@ -1,23 +1,24 @@
<script setup lang="ts">
const settings = computed(() => useSiteStore().settings);
</script>
<template>
<NuxtLink to="/" class="flex items-center gap-3" aria-label="ایمن پیشگامان">
<NuxtLink to="/" class="flex items-center gap-3" :aria-label="settings.name">
<span
class="flex size-10 items-center justify-center rounded-xl bg-white shadow-[0_0_28px_rgba(10,30,92,0.45)]"
class="flex size-11 items-center justify-center overflow-hidden rounded-xl bg-white p-0.5 shadow-[0_0_28px_rgba(10,30,92,0.45)] sm:size-12"
>
<img
src="/images/logo.png"
alt="ایمن پیشگامان"
width="28"
height="28"
class="size-7 object-contain"
:alt="settings.name"
width="48"
height="48"
class="size-full object-contain object-center"
/>
</span>
<span class="leading-tight">
<span class="block text-lg font-black">ایمن پیشگامان</span>
<span class="block text-lg font-black">{{ settings.name }}</span>
<span class="block font-mono text-[9px] tracking-[0.32em] text-muted">
IMEN PISHGAMAN CO.
{{ settings.nameEn }}
</span>
</span>
</NuxtLink>
+20
View File
@@ -28,6 +28,7 @@ export declare const settingsSectionSchema: z.ZodObject<{
locality: z.ZodString;
country: z.ZodDefault<z.ZodString>;
}, z.core.$strip>;
mapUrl: z.ZodDefault<z.ZodString>;
workingHours: z.ZodString;
openingHours: z.ZodObject<{
days: z.ZodArray<z.ZodEnum<{
@@ -43,6 +44,10 @@ export declare const settingsSectionSchema: z.ZodObject<{
closes: z.ZodString;
}, z.core.$strip>;
foundingDate: z.ZodString;
headerCta: z.ZodDefault<z.ZodObject<{
label: z.ZodString;
href: z.ZodString;
}, z.core.$strip>>;
socials: z.ZodArray<z.ZodObject<{
type: z.ZodEnum<{
instagram: "instagram";
@@ -224,6 +229,8 @@ export declare const contactSectionSchema: z.ZodObject<{
formTitle: z.ZodString;
successTitle: z.ZodString;
successText: z.ZodString;
submitLabel: z.ZodDefault<z.ZodString>;
resetLabel: z.ZodDefault<z.ZodString>;
}, z.core.$strip>;
export declare const footerSectionSchema: z.ZodObject<{
enabled: z.ZodDefault<z.ZodBoolean>;
@@ -231,6 +238,9 @@ export declare const footerSectionSchema: z.ZodObject<{
copyright: z.ZodString;
certLine: z.ZodDefault<z.ZodString>;
emergencyNote: z.ZodDefault<z.ZodString>;
linksTitle: z.ZodDefault<z.ZodString>;
productsTitle: z.ZodDefault<z.ZodString>;
contactTitle: z.ZodDefault<z.ZodString>;
}, z.core.$strip>;
export declare const sectionSchemas: {
readonly settings: z.ZodObject<{
@@ -246,6 +256,7 @@ export declare const sectionSchemas: {
locality: z.ZodString;
country: z.ZodDefault<z.ZodString>;
}, z.core.$strip>;
mapUrl: z.ZodDefault<z.ZodString>;
workingHours: z.ZodString;
openingHours: z.ZodObject<{
days: z.ZodArray<z.ZodEnum<{
@@ -261,6 +272,10 @@ export declare const sectionSchemas: {
closes: z.ZodString;
}, z.core.$strip>;
foundingDate: z.ZodString;
headerCta: z.ZodDefault<z.ZodObject<{
label: z.ZodString;
href: z.ZodString;
}, z.core.$strip>>;
socials: z.ZodArray<z.ZodObject<{
type: z.ZodEnum<{
instagram: "instagram";
@@ -442,6 +457,8 @@ export declare const sectionSchemas: {
formTitle: z.ZodString;
successTitle: z.ZodString;
successText: z.ZodString;
submitLabel: z.ZodDefault<z.ZodString>;
resetLabel: z.ZodDefault<z.ZodString>;
}, z.core.$strip>;
readonly footer: z.ZodObject<{
enabled: z.ZodDefault<z.ZodBoolean>;
@@ -449,6 +466,9 @@ export declare const sectionSchemas: {
copyright: z.ZodString;
certLine: z.ZodDefault<z.ZodString>;
emergencyNote: z.ZodDefault<z.ZodString>;
linksTitle: z.ZodDefault<z.ZodString>;
productsTitle: z.ZodDefault<z.ZodString>;
contactTitle: z.ZodDefault<z.ZodString>;
}, z.core.$strip>;
};
export type SectionKey = keyof typeof sectionSchemas;
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"sections.d.ts","sourceRoot":"","sources":["../src/sections.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAGxB;;;;;GAKG;AAEH,eAAO,MAAM,SAAS,yFAQZ,CAAC;AAEX,eAAO,MAAM,YAAY,+HAYf,CAAC;AACX,MAAM,MAAM,UAAU,GAAG,CAAC,OAAO,YAAY,CAAC,CAAC,MAAM,CAAC,CAAC;AAKvD,eAAO,MAAM,iBAAiB;;;;;iBAM5B,CAAC;AACH,MAAM,MAAM,eAAe,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,iBAAiB,CAAC,CAAC;AAEhE,eAAO,MAAM,qBAAqB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBA6BhC,CAAC;AAEH,eAAO,MAAM,iBAAiB;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAa5B,CAAC;AAEH,eAAO,MAAM,iBAAiB;;;iBAG5B,CAAC;AAEH,eAAO,MAAM,kBAAkB;;;;;;;;iBAY7B,CAAC;AAEH,eAAO,MAAM,0BAA0B;;;;;;;;;;;;;;iBAKrC,CAAC;AAEH,eAAO,MAAM,kBAAkB;;;;;;;;;;;;;;;;;;;;;;;;;iBAQ7B,CAAC;AAEH,eAAO,MAAM,qBAAqB;;;;;;;;;;;;;;iBAahC,CAAC;AAEH,eAAO,MAAM,sBAAsB;;;;;;;;;;;;;;iBAMjC,CAAC;AAEH,eAAO,MAAM,uBAAuB;;;;;;;;;;;;;;;;;iBAalC,CAAC;AAEH,eAAO,MAAM,yBAAyB;;;;;;;;;;;;;iBAIpC,CAAC;AAEH,eAAO,MAAM,oBAAoB;;;;;;;;;;;;;;iBAS/B,CAAC;AAEH,eAAO,MAAM,mBAAmB;;;;;;iBAM9B,CAAC;AAEH,eAAO,MAAM,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAajB,CAAC;AAEX,MAAM,MAAM,UAAU,GAAG,MAAM,OAAO,cAAc,CAAC;AACrD,eAAO,MAAM,YAAY,EAAkC,UAAU,EAAE,CAAC;AACxE,MAAM,MAAM,WAAW,CAAC,CAAC,SAAS,UAAU,IAAI,CAAC,CAAC,KAAK,CAAC,CAAC,OAAO,cAAc,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;AACpF,MAAM,MAAM,YAAY,GAAG;KAAG,CAAC,IAAI,UAAU,GAAG,WAAW,CAAC,CAAC,CAAC;CAAE,CAAC;AACjE,MAAM,MAAM,YAAY,GAAG,WAAW,CAAC,UAAU,CAAC,CAAC;AAEnD,eAAO,MAAM,YAAY,GAAI,KAAK,MAAM,KAAG,GAAG,IAAI,UAAmC,CAAC;AAEtF,yCAAyC;AACzC,eAAO,MAAM,cAAc,EAAE,MAAM,CAAC,UAAU,EAAE;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAa9E,CAAC"}
{"version":3,"file":"sections.d.ts","sourceRoot":"","sources":["../src/sections.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAGxB;;;;;GAKG;AAEH,eAAO,MAAM,SAAS,yFAQZ,CAAC;AAEX,eAAO,MAAM,YAAY,+HAYf,CAAC;AACX,MAAM,MAAM,UAAU,GAAG,CAAC,OAAO,YAAY,CAAC,CAAC,MAAM,CAAC,CAAC;AAKvD,eAAO,MAAM,iBAAiB;;;;;iBAM5B,CAAC;AACH,MAAM,MAAM,eAAe,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,iBAAiB,CAAC,CAAC;AAEhE,eAAO,MAAM,qBAAqB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAiChC,CAAC;AAEH,eAAO,MAAM,iBAAiB;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAa5B,CAAC;AAEH,eAAO,MAAM,iBAAiB;;;iBAG5B,CAAC;AAEH,eAAO,MAAM,kBAAkB;;;;;;;;iBAY7B,CAAC;AAEH,eAAO,MAAM,0BAA0B;;;;;;;;;;;;;;iBAKrC,CAAC;AAEH,eAAO,MAAM,kBAAkB;;;;;;;;;;;;;;;;;;;;;;;;;iBAQ7B,CAAC;AAEH,eAAO,MAAM,qBAAqB;;;;;;;;;;;;;;iBAahC,CAAC;AAEH,eAAO,MAAM,sBAAsB;;;;;;;;;;;;;;iBAMjC,CAAC;AAEH,eAAO,MAAM,uBAAuB;;;;;;;;;;;;;;;;;iBAalC,CAAC;AAEH,eAAO,MAAM,yBAAyB;;;;;;;;;;;;;iBAIpC,CAAC;AAEH,eAAO,MAAM,oBAAoB;;;;;;;;;;;;;;;;iBAa/B,CAAC;AAEH,eAAO,MAAM,mBAAmB;;;;;;;;;iBAU9B,CAAC;AAEH,eAAO,MAAM,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAajB,CAAC;AAEX,MAAM,MAAM,UAAU,GAAG,MAAM,OAAO,cAAc,CAAC;AACrD,eAAO,MAAM,YAAY,EAAkC,UAAU,EAAE,CAAC;AACxE,MAAM,MAAM,WAAW,CAAC,CAAC,SAAS,UAAU,IAAI,CAAC,CAAC,KAAK,CAAC,CAAC,OAAO,cAAc,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;AACpF,MAAM,MAAM,YAAY,GAAG;KAAG,CAAC,IAAI,UAAU,GAAG,WAAW,CAAC,CAAC,CAAC;CAAE,CAAC;AACjE,MAAM,MAAM,YAAY,GAAG,WAAW,CAAC,UAAU,CAAC,CAAC;AAEnD,eAAO,MAAM,YAAY,GAAI,KAAK,MAAM,KAAG,GAAG,IAAI,UAAmC,CAAC;AAEtF,yCAAyC;AACzC,eAAO,MAAM,cAAc,EAAE,MAAM,CAAC,UAAU,EAAE;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAa9E,CAAC"}
+14 -2
View File
@@ -53,6 +53,8 @@ export const settingsSectionSchema = z.object({
locality: req(80),
country: z.string().trim().length(2).default("IR"),
}),
/** Google Maps / Neshan / etc. share link shown next to the address */
mapUrl: text(500),
/** human-readable working hours, e.g. «شنبه تا پنجشنبه — ۸:۰۰ تا ۱۷:۰۰» */
workingHours: req(120),
openingHours: z.object({
@@ -61,6 +63,8 @@ export const settingsSectionSchema = z.object({
closes: z.string().regex(/^\d{2}:\d{2}$/, "قالب ساعت باید HH:MM باشد"),
}),
foundingDate: z.string().regex(/^\d{4}$/, "سال میلادی چهاررقمی"),
/** sticky header CTA (e.g. «درخواست مشاوره») */
headerCta: linkSchema.default({ label: "درخواست مشاوره", href: "/#contact" }),
socials: z.array(z.object({ type: z.enum(SOCIAL_TYPES), url: req(300) })).max(12),
ogImage: imageRefSchema.extend({
width: z.number().int().positive().nullable().default(null),
@@ -156,6 +160,10 @@ export const contactSectionSchema = z.object({
formTitle: req(160),
successTitle: req(80),
successText: req(300),
/** submit button on the contact form */
submitLabel: req(80).default("ارسال درخواست مشاوره"),
/** button shown after a successful submit */
resetLabel: req(80).default("ثبت درخواست جدید"),
});
export const footerSectionSchema = z.object({
enabled: z.boolean().default(true),
@@ -163,6 +171,10 @@ export const footerSectionSchema = z.object({
copyright: req(160),
certLine: text(160),
emergencyNote: text(80),
/** column headings under the brand block */
linksTitle: req(40).default("دسترسی"),
productsTitle: req(40).default("محصولات"),
contactTitle: req(40).default("ارتباط"),
});
export const sectionSchemas = {
settings: settingsSectionSchema,
@@ -192,7 +204,7 @@ export const SECTION_LABELS = {
standards: { title: "استانداردها", desc: "استانداردها و گواهی‌نامه‌ها" },
industries: { title: "صنایع", desc: "صنایع تحت پوشش و تصویر هرکدام" },
testimonials: { title: "نظرات مشتریان", desc: "نقل‌قول‌های مشتریان" },
contact: { title: "تماس با ما", desc: "عنوان‌ها و متن‌های فرم تماس" },
footer: { title: "فوتر", desc: "متن معرفی، کپی‌رایت و توضیحات پایین صفحه" },
contact: { title: "تماس با ما", desc: "عنوان‌ها، متن‌های فرم، و ویرایش نشانی و اطلاعات تماس" },
footer: { title: "فوتر", desc: "متن معرفی، عناوین ستون‌ها، کپی‌رایت و توضیحات پایین صفحه" },
};
//# sourceMappingURL=sections.js.map
File diff suppressed because one or more lines are too long
+14 -2
View File
@@ -61,6 +61,8 @@ export const settingsSectionSchema = z.object({
locality: req(80),
country: z.string().trim().length(2).default("IR"),
}),
/** Google Maps / Neshan / etc. share link shown next to the address */
mapUrl: text(500),
/** human-readable working hours, e.g. «شنبه تا پنجشنبه — ۸:۰۰ تا ۱۷:۰۰» */
workingHours: req(120),
openingHours: z.object({
@@ -69,6 +71,8 @@ export const settingsSectionSchema = z.object({
closes: z.string().regex(/^\d{2}:\d{2}$/, "قالب ساعت باید HH:MM باشد"),
}),
foundingDate: z.string().regex(/^\d{4}$/, "سال میلادی چهاررقمی"),
/** sticky header CTA (e.g. «درخواست مشاوره») */
headerCta: linkSchema.default({ label: "درخواست مشاوره", href: "/#contact" }),
socials: z.array(z.object({ type: z.enum(SOCIAL_TYPES), url: req(300) })).max(12),
ogImage: imageRefSchema.extend({
width: z.number().int().positive().nullable().default(null),
@@ -180,6 +184,10 @@ export const contactSectionSchema = z.object({
formTitle: req(160),
successTitle: req(80),
successText: req(300),
/** submit button on the contact form */
submitLabel: req(80).default("ارسال درخواست مشاوره"),
/** button shown after a successful submit */
resetLabel: req(80).default("ثبت درخواست جدید"),
});
export const footerSectionSchema = z.object({
@@ -188,6 +196,10 @@ export const footerSectionSchema = z.object({
copyright: req(160),
certLine: text(160),
emergencyNote: text(80),
/** column headings under the brand block */
linksTitle: req(40).default("دسترسی"),
productsTitle: req(40).default("محصولات"),
contactTitle: req(40).default("ارتباط"),
});
export const sectionSchemas = {
@@ -225,6 +237,6 @@ export const SECTION_LABELS: Record<SectionKey, { title: string; desc: string }>
standards: { title: "استانداردها", desc: "استانداردها و گواهی‌نامه‌ها" },
industries: { title: "صنایع", desc: "صنایع تحت پوشش و تصویر هرکدام" },
testimonials: { title: "نظرات مشتریان", desc: "نقل‌قول‌های مشتریان" },
contact: { title: "تماس با ما", desc: "عنوان‌ها و متن‌های فرم تماس" },
footer: { title: "فوتر", desc: "متن معرفی، کپی‌رایت و توضیحات پایین صفحه" },
contact: { title: "تماس با ما", desc: "عنوان‌ها، متن‌های فرم، و ویرایش نشانی و اطلاعات تماس" },
footer: { title: "فوتر", desc: "متن معرفی، عناوین ستون‌ها، کپی‌رایت و توضیحات پایین صفحه" },
};