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 {