diff --git a/frontend/src/lib/actions/clickOutside.ts b/frontend/src/lib/actions/clickOutside.ts
new file mode 100644
index 0000000..c7b0252
--- /dev/null
+++ b/frontend/src/lib/actions/clickOutside.ts
@@ -0,0 +1,26 @@
+/**
+ * Svelte action: click outside detection
+ * Usage:
+ * Calls the callback when a click occurs outside the element.
+ */
+export function clickOutside(node: HTMLElement, callback: () => void) {
+ function handleClick(event: MouseEvent) {
+ if (!node.contains(event.target as Node)) {
+ callback()
+ }
+ }
+
+ // Delay adding the listener to avoid catching the triggering click
+ setTimeout(() => {
+ document.addEventListener("click", handleClick, true)
+ }, 0)
+
+ return {
+ update(newCallback: () => void) {
+ callback = newCallback
+ },
+ destroy() {
+ document.removeEventListener("click", handleClick, true)
+ },
+ }
+}
diff --git a/frontend/src/lib/formContext.ts b/frontend/src/lib/formContext.ts
new file mode 100644
index 0000000..634ac6d
--- /dev/null
+++ b/frontend/src/lib/formContext.ts
@@ -0,0 +1,12 @@
+export const FORM_CONTEXT = Symbol("FORM_CONTEXT")
+
+export interface ValidatableField {
+ validate: () => Promise | boolean
+ reset: () => void
+ focus?: () => void
+}
+
+export interface FormContext {
+ register: (field: ValidatableField) => void
+ unregister: (field: ValidatableField) => void
+}
diff --git a/frontend/src/lib/requestsStore.ts b/frontend/src/lib/requestsStore.ts
new file mode 100644
index 0000000..6cb0065
--- /dev/null
+++ b/frontend/src/lib/requestsStore.ts
@@ -0,0 +1,21 @@
+import { writable } from "svelte/store"
+
+/**
+ * Global store to track active API requests
+ * Used to display global loading indicator
+ */
+export const activeRequests = writable(0)
+
+/**
+ * Increment active requests counter
+ */
+export function incrementRequests() {
+ activeRequests.update((count) => count + 1)
+}
+
+/**
+ * Decrement active requests counter
+ */
+export function decrementRequests() {
+ activeRequests.update((count) => Math.max(0, count - 1))
+}
diff --git a/frontend/src/lib/serverBuildInfo.ts b/frontend/src/lib/serverBuildInfo.ts
new file mode 100644
index 0000000..99e75c5
--- /dev/null
+++ b/frontend/src/lib/serverBuildInfo.ts
@@ -0,0 +1,10 @@
+import { writable } from "svelte/store"
+
+const serverBuildTime = writable(null)
+
+const setServerBuildTime = (value: string | null | undefined): void => {
+ if (!value) return
+ serverBuildTime.set(value)
+}
+
+export { serverBuildTime, setServerBuildTime }
diff --git a/frontend/src/lib/store.ts b/frontend/src/lib/store.ts
index c598f69..8c7171a 100644
--- a/frontend/src/lib/store.ts
+++ b/frontend/src/lib/store.ts
@@ -2,8 +2,19 @@ import { get, writable } from "svelte/store"
/*********** location **************************/
+/**
+ * Strip trailing slash from a path, preserving root "/".
+ * E.g. "/about/" → "/about"
+ */
+const stripTrailingSlash = (path: string): string => {
+ if (path && path.length > 1 && path.endsWith("/")) {
+ return path.replace(/\/+$/, "")
+ }
+ return path
+}
+
const initLoc = {
- path: (typeof window !== "undefined" && window.location?.pathname) || "/",
+ path: stripTrailingSlash((typeof window !== "undefined" && window.location?.pathname) || "/"),
search: (typeof window !== "undefined" && window.location?.search) || "",
hash: (typeof window !== "undefined" && window.location?.hash) || "",
push: false,
@@ -26,8 +37,9 @@ const publishLocation = (_p?: string) => {
if (_s) _s = "?" + _s
}
+ const rawPath = _p || (typeof window !== "undefined" && window.location?.pathname)
const newLocation: LocationStore = {
- path: _p || (typeof window !== "undefined" && window.location?.pathname),
+ path: stripTrailingSlash(rawPath),
search: _p ? _s : typeof window !== "undefined" && window.location?.search,
hash: _p ? _h : typeof window !== "undefined" && window.location?.hash,
push: !!_p,
@@ -79,6 +91,26 @@ typeof window !== "undefined" &&
publishLocation()
})
+/********************** UI State Stores *****************/
+
+// Store for mobile menu open state (shared between Header and navigation components)
+export const mobileMenuOpen = writable(false)
+
+/********************** Current Content Page *****************/
+
+// Store for tracking the currently displayed content page (used for cross-language linking etc.)
+export const currentContentEntry = writable<{ translationKey?: string; lang?: string; path?: string } | null>(null)
+
/********************** override for admin ui *****************/
export const apiBaseOverride = writable(null)
+
+/********************** Navigation History *****************/
+
+// Store for tracking previous path (used for conditional back button)
+export const previousPath = writable(null)
+
+/********************** Cookie Consent *****************/
+
+// Whether the cookie consent banner is currently visible
+export const cookieConsentVisible = writable(false)
diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts
new file mode 100644
index 0000000..4dbff9b
--- /dev/null
+++ b/frontend/src/lib/utils.ts
@@ -0,0 +1,59 @@
+export function debounce void>(func: T, wait: number): (...args: Parameters) => void {
+ let timeout: NodeJS.Timeout | null = null
+
+ return (...args: Parameters) => {
+ if (timeout) {
+ clearTimeout(timeout)
+ }
+ timeout = setTimeout(() => func(...args), wait)
+ }
+}
+
+export function formatNumber(number: number, decimals = 2): string {
+ return number.toFixed(decimals)
+}
+
+export function formatCurrency(amount: number, currency = "EUR", locale = "de-DE"): string {
+ // goja (tibi-server SSR runtime) does not provide Intl
+ if (typeof Intl === "undefined") {
+ const fixed = amount.toFixed(2).replace(".", ",")
+ return `${fixed}\u00A0${currency === "EUR" ? "€" : currency}`
+ }
+ return new Intl.NumberFormat(locale, {
+ style: "currency",
+ currency: currency,
+ }).format(amount)
+}
+
+export function generateMockImage(seed: string): string {
+ // Generate a consistent color based on the seed
+ const colors = [
+ "bg-red-200",
+ "bg-blue-200",
+ "bg-green-200",
+ "bg-yellow-200",
+ "bg-purple-200",
+ "bg-pink-200",
+ "bg-indigo-200",
+ "bg-teal-200",
+ ]
+
+ const colorIndex = seed.split("").reduce((acc, char) => acc + char.charCodeAt(0), 0) % colors.length
+ return colors[colorIndex]
+}
+
+export function highlightSearchTerm(text: string, searchTerm: string): string {
+ if (!searchTerm.trim()) return text
+
+ const regex = new RegExp(`(${searchTerm})`, "gi")
+ return text.replace(regex, '$1')
+}
+
+export function slugify(value: string): string {
+ return value
+ .normalize("NFD")
+ .replace(/[\u0300-\u036f]/g, "")
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/(^-|-$)/g, "")
+}
diff --git a/types/global.d.ts b/types/global.d.ts
index 885e139..2d6a68c 100644
--- a/types/global.d.ts
+++ b/types/global.d.ts
@@ -19,6 +19,7 @@ interface ApiOptions {
params?: {
[key: string]: string
}
+ signal?: AbortSignal
}
interface LocationStore {
@@ -33,6 +34,8 @@ interface LocationStore {
interface ApiResult {
data: T
count: number
+ /** Build timestamp from server (X-Build-Time header), only present on client requests */
+ buildTime?: string | null
}
interface FileField {
@@ -43,13 +46,106 @@ interface FileField {
}
interface MedialibEntry {
- id: string
- // ...
+ id?: string
+ file?: {
+ src?: string
+ type?: string
+ }
+ alt?: string
+ [key: string]: unknown
}
+/** Pagebuilder: Content Block Entry */
+interface ContentBlockEntry {
+ hide?: boolean
+ headline?: string
+ headlineH1?: boolean
+ subline?: string
+ tagline?: string
+ anchorId?: string
+ containerWidth?: "" | "wide" | "full"
+ background?: {
+ color?: string
+ image?: string
+ }
+ padding?: {
+ top?: string
+ bottom?: string
+ }
+ type?: string
+ callToAction?: {
+ buttonText?: string
+ buttonLink?: string
+ buttonTarget?: string
+ }
+ heroImage?: {
+ image?: string
+ }
+ // richtext fields
+ text?: string
+ imagePosition?: "none" | "left" | "right"
+ imageRounded?: string
+ image?: string
+ // accordion fields
+ accordionItems?: {
+ question?: string
+ answer?: string
+ open?: boolean
+ }[]
+ // imageGallery fields
+ imageGallery?: {
+ images?: {
+ image?: string
+ caption?: string
+ showCaption?: boolean
+ }[]
+ }
+ // richtext caption fields
+ showImageCaption?: boolean
+ imageCaption?: string
+}
+
+/** Content Entry from the CMS */
interface ContentEntry {
- id: string
- // ...
+ id?: string
+ _id?: string
+ active?: boolean
+ publication?: {
+ from?: string | Date
+ to?: string | Date
+ }
+ type?: string
+ lang?: string
+ translationKey?: string
+ name?: string
+ path?: string
+ alternativePaths?: { path?: string }[]
+ thumbnail?: string
+ teaserText?: string
+ blocks?: ContentBlockEntry[]
+ meta?: {
+ title?: string
+ description?: string
+ keywords?: string
+ }
+}
+
+/** Navigation element */
+interface NavigationElement {
+ name: string
+ external?: boolean
+ page?: string
+ hash?: string
+ externalUrl?: string
+}
+
+/** Navigation entry from the CMS */
+interface NavigationEntry {
+ id?: string
+ _id?: string
+ language?: string
+ type?: "header" | "footer"
+ elements?: NavigationElement[]
}
interface ProductEntry {