ssr improve faq and marquee

This commit is contained in:
Sabrina Hennrich 2025-05-19 14:55:49 +02:00
parent fb73b7d2b5
commit 07851846ce
8 changed files with 423 additions and 454 deletions

View File

@ -1,219 +1,110 @@
<template>
<div class="myAccordion">
<div v-for="(item, index) in items" :key="index" class="accordion-item">
<div
class="accordion-header"
:ref="el => setHeaderRef(el, index)"
@click="toggleSection(index)"
>
<div class="accordion-title">{{ item.title }}</div>
<div class="accordion-toggle" :class="{ open: openIndex === index }">
<span></span>
<span></span>
</div>
</div>
<div class="myAccordion">
<article
v-for="(item, i) in items"
:key="i"
class="accordion-item"
>
<!-- Header --------------------------------------------------->
<button
class="accordion-header"
:aria-expanded="openIndex === i"
@click="toggle(i)"
>
<span class="accordion-title">{{ item.title }}</span>
<!-- Toggle-Icon (2 Striche) -->
<span class="accordion-toggle" :class="{ open: openIndex === i }">
<span></span><span></span>
</span>
</button>
<!-- Content --------------------------------------------------->
<transition name="accordion">
<div
v-if="openIndex === i"
class="accordion-content"
:ref="el => setContentRef(el, index)"
:style="{ maxHeight: openIndex === index ? `${contentHeights[index]}px` : '0px' }"
>
<p><span v-html="htmlContent(item.content)"></span></p>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, watch, nextTick } from 'vue';
import { useHtmlConverter } from '../composables/useHTMLConverter';
const { convertToHTML } = useHtmlConverter();
const htmlContent = (data) => {
return convertToHTML(data); // Nutze die convertToHTML Funktion der Composable
};
// Props für die Items
const props = defineProps({
items: {
type: Array,
required: true,
default: () => [],
},
});
// Zustand für das geöffnete Element
const openIndex = ref(null);
// Referenzen für die Inhalte
const contentRefs = ref([]);
// Höhen der Inhalte
const contentHeights = ref([]);
// Referenzen für die Header-Elemente
const headerRefs = ref([]);
// Funktion zum Umschalten des geöffneten Abschnitts
const toggleSection = async (index) => {
// Umschalten des offenen Indexes
openIndex.value = openIndex.value === index ? null : index;
// Wenn ein neuer Abschnitt geöffnet wurde
if (openIndex.value !== null) {
await nextTick(); // Warten, bis das DOM aktualisiert wurde
setTimeout(() => {
const header = headerRefs.value[openIndex.value]; // Header des offenen Elements
const fixedHeaderHeight = document.querySelector('.fixed-header')?.offsetHeight || 0;
if (header) {
const topPosition = header.getBoundingClientRect().top + window.scrollY - fixedHeaderHeight - 20; // 20px Puffer
// Scrollen zur berechneten Position
window.scrollTo({
top: topPosition > 0 ? topPosition : 0, // Verhindert negatives Scrollen
behavior: 'smooth',
});
}
}, 100); // Kleiner Timeout für das reibungslose Scrollen
}
};
// Setze die Referenzen dynamisch
const setContentRef = (el, index) => {
if (el) {
contentRefs.value[index] = el;
}
};
const setHeaderRef = (el, index) => {
if (el) {
headerRefs.value[index] = el;
}
};
// Berechne die Höhen der Inhalte
const calculateHeights = () => {
contentHeights.value = contentRefs.value.map((el) => {
if (!el) return 0;
const additionalHeight = 50; // Fester Wert für oben und unten
return el.scrollHeight + additionalHeight; // Dynamische Höhe + fester Wert
});
};
// Initialisiere die Berechnung nach dem Mounten
onMounted(async () => {
contentRefs.value = Array.from({ length: props.items.length });
headerRefs.value = Array.from({ length: props.items.length });
await nextTick();
calculateHeights();
});
// Aktualisiere die Höhen, wenn sich die Items ändern
watch(
() => props.items,
async () => {
contentRefs.value = Array.from({ length: props.items.length });
headerRefs.value = Array.from({ length: props.items.length });
await nextTick();
calculateHeights();
}
);
</script>
<style lang="scss">
.myAccordion {
.accordion-item {
border-bottom: 1px solid #ccc;
&:last-of-type {
border-bottom: none;
}
.accordion-header {
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
padding: 1rem;
font-family: 'Mainfont-Bold';
background-color: white;
transition: background-color 0.3s ease;
&:hover {
background-color: lighten(#EEEBE5, 6%);
}
.accordion-title {
flex: 1; // Nimmt den freien Platz ein
text-align: left; // Links ausgerichtet
font-weight: bold;
font-size: 1rem;
margin-right: 1rem;
}
.accordion-toggle {
position: relative;
flex-shrink: 0; // Verhindert, dass das Icon skaliert wird
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
span {
position: absolute;
width: 15px; // Kürzere Linien für saubere Spitzen
height: 2px;
background-color: #333;
transition: transform 0.3s ease, left 0.3s ease, top 0.3s ease;
top: 8px;
&:first-child {
transform: rotate(135deg); // Linie 1: Oben links zur Mitte
left: 5px; // Leicht nach links verschoben
}
&:last-child {
transform: rotate(-135deg); // Linie 2: Oben rechts zur Mitte
left: -5px; // Leicht nach rechts verschoben
}
}
&.open {
span:first-child {
transform: rotate(45deg); // Linie 1: Teil des "X"
left: 0; // Zentriert
}
span:last-child {
transform: rotate(-45deg); // Linie 2: Teil des "X"
left: 0; // Zentriert
}
}
}
}
.accordion-content {
overflow: hidden;
transition: max-height 0.3s ease-out;
padding: 0 1rem;
background-color: white;
h3 {
font-size: 1rem;
line-height: 1.4rem;
margin-bottom: .5rem;
}
p {
margin: 1rem 0.5rem;
font-size: 1rem;
}
}
}
}
</style>
v-html="item.html"
/>
</transition>
</article>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
defineProps<{ items: { title: string; html: string }[] }>()
const openIndex = ref<number | null>(null)
const toggle = (i: number) =>
(openIndex.value = openIndex.value === i ? null : i)
</script>
<style lang="sass">
.myAccordion
.accordion-item
border-bottom: 1px solid $lightgrey
width: 100%
.accordion-header
all: unset
width: 100%
display: flex
justify-content: space-between
align-items: center
padding: 1.2rem 1rem 1.2rem 1rem
background: #fff
cursor: pointer
border: 0
font: inherit
text-align: left
font-family: 'Mainfont-Bold'
&:hover
background: linear-gradient(to left, white 1%, lighten($lightgrey, 2%) 80%, white 100%)
// Icon
.accordion-toggle
position: relative
width: 20px
height: 20px
flex-shrink: 0
display: flex
justify-content: center
align-items: center
span
position: absolute
width: 16px
height: 2px
background: #333
transition: transform .25s
&:first-child
transform: rotate(90deg) // horizontale Linie wird senkrecht
&.open
span:first-child
transform: rotate(45deg)
span:last-child
transform: rotate(-45deg)
// Content
.accordion-content
padding: 0 1rem 1rem
// simple height-fade transition
.accordion-enter-from,
.accordion-leave-to
max-height: 0
opacity: 0
.accordion-enter-active,
.accordion-leave-active
transition: all .25s ease
.accordion-enter-to,
.accordion-leave-from
max-height: 500px // ausreichend groß
opacity: 1
</style>

View File

@ -24,7 +24,14 @@
>
<div class="row left m-2">
<div class="col-md-6" id="hintBox">
<img class="mobileAspBox" v-if="screenWidth <= 768" :src="cmsUrl + companyinfo?.profileImage?.data?.attributes?.url" alt="Ansprechpartner Sabrina Hennrich">
<NuxtImg
v-if="screenWidth <= 768"
class="mobileAspBox"
:src="cmsUrl + companyinfo?.profileImage?.data?.attributes?.url"
alt="Sabrina Hennrich"
:width="400"
format="webp"
/>
<h2 id="contactTitle">{{ $t('contactForm.yourcontact2us') }}</h2>
<p class="my-4">
<svg aria-hidden="true">
@ -38,8 +45,12 @@
{{ companyinfo.company }}<br>{{ companyinfo.street }} <br>{{ companyinfo.postalcode }} {{ companyinfo.city }}
</p>
<p class="aspProf">{{ $t('contactForm.yourcontactperson') }} <b>Sabrina Hennrich</b></p>
<div class="aspBox"><img :src="cmsUrl + companyinfo?.profileImage?.data?.attributes?.url" alt="Ansprechpartner Sabrina Hennrich"></div>
<div class="aspBox"><NuxtImg
:src="cmsUrl + companyinfo?.profileImage?.data?.attributes?.url"
alt="Ansprechpartner Sabrina Hennrich"
:width="400"
format="webp"
/></div>
</div>
</div>
<div class="col-md-6">
@ -99,7 +110,7 @@
<span class="check"></span>
{{ $t('contactForm.privacyInfotextBeforeLink') }}
<NuxtLinkLocale
to="/privacy"
:to="$t('privacy')"
:aria-label="$t('privacy')"
>
{{ $t('contactForm.privacyInfotextLinkText') }}

View File

@ -1,50 +1,46 @@
<template>
<section class="faq" id="faq">
<h3>{{ headline }}</h3>
<Accordion v-if="accordionItems.length" :items="accordionItems" />
<p v-else>Lade Daten...</p>
<div class="row mt-4">
<div class="col-md-6 mb-3">
<h4> Noch Fragen? </h4>
</div>
<div class="col-md-6">
<button @click.prevent="toggleContactBubble" role="button" class="pinkBtn">
{{ button }}
</button>
</div>
<section class="faq" id="faq">
<h3>{{ headline }}</h3>
<Accordion v-if="accordionItems.length" :items="accordionItems" />
<p v-else>Lade Daten...</p>
<div class="row mt-4">
<div class="col-md-6 mb-3">
<h4> Noch Fragen? </h4>
</div>
</section>
</template>
<script setup>
import { useMainStore } from '@/stores/main';
import { storeToRefs } from 'pinia';
import { computed, defineProps, defineAsyncComponent } from 'vue';
const props = defineProps({
pageLink: { type: String, required: true },
headline: { type: String, default: "Häufig gestellte Fragen (FAQs)" },
button: { type: String, default: "Sprechen Sie uns gerne an!" },
});
const mainStore = useMainStore();
const { pages } = storeToRefs(mainStore); // Wir holen die `pages` aus dem Pinia-Store
const toggleContactBubble = () => mainStore.toggleContactBubble();
// 🔹 **FAQs für die aktuelle Seite aus `pages` filtern**
const accordionItems = computed(() => {
const currentPage = pages.value?.find(page => page.pageLink === props.pageLink);
const faqsArray = Array.isArray(currentPage?.faqs.data) ? currentPage.faqs.data : []; // Sicherstellen, dass es ein Array ist
return faqsArray.map(faq => ({
title: faq.attributes.question,
content: faq.attributes.answer,
}));
});
</script>
<div class="col-md-6">
<button @click.prevent="toggleContactBubble" role="button" class="pinkBtn">
{{ button }}
</button>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { computed, defineProps } from 'vue'
import { useMainStore } from '@/stores/main'
import { useHtmlConverter } from '~/composables/useHTMLConverter'
const { convertToHTML } = useHtmlConverter()
const props = defineProps({
pageLink: { type: String, required: true },
headline: { type: String, default: "Häufig gestellte Fragen (FAQs)" },
button: { type: String, default: "Sprechen Sie uns gerne an!" },
})
const mainStore = useMainStore()
const accordionItems = computed(() =>
mainStore.getFaqsByPageLink(props.pageLink).map(faq => ({
title: faq.question,
html: convertToHTML(faq.answer) // <- hier passiert die Umwandlung
}))
)
const toggleContactBubble = () => mainStore.toggleContactBubble()
</script>
<style lang="sass">
.faq

View File

@ -13,7 +13,7 @@
id="wave"
d=" M 0 0 L 500 0 L 500 14 Q 354.4 -2.8 250 11 Q 145.6 24.8 0 14 L 0 0 Z "
fill="#EEEBE5"
></path>
/>
</g>
</svg>
@ -21,52 +21,48 @@
<div class="container">
<h2 class="pt-4 pb-3">{{ title }}</h2>
<!-- Marquee mit doppeltem Inhalt für endloses Scrollen -->
<div class="marquee marquee--hover-pause mt-5">
<ul class="marquee__content">
<li v-for="(item, index) in items" :key="index">
<NuxtLink
v-if="item.link"
:to="`/${link}/${item.link}`"
class="custLogoLink"
>
<img
:src="cmsUrl + getImageUrl(item)"
<NuxtLink v-if="item.link" :to="item.link" class="custLogoLink">
<NuxtImg
:src="cmsUrl + item.logo.url"
alt="item.logo.alternativeText || 'Logo'"
width="250"
format="webp"
class="custLogo"
:style="{ height: logoHeight + 'px' }"
:alt="getAltText(item)"
/>
</NuxtLink>
<img
<NuxtImg
v-else
:src="cmsUrl + getImageUrl(item)"
:src="cmsUrl + item.logo.url"
alt="item.logo.alternativeText || 'Logo'"
width="250"
format="webp"
class="custLogo"
:style="{ height: logoHeight + 'px' }"
:alt="getAltText(item)"
/>
</li>
</ul>
<ul aria-hidden="true" class="marquee__content">
<li v-for="(item, index) in items" :key="'duplicate-' + index">
<NuxtLink
v-if="item.link"
:to="`/${link}/${item.link}`"
class="custLogoLink"
>
<img
:src="cmsUrl + getImageUrl(item)"
<!-- Duplizierte Liste für Endlos-Scroll -->
<ul class="marquee__content duplicate" aria-hidden="true">
<li v-for="(item, index) in items" :key="'dup-'+index">
<NuxtLink v-if="item.link" :to="item.link" class="custLogoLink">
<NuxtImg
:src="cmsUrl + item.logo.url"
alt="item.logo.alternativeText || 'Logo'"
width="250"
format="webp"
class="custLogo"
:style="{ height: logoHeight + 'px' }"
:alt="getAltText(item)"
/>
</NuxtLink>
<img
<NuxtImg
v-else
:src="cmsUrl + getImageUrl(item)"
:src="cmsUrl + item.logo.url"
alt="item.logo.alternativeText || 'Logo'"
width="250"
format="webp"
class="custLogo"
:style="{ height: logoHeight + 'px' }"
:alt="getAltText(item)"
/>
</li>
</ul>
@ -105,10 +101,13 @@
<script setup>
const runtimeConfig = useRuntimeConfig();
const cmsUrl = computed(() => runtimeConfig.public.cmsBaseUrl)
import { computed } from 'vue'
// Props: title, items, logoHeight, und link (optional)
// Runtime config & base CMS URL
const runtimeConfig = useRuntimeConfig()
const cmsUrl = computed(() => runtimeConfig.public.cmsBaseUrl)
// Props
const props = defineProps({
items: {
type: Array,
@ -116,7 +115,7 @@
},
logoHeight: {
type: Number,
default: 50, // Standardhöhe in Pixel
default: 50,
},
title: {
type: String,
@ -124,32 +123,9 @@
},
link: {
type: String,
default: 'projekt', // Standardwert, wenn keine spezifische Seite angegeben wird
default: 'projekt',
},
});
// Funktion zur Bestimmung des Bild-URLs basierend auf Typ des Items
const getImageUrl = (item) => {
if (item.logo) {
// Für Customers: Verwende das logo-Feld
return item.logo.data.attributes.url;
} else if (item.projectImages && item.projectImages.data.length > 0) {
// Für Projects: Verwende das erste Bild im projectImages-Feld
return item.projectImages.data[0].attributes.url;
}
return '';
};
// Funktion zur Ermittlung des Alt-Texts für das Bild
const getAltText = (item) => {
if (item.logo) {
return item.logo.data.attributes.alternativeText || item.company || '';
} else if (item.projectImages && item.projectImages.data.length > 0) {
return item.projectImages.data[0].attributes.alternativeText || item.projectTitle || '';
}
return '';
};
})
</script>
<style lang="sass">

