header & hero texts

This commit is contained in:
2025-10-06 14:44:46 +00:00
parent 9af63ab5a3
commit 8974ae93e1
11 changed files with 202 additions and 292 deletions

View File

@@ -17,3 +17,16 @@ export const socialIcons = {
linkedin: "https://www.linkedin.com/company/kontextwerk",
youtube: "https://www.youtube.com/@kontextwerk",
}
export type HeaderLinkType = "link" | "button"
export type HeaderLink =
| ({ sectionId: string; href?: never } & { label: string; type?: HeaderLinkType })
| ({ href: string; sectionId?: never } & { label: string; type?: HeaderLinkType })
export const headerLinks: HeaderLink[] = [
{ label: "Überblick", sectionId: "leistungen" },
{ label: "Voicebot", sectionId: "voicebot-demo" },
{ label: "Chatbot", sectionId: "chatbot-demo" },
{ label: "Kontakt", sectionId: "kontakt", type: "button" },
]

View File

@@ -15,7 +15,8 @@ export const spaLink: Action<HTMLAnchorElement> = (node) => {
const currentUrl = window.location.pathname + window.location.search + window.location.hash
if (nextUrl === currentUrl) {
window.scrollTo({ top: 0, behavior: "smooth" })
const hashValue = anchor.hash ? anchor.hash.substring(1) : null
scrollToTarget(hashValue)
return
}
@@ -32,14 +33,48 @@ export const spaLink: Action<HTMLAnchorElement> = (node) => {
}
}
const scrollToTarget = (hash: string | null) => {
const header = document.getElementById("header-container")
const headerHeight = header?.getBoundingClientRect().height ?? 0
if (!hash) {
window.scrollTo({ top: 0, behavior: "smooth" })
return
}
const decodedHash = decodeURIComponent(hash)
const tryScroll = (attempt = 0) => {
const target = document.getElementById(decodedHash)
if (target) {
const targetPosition = target.getBoundingClientRect().top + window.scrollY
const offset = Math.max(targetPosition - headerHeight - 16, 0)
window.scrollTo({ top: offset, behavior: "smooth" })
return
}
if (attempt < 10) {
requestAnimationFrame(() => tryScroll(attempt + 1))
} else {
window.scrollTo({ top: 0, behavior: "smooth" })
}
}
requestAnimationFrame(() => tryScroll())
}
export const spaNavigate = (to: string, options?: { replace?: boolean }) => {
const hashIndex = to.indexOf("#")
const hash = hashIndex >= 0 ? to.slice(hashIndex + 1) : null
if (options?.replace) {
window.history.replaceState(null, "", to)
} else {
window.history.pushState(null, "", to)
}
window.scrollTo({ top: 0, behavior: "smooth" })
scrollToTarget(hash)
}
export const spaBack = () => {

View File

@@ -1,9 +1,13 @@
<script lang="ts">
import { onMount } from "svelte"
import { spaLink } from "../../actions"
import { location } from "../../store"
import { headerLinks } from "../../../config"
import type { HeaderLink } from "../../../config"
let scrolled: boolean = false,
isHomepage: boolean = true
let scrolled: boolean = $state(false)
const links: HeaderLink[] = headerLinks
function checkScroll() {
scrolled = window.scrollY >= 100
@@ -19,8 +23,13 @@
}
})
$: checkScroll()
$: darkBG = scrolled
$effect(() => {
checkScroll()
})
let isHomepage = $derived($location?.path === "/")
let darkBG = $derived(scrolled || !isHomepage)
const resolveHref = (link: HeaderLink) => ("sectionId" in link ? `/#${link.sectionId}` : link.href)
</script>
<header
@@ -44,6 +53,20 @@
alt="logo"
/>
</a>
<ul class="nav-links">
{#each links as link}
<li>
<a
href={resolveHref(link)}
use:spaLink
class="nav-link"
class:nav-link-button={link.type === "button"}
>
{link.label}
</a>
</li>
{/each}
</ul>
</nav>
</div>
</header>
@@ -92,6 +115,80 @@
object-fit: contain;
}
}
.nav-links {
display: flex;
align-items: center;
gap: 1.5rem;
list-style: none;
margin: 0;
padding: 0;
li {
display: flex;
align-items: center;
justify-content: center;
}
.nav-link {
color: var(--text-100);
text-decoration: none;
font-weight: 500;
font-size: 0.95rem;
transition:
color 0.2s ease,
background-color 0.2s ease,
border-color 0.2s ease;
padding: 0.5rem 0;
position: relative;
&:focus-visible {
outline: 2px solid var(--primary-200);
outline-offset: 3px;
}
&:after {
content: "";
position: absolute;
left: 0;
bottom: 0;
width: 100%;
height: 2px;
background: currentColor;
transform: scaleX(0);
transform-origin: left;
transition: transform 0.2s ease;
}
&:hover,
&:focus-visible {
color: var(--primary-200);
&:after {
transform: scaleX(1);
}
}
}
.nav-link-button {
border: 1px solid var(--primary-200);
border-radius: 999px;
padding: 0.45rem 1.4rem;
font-weight: 600;
color: var(--neutral-white);
background: linear-gradient(135deg, var(--primary-200), var(--primary-100));
box-shadow: 0 0 12px rgba(116, 30, 32, 0.45);
&:after {
display: none;
}
transition:
background 0.3s ease,
color 0.3s ease;
&:hover,
&:focus-visible {
color: var(--neutral-white);
background: linear-gradient(135deg, var(--primary-100), #ff5252);
}
}
}
}
}
&.homepageHeader {
@@ -102,4 +199,35 @@
background-color: var(--bg-100);
}
}
@media @mobile {
.headercontainer {
.padding {
.menu {
flex-direction: column;
height: auto;
padding: 0.8rem 0;
gap: 0.8rem;
.logo-container {
height: 48px;
img {
height: 44px;
}
}
.nav-links {
flex-wrap: wrap;
justify-content: center;
gap: 0.8rem 1.2rem;
.nav-link {
padding: 0.4rem 0;
font-size: 0.9rem;
}
.nav-link-button {
padding: 0.4rem 1rem;
}
}
}
}
}
}
</style>

