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:
@@ -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 };
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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?
|
||||
|
||||
Reference in New Issue
Block a user