header & hero texts
This commit is contained in:
@@ -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" },
|
||||
]
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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."
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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."
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user