View File

@ -1,43 +1,39 @@
// composables/usePageMeta.ts
import { watch, computed } from 'vue'
import { useRoute } from 'vue-router'
import { useMainStore } from '~/stores/main'
import { useMainStore } from '@/stores/main'
import { useHead } from '@unhead/vue'
export function usePageMeta() {
export function usePageMeta () {
const route = useRoute()
const pageLink = route.path
const mainStore = useMainStore()
const page = mainStore.getPageByLink(pageLink)
console.log(page)
// ► Der aktuelle Page-Eintrag als computed
const currentPage = computed(() => mainStore.getPageByLink(route.path))
if (!page) {
console.warn(`Keine Seite gefunden für den pageLink "${pageLink}"`)
return
}
// ► Reagiere auf Route- oder Store-Änderungen
watch(
() => currentPage.value, // Quelle
(page) => { // Callback
if (!page) return
const metaTitle = page.SEO?.pageTitle || 'Standard Title'
const metaDescription = page.SEO?.seoDescription || 'Standard Description'
const metaImage = page.SEO?.seoImage?.url || '/default-image.jpg'
const metaTitle = page.SEO?.pageTitle ?? 'Standard Title'
const metaDescription = page.SEO?.seoDescription ?? 'Standard Description'
const metaImage = page.SEO?.seoImage?.url ?? '/default-image.jpg'
try {
JSON.stringify(metaTitle)
JSON.stringify(metaDescription)
JSON.stringify(metaImage)
} catch (err) {
console.error('Fehler beim Serialisieren der Meta-Daten:', err)
}
useHead({
title: metaTitle,
meta: [
{ name: 'description', content: metaDescription },
{ property: 'og:title', content: metaTitle },
{ property: 'og:description', content: metaDescription },
{ property: 'og:image', content: metaImage },
{ name: 'twitter:title', content: metaTitle },
{ name: 'twitter:description', content: metaDescription },
{ name: 'twitter:image', content: metaImage },
]
})
useHead({
title: metaTitle,
meta: [
{ name: 'description', content: metaDescription },
{ property: 'og:title', content: metaTitle },
{ property: 'og:description', content: metaDescription },
{ property: 'og:image', content: metaImage },
{ name: 'twitter:title', content: metaTitle },
{ name: 'twitter:description', content: metaDescription },
{ name: 'twitter:image', content: metaImage }
]
})
},
{ immediate: true } // Sofort beim ersten Aufruf ausführen
)
}

View File

@ -1,8 +1,8 @@
<template>
<div class="homePage">
<section class="heroBox">
<section class="heroBox" aria-labelledby="hero-heading">
<div class="container-10">
<h1>{{ $t('home.heroBox.h1') }}</h1>
<h1 id="hero-heading">{{ $t('home.heroBox.h1') }}</h1>
<h2>{{ $t('home.heroBox.h2') }}</h2>
<h3>{{ $t('home.heroBox.h3') }}</h3>
</div>
@ -11,21 +11,28 @@
<path d="M 0 0 L 500 0 L 500 14 Q 354.4 -2.8 250 11 Q 145.6 24.8 0 14 L 0 0 Z" fill="#FFF"></path>
</svg>
</section>
<section>
<section aria-labelledby="solution-title">
<div class="container-10 webStrategy">
<img class="imgFloatLeft" src="https://strapi.digimedialoop.de/uploads/web_wireframe_Strategie_0bae802a68.png" alt="wireframe web strategie">
<NuxtImg
class="imgFloatLeft"
src="https://strapi.digimedialoop.de/uploads/web_wireframe_Strategie_0bae802a68.png"
alt="Wireframe Web Strategie"
width="300"
format="webp"
/>
<h2>{{ $t('home.solution.title') }}</h2>
<h2 id="solution-title">{{ $t('home.solution.title') }}</h2>
<h3>{{ $t('home.solution.teaser') }}</h3>
<p>{{ $t('home.solution.text') }}</p>
<button class="mintBtn"
role="button"
aria-describedby="solution-title"
aria-label="headless CMS Info" @click="navigateToArticle">{{ $t('home.solution.buttonText') }}</button>
</div>
</section>
<section class="targetGroup">
<section class="targetGroup" aria-labelledby="invitation-title">
<svg class="sectionWave wave-top" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 20">
<path d="M 0 0 L 500 0 L 500 14 Q 354.4 -2.8 250 11 Q 145.6 24.8 0 14 L 0 0 Z" fill="#FFF"></path>
</svg>
@ -34,10 +41,11 @@
<div class="col-md-4">
</div>
<div class="col-md-8 pt-5 pb-5">
<h2>{{ $t('home.invitation.title') }}</h2>
<h2 id="invitation-title">{{ $t('home.invitation.title') }}</h2>
<h3>{{ $t('home.invitation.teaser') }}</h3>
<button class="pinkBtn" @click.prevent="toggleContactBubble"
role="button"
aria-describedby="invitation-title"
aria-label="Kontaktformular öffnen">{{ $t('home.invitation.button') }}</button>
</div>
</div>
@ -76,9 +84,9 @@
<div class="row">
<div class="col-md-6 my-5">
<div class="innerBox">
<div class="canDoItem">
<div class="canDoItem" role="group" aria-labelledby="cando-title">
<div class="imageBox" style="background-image: url('https://strapi.digimedialoop.de/uploads/Screen_Shot_Tool_20250228133408_beb2a11980.png');"></div>
<h3>{{ $t('home.canDo.item3.title') }}</h3>
<h3 id="cando-title">{{ $t('home.canDo.item3.title') }}</h3>
<p>{{ $t('home.canDo.item3.text') }}</p>
</div>
</div>
@ -130,7 +138,7 @@
:aria-label="$t('home.finalCall.button')">{{ $t('home.finalCall.button') }}</button>
</div>
</section>
<MarqueeBanner :items="customers" :logoHeight="60" :title="$t('home.marqueeBanner.title')" />
<MarqueeBanner :items="logoItems" :logoHeight="60" :title="$t('home.marqueeBanner.title')" />
<FAQArea pageLink="/" :headline="$t('home.faqArea.headline')" />
</div>
@ -144,8 +152,11 @@ import { defineAsyncComponent } from 'vue';
import { useRouter } from 'vue-router';
const router = useRouter();
const runtimeConfig = useRuntimeConfig();
const cmsUrl = computed(() => runtimeConfig.public.cmsBaseUrl)
const mainStore = useMainStore();
const { cmsUrl, customers } = storeToRefs(mainStore);
const { customers } = storeToRefs(mainStore);
const toggleContactBubble = () => mainStore.toggleContactBubble();
const navigateToArticle = () => {
@ -155,6 +166,16 @@ const navigateToArticle = () => {
const screenWidth = computed(() => mainStore.screenWidth);
const waveHeight = computed(() => (screenWidth.value / 25).toFixed(0));
const logoItems = computed(() => {
return customers.value.map(customer => ({
company: customer.company || '',
logo: {
url: customer.logo?.url || '',
alternativeText: customer.company || ''
}
}))
})
</script>

View File

@ -1,4 +1,10 @@
import { useMainStore } from '@/stores/main'
export default defineNuxtPlugin(async (nuxtApp) => {
const store = useMainStore()
await store.fetchInitialData()
})
const mainStore = useMainStore(nuxtApp.$pinia)
if (!mainStore.dataFetched) {
await mainStore.fetchInitialData()
}
})

View File

@ -24,36 +24,53 @@ interface CompanyInfo {
}
}
interface SEO {
pageTitle: string
seoDescription: string // Achtung: Schreibfehler wird so übernommen
seoKeywords: string
type: string
seoImage?: CompanyLogo | null
}
interface PageSection {
id: number
sectionText: string
sectionImage?: CompanyLogo | null
}
interface FAQ {
question: string
answer: string
}
interface Page {
id: number
pageName: string
pageLink: string
header_image?: {
url: string
alternativeText?: string
} | null
SEO?: {
pageTitle: string
seoDescription: string
seoKeywords: string
type: string
seoImage?: {
url: string
alternativeText?: string
} | null
} | null
faqs: Array<{
question: string
answer: string
}>
pageSections: Array<{
id: number
sectionText: string
sectionImage?: {
url: string
alternativeText?: string
} | null
}>
header_image?: CompanyLogo | null
SEO?: SEO | null
faqs: FAQ[]
pageSections: PageSection[]
}
interface CustomerProject {
id: number
projectTitle: string
launchDate?: string
projectDescription?: string
link?: string
webpage?: string
technologies: { titel: string; icon?: string }[]
projectImages: CompanyLogo[]
}
interface Customer {
id: number
company: string
city: string
logo?: CompanyLogo | null
invertLogo?: CompanyLogo | null
projects: CustomerProject[]
}
export const useMainStore = defineStore('main', {
@ -64,108 +81,163 @@ export const useMainStore = defineStore('main', {
screenWidth: 1440,
companyinfo: null as CompanyInfo | null,
pages: [] as Page[],
customers: [] as Customer[],
dataFetched: false,
loading: false,
error: null as { message: string, stack?: string } | null,
error: null as { message: string; stack?: string } | null,
}),
getters: {
invertLogoUrl: (state) => {
const runtimeConfig = useRuntimeConfig()
const logoUrl = state.companyinfo?.invertlogo?.data?.attributes?.url
return logoUrl
return logoUrl
? `${runtimeConfig.public.cmsBaseUrl}${logoUrl}`
: '/uploads/dummy_Image_4abc3f04dd.webp'
},
isMobile: (state) => state.screenWidth < 768,
/** Neuer Getter: Seite anhand pageLink finden */
getPageByLink: (state) => {
return (link: string) => state.pages.find(p => p.pageLink === link)
getPageByLink: (state) => (link: string) =>
state.pages.find((p) => p.pageLink === link),
getCustomerById: (state) => (id: number) =>
state.customers.find((c) => c.id === id),
getFaqsByPageId: (state) => (pageId: number) =>
state.pages.find((p) => p.id === pageId)?.faqs ?? [],
getFaqsByPageLink: (state) => (link: string) => {
const page = state.pages.find(p => p.pageLink === link);
return page?.faqs ?? [];
}
},
actions: {
toggleMenu() {
this.menuOpen = !this.menuOpen
},
closeMenu() {
this.menuOpen = false
},
toggleContactBubble() {
this.contactBoxOpen = !this.contactBoxOpen
},
setScrollPosition(pos: number) {
this.scrollPosition = pos
},
setScreenWidth(width: number) {
this.screenWidth = width
},
async fetchInitialData() {
if (this.dataFetched) return
this.loading = true
const { public: cfg } = useRuntimeConfig()
try {
const runtimeConfig = useRuntimeConfig()
const cmsUrl = runtimeConfig.public.cmsBaseUrl
const [companyRes, pagesRes, customersRes] = await Promise.all([
$fetch(`${cfg.cmsBaseUrl}/api/companyinfo?populate=*`, {
headers: { Authorization: `Bearer ${cfg.cmsToken}` },
}),
$fetch(`${cfg.cmsBaseUrl}/api/pages?populate=*`, {
headers: { Authorization: `Bearer ${cfg.cmsToken}` },
}),
$fetch(`${cfg.cmsBaseUrl}/api/customers?populate=*`, {
headers: { Authorization: `Bearer ${cfg.cmsToken}` },
}),
])
const companyRes = await $fetch(`${cmsUrl}/api/companyinfo?populate=*`, {
headers: { 'Authorization': `Bearer ${runtimeConfig.public.cmsToken}` }
})
this.companyinfo = companyRes.data?.attributes || companyRes
// CompanyInfo (Single Type)
this.companyinfo = companyRes.data?.attributes ?? companyRes
const pagesRes = await $fetch(`${cmsUrl}/api/pages?populate=*`, {
headers: { 'Authorization': `Bearer ${runtimeConfig.public.cmsToken}` }
// Pages
this.pages = pagesRes.data.map((item: any) => {
const a = item.attributes
return {
id: item.id,
pageName: a.pageName,
pageLink: a.pageLink,
header_image: a.header_image?.data
? {
url: a.header_image.data.attributes.url,
alternativeText: a.header_image.data.attributes.alternativeText,
}
: null,
SEO: a.SEO
? {
pageTitle: a.SEO.pageTitle,
seoDescription: a.SEO.seoDesicription, // Fehler absichtlich
seoKeywords: a.SEO.seoKeywords,
type: a.SEO.type,
seoImage: a.SEO.seoImage?.data
? {
url: a.SEO.seoImage.data.attributes.url,
alternativeText: a.SEO.seoImage.data.attributes.alternativeText,
}
: null,
}
: null,
faqs: a.faqs?.data?.map((f: any) => ({
question: f.attributes.question,
answer: f.attributes.answer,
})) ?? [],
pageSections: a.pageSections?.map((s: any) => ({
id: s.id,
sectionText: s.sectionText,
sectionImage: s.sectionImage?.data
? {
url: s.sectionImage.data.attributes.url,
alternativeText: s.sectionImage.data.attributes.alternativeText,
}
: null,
})) ?? [],
}
})
this.pages = pagesRes.data.map((item: any) => ({
id: item.id,
pageName: item.attributes.pageName,
pageLink: item.attributes.pageLink,
header_image: item.attributes.header_image ? {
url: item.attributes.header_image.data.attributes.url,
alternativeText: item.attributes.header_image.data.attributes.alternativeText
} : null,
SEO: item.attributes.SEO ? {
pageTitle: item.attributes.SEO.pageTitle,
seoDescription: item.attributes.SEO.seoDesicription,
seoKeywords: item.attributes.SEO.seoKeywords,
type: item.attributes.SEO.type,
seoImage: item.attributes.SEO.seoImage ? {
url: item.attributes.SEO.seoImage.data.attributes.url,
alternativeText: item.attributes.SEO.seoImage.data.attributes.alternativeText
} : null
} : null,
faqs: item.attributes.faqs ? item.attributes.faqs.data.map((faq: any) => ({
question: faq.attributes.question,
answer: faq.attributes.answer
})) : [],
pageSections: item.attributes.pageSections ? item.attributes.pageSections.map((section: any) => ({
id: section.id,
sectionText: section.sectionText,
sectionImage: section.sectionImage ? {
url: section.sectionImage.data.attributes.url,
alternativeText: section.sectionImage.data.attributes.alternativeText
} : null
})) : []
}))
// Customers
this.customers = customersRes.data.map((item: any) => {
const a = item.attributes
return {
id: item.id,
company: a.company,
city: a.city,
logo: a.logo?.data?.attributes ?? null,
invertLogo: a.invertLogo?.data?.attributes ?? null,
projects: (a.projects?.data ?? []).map((p: any) => ({
id: p.id,
projectTitle: p.attributes.projectTitle,
projectImages: p.attributes.projectImages?.data?.map((img: any) => ({
url: img.attributes.url,
alternativeText: img.attributes.alternativeText,
})) ?? [],
launchDate: p.attributes.launchDate,
projectDescription: p.attributes.projectDescription,
link: p.attributes.link,
webpage: p.attributes.webpage,
technologies: p.attributes.Technologien?.data?.map((t: any) => ({
titel: t.attributes.titel,
icon: t.attributes.icon,
})) ?? [],
})),
}
})
this.dataFetched = true
} catch (err) {
const errorObj = err as Error
this.error = {
message: errorObj.message,
stack: errorObj.stack
}
console.error('Fehler beim Laden der Daten:', errorObj)
const e = err as Error
this.error = { message: e.message, stack: e.stack }
console.error('Fetch-Fehler:', e)
} finally {
this.loading = false
}
}
}
},
},
})