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">