Wire landing footer contact info to site settings.

Admin phone/email/address/socials now drive the public footer instead of stale section JSON.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-30 21:36:43 +03:30
co-authored by Cursor
parent e9eafdaabc
commit 55e3b6a0e9
4 changed files with 112 additions and 32 deletions
+9 -11
View File
@@ -120,13 +120,12 @@
<!-- FOOTER -->
<template v-else-if="key === 'footer'">
<p class="muted" style="margin-bottom: 1rem">
ایمیل، تلفن، آدرس و شبکه‌های اجتماعی از بخش «تنظیمات سایت» خوانده می‌شوند. اینجا فقط متن‌های نمایشی فوتر را ویرایش کنید.
</p>
<div class="field"><label class="label">عنوان بخش</label><input v-model="form.tag" class="input" /></div>
<div class="field"><label class="label">نوار متحرک پایین (هر خط یک مورد)</label><textarea v-model="footerMarqueeText" class="textarea" /></div>
<div class="field"><label class="label">متن دعوت به همکاری</label><input v-model="form.cta" class="input" /></div>
<div class="field"><label class="label">ایمیل</label><input v-model="form.email" class="input" dir="ltr" /></div>
<div class="field"><label class="label">تلفن</label><input v-model="form.phone" class="input" dir="ltr" /></div>
<div class="field"><label class="label">آدرس (هر خط یک سطر)</label><textarea v-model="footerAddressText" class="textarea" /></div>
<div class="field"><label class="label">شبکه‌های اجتماعی (هر خط یک مورد)</label><textarea v-model="footerSocialsText" class="textarea" /></div>
</template>
<template v-else>
@@ -166,8 +165,6 @@ const form = reactive<any>({});
const marqueeText = ref("");
const manifestoWordsText = ref("");
const footerMarqueeText = ref("");
const footerAddressText = ref("");
const footerSocialsText = ref("");
function splitTags(v: string) {
return v
@@ -209,8 +206,6 @@ onMounted(async () => {
if (key === "team") form.members = form.members || [];
if (key === "footer") {
footerMarqueeText.value = (form.marquee || []).join("\n");
footerAddressText.value = (form.address || []).join("\n");
footerSocialsText.value = (form.socials || []).join("\n");
}
ready.value = true;
});
@@ -222,10 +217,13 @@ function buildPayload() {
}
if (key === "footer") {
return {
...form,
tag: form.tag,
cta: form.cta,
marquee: footerMarqueeText.value.split("\n").map((s) => s.trim()).filter(Boolean),
address: footerAddressText.value.split("\n").map((s) => s.trim()).filter(Boolean),
socials: footerSocialsText.value.split("\n").map((s) => s.trim()).filter(Boolean),
email: form.email,
phone: form.phone,
address: form.address,
socials: form.socials,
};
}
return { ...form };
+28 -2
View File
@@ -10,7 +10,7 @@ export class ContentService {
return this.prisma.siteSettings.findUniqueOrThrow({ where: { id: "default" } });
}
updateSettings(data: {
async updateSettings(data: {
brandName?: string;
tagline?: string;
email?: string;
@@ -21,7 +21,7 @@ export class ContentService {
logoUrl?: string | null;
}) {
const { logoUrl, timezone, ...rest } = data;
return this.prisma.siteSettings.update({
const settings = await this.prisma.siteSettings.update({
where: { id: "default" },
data: {
...rest,
@@ -29,6 +29,32 @@ export class ContentService {
...(logoUrl !== undefined ? { logoUrl: logoUrl || null } : {}),
},
});
// Keep footer section contact fields in sync with site settings
const footer = await this.prisma.sectionContent.findUnique({ where: { key: "footer" } });
if (footer) {
const prev = (footer.data || {}) as Record<string, unknown>;
const addressLines = settings.address
? settings.address.split(/\n|,/).map((s) => s.trim()).filter(Boolean)
: (prev.address as string[]) || [];
const socials = Array.isArray(settings.socials)
? (settings.socials as Array<{ label: string; url: string }>).map((s) => s.label).filter(Boolean)
: prev.socials;
await this.prisma.sectionContent.update({
where: { key: "footer" },
data: {
data: {
...prev,
email: settings.email,
phone: settings.phone,
address: addressLines,
socials,
},
},
});
}
return settings;
}
async listSections() {
+67 -18
View File
@@ -2,45 +2,56 @@
<footer id="contact" class="relative overflow-hidden border-t border-white/[0.07]">
<div class="border-b border-white/[0.07] py-8 md:py-10">
<SiteMarquee
:items="data.marquee"
:items="data.marquee || []"
:duration="20"
class-name="font-display text-xl font-bold md:text-3xl"
/>
</div>
<div class="mx-auto max-w-[1500px] px-6 py-20 md:px-10 md:py-28">
<SectionTag index="۰۶" :title="data.tag" right="پاسخ تا ۲۴ ساعت" />
<SectionTag index="۰۶" :title="data.tag || 'تماس'" right="پاسخ تا ۲۴ ساعت" />
<div class="mt-14 grid gap-12 lg:grid-cols-[1.4fr_1fr]">
<div>
<h2 class="font-display text-[clamp(2.2rem,6vw,4.5rem)] font-extrabold leading-[1.15] tracking-[-0.02em]">
{{ data.cta }}
{{ data.cta || "شروع همکاری" }}
</h2>
<a
:href="`mailto:${data.email}`"
v-if="email"
:href="`mailto:${email}`"
class="mt-8 inline-flex items-center gap-3 border border-volt bg-volt px-6 py-3 text-sm text-ink transition hover:bg-transparent hover:text-volt"
dir="ltr"
>
{{ data.email }} ↗
{{ email }} ↗
</a>
</div>
<div class="grid gap-8 sm:grid-cols-3 lg:grid-cols-1">
<div>
<p class="text-xs text-ash">کسب‌وکار جدید</p>
<p class="mt-2 text-sm" dir="ltr">{{ data.email }}</p>
<p class="text-sm text-ash" dir="ltr">{{ data.phone }}</p>
<p v-if="email" class="mt-2 text-sm" dir="ltr">{{ email }}</p>
<p v-if="phone" class="text-sm text-ash" dir="ltr">{{ phone }}</p>
</div>
<div>
<p class="text-xs text-ash">استودیو</p>
<p v-for="line in data.address" :key="line" class="mt-1 text-sm text-ash">{{ line }}</p>
<p v-for="line in addressLines" :key="line" class="mt-1 text-sm text-ash">{{ line }}</p>
</div>
<div>
<p class="text-xs text-ash">شبکه‌های اجتماعی</p>
<p v-for="s in data.socials" :key="s" class="mt-1 text-sm text-ash">{{ s }}</p>
<template v-if="socialLinks.length">
<a
v-for="s in socialLinks"
:key="s.label + s.url"
:href="s.url || undefined"
class="mt-1 block text-sm text-ash hover:text-volt"
:target="s.url ? '_blank' : undefined"
rel="noopener"
>{{ s.label }}</a>
</template>
<p v-else-if="legacySocials.length" v-for="s in legacySocials" :key="s" class="mt-1 text-sm text-ash">{{ s }}</p>
</div>
</div>
</div>
<div class="mt-20 flex flex-wrap items-center justify-between gap-4 border-t border-white/[0.07] pt-6 text-xs text-ash">
<span>© {{ year }} گروه نرم‌افزاری منتک</span>
<span>© {{ year }} گروه نرم‌افزاری {{ brand || "منتک" }}</span>
<span>{{ brand || "منتک" }}®</span>
</div>
</div>
@@ -48,17 +59,55 @@
</template>
<script setup lang="ts">
defineProps<{
const props = defineProps<{
brand?: string;
email?: string;
phone?: string;
address?: string;
socials?: Array<{ label: string; url: string }>;
data: {
tag: string;
marquee: string[];
cta: string;
email: string;
phone: string;
address: string[];
socials: string[];
tag?: string;
marquee?: string[];
cta?: string;
email?: string;
phone?: string;
address?: string[] | string;
socials?: string[] | Array<{ label: string; url: string }>;
};
}>();
const year = new Date().getFullYear();
const email = computed(() => props.email || props.data.email || "");
const phone = computed(() => props.phone || props.data.phone || "");
const addressLines = computed(() => {
if (props.address) {
return props.address
.split(/\n|,/)
.map((s) => s.trim())
.filter(Boolean);
}
const a = props.data.address;
if (Array.isArray(a)) return a;
if (typeof a === "string") return a.split(/\n|,/).map((s) => s.trim()).filter(Boolean);
return [];
});
const socialLinks = computed(() => {
if (props.socials?.length) return props.socials.filter((s) => s.label);
const raw = props.data.socials;
if (!Array.isArray(raw) || !raw.length) return [];
if (typeof raw[0] === "object" && raw[0] !== null) {
return (raw as Array<{ label: string; url: string }>).filter((s) => s.label);
}
return [];
});
const legacySocials = computed(() => {
const raw = props.data.socials;
if (!Array.isArray(raw) || !raw.length) return [] as string[];
if (typeof raw[0] === "string") return raw as string[];
return [];
});
</script>
+8 -1
View File
@@ -21,7 +21,14 @@
<StatsSection :data="site.sections.stats" />
<TeamSection :data="site.sections.team" />
</main>
<SiteFooter :brand="site.settings.brandName" :data="site.sections.footer" />
<SiteFooter
:brand="site.settings.brandName"
:email="site.settings.email"
:phone="site.settings.phone"
:address="site.settings.address"
:socials="site.settings.socials"
:data="site.sections.footer"
/>
</div>
<div v-else class="grid min-h-screen place-items-center text-ash">
Unable to load site content. Is the API running?