View File

@@ -46,6 +46,7 @@
properties={voiceProperties}
title="Chatbot Demo"
reverse={true}
sectionId="chatbot-demo"
upperDescription="Unsere Voicebots sind rund um die Uhr für Ihre Kunden da und bieten maßgeschneiderte Lösungen, die perfekt auf Ihre Bedürfnisse abgestimmt sind."
lowerDescription="Durch den Einsatz modernster KI-Technologien gewährleisten wir eine intelligente und effiziente Kommunikation, die den höchsten Datenschutzstandards entspricht."
>

View File

@@ -84,7 +84,10 @@
<CrinkledSection>
{#snippet contentSnippet()}
<section class="small-wrapper contact-row">
<section
class="small-wrapper contact-row"
id="kontakt"
>
<div class="contact-wrapper">
<div class="copy">
<small>Kontakt</small>

View File

@@ -46,28 +46,28 @@
title: "Schneller",
alias: "Schneller",
shortDescription:
"Unser internes System sorgt für eine schnelle und effiziente Umsetzung Ihres Projekts. Dadurch ermöglichen wir, ihr Projekt in Wochen, statt Monaten zu realisieren!",
"Projektstart innerhalb von 3 Werktagen. Ein FAQ-Bot ist nach 3 Tagen live, komplexere Lösungen in Rekordzeit möglich durch erprobte Prozesse und eine modulare Codebasis.",
color: "#ffffff",
},
{
title: "Qualitativer",
alias: "Qualitativer",
title: "Zuverlässiger",
alias: "Zuverlässiger",
shortDescription:
"Höhere Qualität durch spezialisierte Experten. Wir setzen auf ein Netzwerk aus erfahrenen Fachleuten, um Ihnen die bestmöglichen Lösungen mit State-of-the-Art-Technologien zu bieten.",
"Der Assistent greift ausschließlich auf freigegebenes Unternehmenswissen zu. Mehrstufige Qualitätsprüfungen und monatliche KPI-Berichte sichern dauerhaft verlässliche Ergebnisse.",
color: "#741e20",
},
{
title: "Entspannter",
alias: "Entspannter",
title: "Sorglos",
alias: "Sorglos",
shortDescription:
"Wir bieten Ihnen einen Rundum-sorglos-Service. Von der Konzeption über die Umsetzung bis hin zur Nachbetreuung. Alles aus einer Hand.",
"Alles aus einer Hand: Kick-off-Workshop, Datenaufbereitung, Implementierung und Kanalintegration. Hosting im deutschen Rechenzentrum, 24/7-Monitoring, automatisierte Tests und kontinuierliche Optimierung sind für uns Standard.",
color: "#ffffff",
},
{
title: "Autonomer",
alias: "Autonomer",
title: "Unabhängiger",
alias: "Unabhängiger",
shortDescription:
"Sie entscheiden wo die Software gehostet wird. Ob bei uns in unseren deutschen Rechenzentren oder bei Ihnen, wir bieten beides an.",
"Betriebsmodell nach Wahl: Managed in deutschen Rechenzentren, Private Cloud oder On-Premise. Offene Schnittstellen und containerisierte Übergabe garantieren volle Kontrolle auf Wunsch auch mit Ihrem eigenen Sprachmodell.",
color: "#741e20",
},
]
@@ -75,6 +75,7 @@
<section
class="splittedHomepage"
id="leistungen"
style="--color: {currentColor}"
>
<div class="wrapper">

