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 -->
|
<!-- FOOTER -->
|
||||||
<template v-else-if="key === '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><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><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.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>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
@@ -166,8 +165,6 @@ const form = reactive<any>({});
|
|||||||
const marqueeText = ref("");
|
const marqueeText = ref("");
|
||||||
const manifestoWordsText = ref("");
|
const manifestoWordsText = ref("");
|
||||||
const footerMarqueeText = ref("");
|
const footerMarqueeText = ref("");
|
||||||
const footerAddressText = ref("");
|
|
||||||
const footerSocialsText = ref("");
|
|
||||||
|
|
||||||
function splitTags(v: string) {
|
function splitTags(v: string) {
|
||||||
return v
|
return v
|
||||||
@@ -209,8 +206,6 @@ onMounted(async () => {
|
|||||||
if (key === "team") form.members = form.members || [];
|
if (key === "team") form.members = form.members || [];
|
||||||
if (key === "footer") {
|
if (key === "footer") {
|
||||||
footerMarqueeText.value = (form.marquee || []).join("\n");
|
footerMarqueeText.value = (form.marquee || []).join("\n");
|
||||||
footerAddressText.value = (form.address || []).join("\n");
|
|
||||||
footerSocialsText.value = (form.socials || []).join("\n");
|
|
||||||
}
|
}
|
||||||
ready.value = true;
|
ready.value = true;
|
||||||
});
|
});
|
||||||
@@ -222,10 +217,13 @@ function buildPayload() {
|
|||||||
}
|
}
|
||||||
if (key === "footer") {
|
if (key === "footer") {
|
||||||
return {
|
return {
|
||||||
...form,
|
tag: form.tag,
|
||||||
|
cta: form.cta,
|
||||||
marquee: footerMarqueeText.value.split("\n").map((s) => s.trim()).filter(Boolean),
|
marquee: footerMarqueeText.value.split("\n").map((s) => s.trim()).filter(Boolean),
|
||||||
address: footerAddressText.value.split("\n").map((s) => s.trim()).filter(Boolean),
|
email: form.email,
|
||||||
socials: footerSocialsText.value.split("\n").map((s) => s.trim()).filter(Boolean),
|
phone: form.phone,
|
||||||
|
address: form.address,
|
||||||
|
socials: form.socials,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { ...form };
|
return { ...form };
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export class ContentService {
|
|||||||
return this.prisma.siteSettings.findUniqueOrThrow({ where: { id: "default" } });
|
return this.prisma.siteSettings.findUniqueOrThrow({ where: { id: "default" } });
|
||||||
}
|
}
|
||||||
|
|
||||||
updateSettings(data: {
|
async updateSettings(data: {
|
||||||
brandName?: string;
|
brandName?: string;
|
||||||
tagline?: string;
|
tagline?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
@@ -21,7 +21,7 @@ export class ContentService {
|
|||||||
logoUrl?: string | null;
|
logoUrl?: string | null;
|
||||||
}) {
|
}) {
|
||||||
const { logoUrl, timezone, ...rest } = data;
|
const { logoUrl, timezone, ...rest } = data;
|
||||||
return this.prisma.siteSettings.update({
|
const settings = await this.prisma.siteSettings.update({
|
||||||
where: { id: "default" },
|
where: { id: "default" },
|
||||||
data: {
|
data: {
|
||||||
...rest,
|
...rest,
|
||||||
@@ -29,6 +29,32 @@ export class ContentService {
|
|||||||
...(logoUrl !== undefined ? { logoUrl: logoUrl || null } : {}),
|
...(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() {
|
async listSections() {
|
||||||
|
|||||||
@@ -2,45 +2,56 @@
|
|||||||
<footer id="contact" class="relative overflow-hidden border-t border-white/[0.07]">
|
<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">
|
<div class="border-b border-white/[0.07] py-8 md:py-10">
|
||||||
<SiteMarquee
|
<SiteMarquee
|
||||||
:items="data.marquee"
|
:items="data.marquee || []"
|
||||||
:duration="20"
|
:duration="20"
|
||||||
class-name="font-display text-xl font-bold md:text-3xl"
|
class-name="font-display text-xl font-bold md:text-3xl"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mx-auto max-w-[1500px] px-6 py-20 md:px-10 md:py-28">
|
<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 class="mt-14 grid gap-12 lg:grid-cols-[1.4fr_1fr]">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="font-display text-[clamp(2.2rem,6vw,4.5rem)] font-extrabold leading-[1.15] tracking-[-0.02em]">
|
<h2 class="font-display text-[clamp(2.2rem,6vw,4.5rem)] font-extrabold leading-[1.15] tracking-[-0.02em]">
|
||||||
{{ data.cta }}
|
{{ data.cta || "شروع همکاری" }}
|
||||||
</h2>
|
</h2>
|
||||||
<a
|
<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"
|
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"
|
dir="ltr"
|
||||||
>
|
>
|
||||||
{{ data.email }} ↗
|
{{ email }} ↗
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid gap-8 sm:grid-cols-3 lg:grid-cols-1">
|
<div class="grid gap-8 sm:grid-cols-3 lg:grid-cols-1">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-xs text-ash">کسبوکار جدید</p>
|
<p class="text-xs text-ash">کسبوکار جدید</p>
|
||||||
<p class="mt-2 text-sm" dir="ltr">{{ data.email }}</p>
|
<p v-if="email" class="mt-2 text-sm" dir="ltr">{{ email }}</p>
|
||||||
<p class="text-sm text-ash" dir="ltr">{{ data.phone }}</p>
|
<p v-if="phone" class="text-sm text-ash" dir="ltr">{{ phone }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-xs text-ash">استودیو</p>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-xs text-ash">شبکههای اجتماعی</p>
|
<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>
|
||||||
</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">
|
<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>
|
<span>{{ brand || "منتک" }}®</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -48,17 +59,55 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
brand?: string;
|
brand?: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
address?: string;
|
||||||
|
socials?: Array<{ label: string; url: string }>;
|
||||||
data: {
|
data: {
|
||||||
tag: string;
|
tag?: string;
|
||||||
marquee: string[];
|
marquee?: string[];
|
||||||
cta: string;
|
cta?: string;
|
||||||
email: string;
|
email?: string;
|
||||||
phone: string;
|
phone?: string;
|
||||||
address: string[];
|
address?: string[] | string;
|
||||||
socials: string[];
|
socials?: string[] | Array<{ label: string; url: string }>;
|
||||||
};
|
};
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const year = new Date().getFullYear();
|
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>
|
</script>
|
||||||
|
|||||||
@@ -21,7 +21,14 @@
|
|||||||
<StatsSection :data="site.sections.stats" />
|
<StatsSection :data="site.sections.stats" />
|
||||||
<TeamSection :data="site.sections.team" />
|
<TeamSection :data="site.sections.team" />
|
||||||
</main>
|
</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>
|
||||||
<div v-else class="grid min-h-screen place-items-center text-ash">
|
<div v-else class="grid min-h-screen place-items-center text-ash">
|
||||||
Unable to load site content. Is the API running?
|
Unable to load site content. Is the API running?
|
||||||
|
|||||||
Reference in New Issue
Block a user