View File

@@ -53,6 +53,7 @@
<ProductCategoryFrame
properties={voiceProperties}
title="Voicebot Demo"
sectionId="voicebot-demo"
upperDescription="Unsere Voicebots sind rund um die Uhr für Ihre Kunden da und bieten maßgeschneiderte Lösungen, die perfekt auf Ihre Bedürfnisse abgestimmt sind."
lowerDescription="Durch den Einsatz modernster KI-Technologien gewährleisten wir eine intelligente und effiziente Kommunikation, die den höchsten Datenschutzstandards entspricht."
>

View File

@@ -1,53 +0,0 @@
<script lang="ts">
import { onMount } from "svelte"
export let query: string
let mql: MediaQueryList
let mqlListener: {
(v: any): any
(this: MediaQueryList, ev: MediaQueryListEvent): any
(this: MediaQueryList, ev: MediaQueryListEvent): any
}
let wasMounted = false
let matches = false
onMount(() => {
wasMounted = true
return () => {
removeActiveListener()
}
})
$: {
if (wasMounted) {
removeActiveListener()
addNewListener(query)
}
}
function addNewListener(query: string) {
if (!query) {
query = "(min-width:0px)"
console.warn(
"Missing view property 'meta.views.mediaQuery' in collection config. Query has been set to '(min-width:0px)'."
)
}
if (typeof "window" == "undefined") return
mql = window.matchMedia(query)
mqlListener = (e: { matches: boolean }) => (matches = e.matches)
//mql.addListener(mqlListener)
mql.addEventListener("change", mqlListener)
matches = mql.matches
}
function removeActiveListener() {
if (mql && mqlListener) {
// mql.removeListener(mqlListener)
mql.removeEventListener("change", mqlListener)
}
}
</script>
{#if matches}
<slot />
{/if}

View File

@@ -1,83 +0,0 @@
<script module lang="ts">
import pDebounce from "p-debounce"
import { apiBaseURL } from "../../configs/config"
import { getCachedEntries, getCachedEntry } from "../../api"
const medialibCache: { [id: string]: MedialibEntry } = {}
let loadQueue: string[] = []
export async function loadMedialibEntry(id: string): Promise<MedialibEntry> {
if (medialibCache[id]) return medialibCache[id]
loadQueue.push(id)
await processQueueDebounced()
return medialibCache[id]
}
const processQueue = async () => {
if (loadQueue.length) {
const _ids = loadQueue
loadQueue = []
const entries = await getCachedEntries("medialib", { _id: { $in: _ids } })
entries.forEach((entry) => {
medialibCache[entry.id] = entry
})
}
}
const processQueueDebounced = pDebounce(processQueue, 50)
</script>
<script lang="ts">
let {
id,
noPlaceholder,
loading,
childrenSnippet,
loadingSnippet,
notFoundSnippet,
}: {
id: string
noPlaceholder?: boolean
loading?: boolean
loadingSnippet: ({ entry, src }: { entry: MedialibEntry; src: string }) => any
childrenSnippet: ({ entry, src }: { entry: MedialibEntry; src: string }) => any
notFoundSnippet: () => any
} = $props()
let entry: MedialibEntry = $state(),
fileSrc: string = $state()
async function loadFile() {
loading = true
entry = null
fileSrc = null
try {
entry =
typeof window !== "undefined"
? await loadMedialibEntry(id)
: await getCachedEntry("medialib", { _id: id })
if (entry?.file?.src) {
fileSrc = `${apiBaseURL}${window?.location.host.includes("tibi") ? "_/renz_shop_2024/" : ""}medialib/${id}/${entry.file.src}`
}
} catch (e) {
console.error(e)
}
loading = false
}
$effect(() => {
if (id) loadFile()
})
</script>
{#if id}
{#if loading}
{#if !noPlaceholder}
{@render loadingSnippet({ entry, src: fileSrc })}
{/if}
{:else if entry}
{@render childrenSnippet({ entry, src: fileSrc })}
{:else if !noPlaceholder}
{@render notFoundSnippet()}
{/if}
{/if}

View File

@@ -1,138 +0,0 @@
<script lang="ts">
import { onDestroy } from "svelte"
import MedialibFile from "./MedialibFile.svelte"
interface Props {
id: string
filter?:
| null
| "xs"
| "s"
| "m"
| "l"
| "xl"
| "xxl"
| "xs-webp"
| "s-webp"
| "m-webp"
| "l-webp"
| "xl-webp"
| "xxl-webp"
minWidth?: number
noPlaceholder?: boolean
style?: string
widthMultiplier?: number
lazy?: boolean
}
let {
id,
filter,
minWidth = null,
noPlaceholder = false,
style,
widthMultiplier = 1,
lazy = false,
}: Props = $props()
let imgElement: HTMLImageElement = $state()
function getSrcWithFilter(_imgElement: HTMLImageElement, entry: MedialibEntry, src: string) {
if (typeof window === "undefined") {
return src + `?filter=${filter || "xs-webp"}`
}
let internalFilter = filter
if (imgElement) {
if (!entry.file?.type?.match(/^image\/(png|jpe?g|webp)/)) return src // no filter for svg
if (!internalFilter) {
let imgWidth = _imgElement.width
if (widthMultiplier) {
imgWidth *= widthMultiplier
}
// get the width of the image element
const width = minWidth ? (imgWidth < minWidth ? minWidth : imgWidth) : imgWidth
switch (true) {
case width <= 90:
internalFilter = "xs-webp"
break
case width <= 300:
internalFilter = "s-webp"
break
case width <= 600:
internalFilter = "m-webp"
break
case width <= 1200:
internalFilter = "l-webp"
break
case width <= 2000:
internalFilter = "xl-webp"
break
case width <= 4000:
internalFilter = "xxl-webp"
break
}
}
return src + (internalFilter ? `?filter=${internalFilter}` : "")
} else {
// placeholder
return "/assets/img/placeholder-image.png"
}
}
let resizeObserver: ResizeObserver
function initResizeObserver(node: HTMLImageElement, { entry, src }: { entry: MedialibEntry; src: string }) {
if (!filter && typeof ResizeObserver !== "undefined") {
resizeObserver?.disconnect()
resizeObserver = new ResizeObserver(() => {
const oldWidth = parseInt(node.getAttribute("data-width")) || 0
const newWidth = node.width
if (oldWidth >= newWidth) return
const newSrc = getSrcWithFilter(imgElement, entry, src)
if (newSrc !== node.getAttribute("src")) {
node.setAttribute("src", newSrc)
}
node.setAttribute("data-width", newWidth.toString())
})
resizeObserver.observe(node)
}
return {
destroy() {
resizeObserver?.disconnect()
},
}
}
onDestroy(() => {
resizeObserver?.disconnect()
})
let properties = {}
if (lazy) {
properties = { loading: "lazy" }
}
</script>
{#if id}
<MedialibFile {id} {noPlaceholder}>
{#snippet childrenSnippet({ entry, src }: { entry: MedialibEntry; src: string })}
<img
{style}
bind:this={imgElement}
src={getSrcWithFilter(imgElement, entry, src)}
alt={entry.alt || "icon"}
data-entry-id={id}
use:initResizeObserver={{ entry, src }}
loading="lazy"
{...properties}
/>
{/snippet}
{#snippet loadingSnippet({ entry, src }: { entry: MedialibEntry; src: string })}{/snippet}
{#snippet notFoundSnippet()}{/snippet}
</MedialibFile>
{/if}

View File

@@ -7,7 +7,7 @@
upperDescription,
lowerDescription,
reverse = false,
sectionId,
primaryContent,
}: {
properties: Array<{ title: string; icon: string; color: string }>
@@ -15,12 +15,14 @@
upperDescription: string
reverse?: boolean
lowerDescription: string
sectionId?: string
primaryContent: () => any
} = $props()
</script>
<section
class="product-frame"
id={sectionId}
class:reverse
>
<div class="wrapper">