Updated site completely
Some checks failed
GitHub Actions / build (18) (push) Has been cancelled
GitHub Actions / build (20) (push) Has been cancelled
GitHub Actions / build (22) (push) Has been cancelled
GitHub Actions / check (push) Has been cancelled

This commit is contained in:
becarta
2025-03-29 22:32:31 +01:00
parent a9adf1bb4f
commit 890d7b8670
56 changed files with 1807 additions and 1299 deletions

View File

@@ -0,0 +1,30 @@
import { JSDOM } from 'jsdom';
import createDOMPurify from 'dompurify';
export const handler = async (event) => {
try {
const data = JSON.parse(event.body);
const DOMPurify = createDOMPurify(new JSDOM('').window);
// Sanitize user input
const sanitizedData = {
name: DOMPurify.sanitize(data.name),
email: DOMPurify.sanitize(data.email),
message: DOMPurify.sanitize(data.message),
};
// TODO: Process the sanitized data (e.g., send an email)
console.log('Sanitized data:', sanitizedData);
return {
statusCode: 200,
body: JSON.stringify({ message: 'Form submitted successfully!' }),
};
} catch (error) {
console.error('Error:', error);
return {
statusCode: 500,
body: JSON.stringify({ message: 'An error occurred.' }),
};
}
};

9
package-lock.json generated
View File

@@ -4016,9 +4016,9 @@
} }
}, },
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001667", "version": "1.0.30001707",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001667.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001707.tgz",
"integrity": "sha512-7LTwJjcRkzKFmtqGsibMeuXmvFDfZq/nzIjnmgCGzKKRVzjD72selLDK1oPF/Oxzmt4fNcPvTDvGqSDG4tCALw==", "integrity": "sha512-3qtRjw/HQSMlDWf+X79N206fepf4SOOU6SQLMaq/0KkZLmSjPxAkBOQQ+FxbHKfHmYLZFfdWsO3KA90ceHPSnw==",
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@@ -4032,7 +4032,8 @@
"type": "github", "type": "github",
"url": "https://github.com/sponsors/ai" "url": "https://github.com/sponsors/ai"
} }
] ],
"license": "CC-BY-4.0"
}, },
"node_modules/ccount": { "node_modules/ccount": {
"version": "2.0.1", "version": "2.0.1",

View File

@@ -1,14 +1,16 @@
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@layer base { @layer base {
p, li, div { p,
li,
div {
line-height: 1.5; line-height: 1.5;
} }
} }
@layer utilities { @layer utilities {
.bg-page { .bg-page {
background-color: var(--aw-color-bg-page); background-color: var(--aw-color-bg-page);
} }
@@ -24,83 +26,75 @@
.text-muted { .text-muted {
color: var(--aw-color-text-muted); color: var(--aw-color-text-muted);
} }
.content-backdrop { }
@apply bg-white/20 dark:bg-gray-900/15 backdrop-blur-sm hover:bg-white/30 hover:backdrop-blur-md dark:hover:backdrop-blur-md transition-all duration-300 ease-in-out;
}
.content-card {
@apply rounded-lg p-4 md:p-6 content-backdrop;
}
}
@layer components { @layer components {
.btn { .btn {
@apply inline-flex items-center justify-center rounded-full border-gray-400 border bg-transparent font-medium text-center text-base text-page leading-snug transition py-3.5 px-6 md:px-8 ease-in duration-200 focus:ring-blue-500 focus:ring-offset-blue-200 focus:ring-2 focus:ring-offset-2 hover:bg-gray-100 hover:border-gray-600 dark:text-slate-300 dark:border-slate-500 dark:hover:bg-slate-800 dark:hover:border-slate-800 cursor-pointer; @apply inline-flex items-center justify-center rounded-full border-gray-400 border bg-transparent font-medium text-center text-base text-page leading-snug transition py-3.5 px-6 md:px-8 ease-in duration-200 focus:ring-blue-500 focus:ring-offset-blue-200 focus:ring-2 focus:ring-offset-2 hover:bg-gray-100 hover:border-gray-600 dark:text-slate-300 dark:border-slate-500 dark:hover:bg-slate-800 dark:hover:border-slate-800 cursor-pointer;
} }
.btn-primary { .btn-primary {
@apply btn font-semibold bg-primary text-white border-primary hover:bg-secondary hover:border-secondary hover:text-white dark:text-white dark:bg-primary dark:border-primary dark:hover:border-secondary dark:hover:bg-secondary; @apply btn font-semibold bg-primary text-white border-primary hover:bg-secondary hover:border-secondary hover:text-white dark:text-white dark:bg-primary dark:border-primary dark:hover:border-secondary dark:hover:bg-secondary;
} }
.btn-secondary { .btn-secondary {
@apply btn; @apply btn;
} }
.btn-tertiary { .btn-tertiary {
@apply btn border-none shadow-none text-muted hover:text-gray-900 dark:text-gray-400 dark:hover:text-white; @apply btn border-none shadow-none text-muted hover:text-gray-900 dark:text-gray-400 dark:hover:text-white;
} }
} }
#header.scroll > div:first-child { #header.scroll > div:first-child {
@apply bg-page md:bg-white/90 md:backdrop-blur-md; @apply bg-page md:bg-white/90 md:backdrop-blur-md;
box-shadow: 0 0.375rem 1.5rem 0 rgb(140 152 164 / 13%); box-shadow: 0 0.375rem 1.5rem 0 rgb(140 152 164 / 13%);
} }
.dark #header.scroll > div:first-child, .dark #header.scroll > div:first-child,
#header.scroll.dark > div:first-child { #header.scroll.dark > div:first-child {
@apply bg-page md:bg-[#030621e6] border-b border-gray-500/20; @apply bg-page md:bg-[#030621e6] border-b border-gray-500/20;
box-shadow: none; box-shadow: none;
} }
/* #header.scroll > div:last-child { /* #header.scroll > div:last-child {
@apply py-3; @apply py-3;
} */ } */
#header.expanded nav { #header.expanded nav {
position: fixed; position: fixed;
top: 70px; top: 70px;
left: 0; left: 0;
right: 0; right: 0;
bottom: 70px !important; bottom: 70px !important;
padding: 0 5px; padding: 0 5px;
} }
.dropdown:focus .dropdown-menu, .dropdown:focus .dropdown-menu,
.dropdown:focus-within .dropdown-menu, .dropdown:focus-within .dropdown-menu,
.dropdown:hover .dropdown-menu { .dropdown:hover .dropdown-menu {
display: block; display: block;
} }
[astro-icon].icon-light > * { [astro-icon].icon-light > * {
stroke-width: 1.2; stroke-width: 1.2;
} }
[astro-icon].icon-bold > * { [astro-icon].icon-bold > * {
stroke-width: 2.4; stroke-width: 2.4;
} }
[data-aw-toggle-menu] path { [data-aw-toggle-menu] path {
@apply transition; @apply transition;
} }
[data-aw-toggle-menu].expanded g > path:first-child { [data-aw-toggle-menu].expanded g > path:first-child {
@apply -rotate-45 translate-y-[15px] translate-x-[-3px]; @apply -rotate-45 translate-y-[15px] translate-x-[-3px];
} }
[data-aw-toggle-menu].expanded g > path:last-child { [data-aw-toggle-menu].expanded g > path:last-child {
@apply rotate-45 translate-y-[-8px] translate-x-[14px]; @apply rotate-45 translate-y-[-8px] translate-x-[14px];
} }
/* To deprecated */ /* To deprecated */
.dd *:first-child { .dd *:first-child {
margin-top: 0; margin-top: 0;
} }

View File

@@ -5,12 +5,18 @@ const { lang = 'en' } = Astro.params;
const t = getTranslation(lang); const t = getTranslation(lang);
--- ---
<div id="cookie-banner" class="fixed bottom-0 left-0 right-0 z-50 p-4 content-backdrop shadow-lg transform transition-transform duration-300 translate-y-full" style="display: none;"> <div
id="cookie-banner"
class="fixed bottom-0 left-0 right-0 z-50 p-4 content-backdrop shadow-lg transform transition-transform duration-300 translate-y-full"
style="display: none;"
>
<div class="container mx-auto max-w-6xl flex flex-col sm:flex-row items-center justify-between gap-4"> <div class="container mx-auto max-w-6xl flex flex-col sm:flex-row items-center justify-between gap-4">
<div class="text-sm text-gray-800 dark:text-gray-200 font-medium"> <div class="text-sm text-gray-800 dark:text-gray-200 font-medium">
<p> <p>
{t.cookies.message} {t.cookies.message}
<a href={`/${lang}/privacy#cookie-usage`} class="text-blue-600 dark:text-blue-400 hover:underline">{t.cookies.learnMore}</a> <a href={`/${lang}/privacy#cookie-usage`} class="text-blue-600 dark:text-blue-400 hover:underline"
>{t.cookies.learnMore}</a
>
</p> </p>
</div> </div>
<div class="flex-shrink-0"> <div class="flex-shrink-0">
@@ -41,7 +47,7 @@ const t = getTranslation(lang);
// Helper function to set cookie with expiration // Helper function to set cookie with expiration
function setCookie(name, value, days) { function setCookie(name, value, days) {
const date = new Date(); const date = new Date();
date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000)); date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000);
const expires = `expires=${date.toUTCString()}`; const expires = `expires=${date.toUTCString()}`;
document.cookie = `${name}=${value}; ${expires}; path=/; SameSite=Lax`; document.cookie = `${name}=${value}; ${expires}; path=/; SameSite=Lax`;
} }
@@ -49,7 +55,7 @@ const t = getTranslation(lang);
function setupCookieBanner() { function setupCookieBanner() {
const cookieBanner = document.getElementById('cookie-banner'); const cookieBanner = document.getElementById('cookie-banner');
const acceptButton = document.getElementById('accept-cookies'); const acceptButton = document.getElementById('accept-cookies');
if (!cookieBanner || !acceptButton) return; if (!cookieBanner || !acceptButton) return;
// Check if user has already accepted cookies // Check if user has already accepted cookies
@@ -83,7 +89,7 @@ const t = getTranslation(lang);
acceptButton.addEventListener('click', () => { acceptButton.addEventListener('click', () => {
// Store consent in cookie (primary storage) // Store consent in cookie (primary storage)
setCookie('cookieConsentAccepted', 'true', 365); setCookie('cookieConsentAccepted', 'true', 365);
// Also store in localStorage as backup // Also store in localStorage as backup
try { try {
localStorage.setItem('cookieConsentAccepted', 'true'); localStorage.setItem('cookieConsentAccepted', 'true');
@@ -91,14 +97,14 @@ const t = getTranslation(lang);
console.error('Error setting localStorage:', e); console.error('Error setting localStorage:', e);
// Continue with cookie storage // Continue with cookie storage
} }
// Hide the banner with animation // Hide the banner with animation
cookieBanner.classList.add('translate-y-full'); cookieBanner.classList.add('translate-y-full');
// Remove from DOM after animation completes // Remove from DOM after animation completes
setTimeout(() => { setTimeout(() => {
cookieBanner.style.display = 'none'; cookieBanner.style.display = 'none';
}, 300); }, 300);
}); });
} }
</script> </script>

View File

@@ -66,5 +66,4 @@ import '@fontsource-variable/inter';
color: snow; color: snow;
} }
} }
</style> </style>

View File

@@ -8,16 +8,16 @@ interface Props {
const { currentLang } = Astro.props; const { currentLang } = Astro.props;
type SupportedLanguage = typeof supportedLanguages[number]; type SupportedLanguage = (typeof supportedLanguages)[number];
const languages = [ const languages = [
{ code: 'en' as SupportedLanguage, name: 'English', flag: 'gb' }, { code: 'en' as SupportedLanguage, name: 'English', flag: 'gb' },
{ code: 'nl' as SupportedLanguage, name: 'Dutch', flag: 'nl' }, { code: 'nl' as SupportedLanguage, name: 'Dutch', flag: 'nl' },
{ code: 'de' as SupportedLanguage, name: 'German', flag: 'de' }, { code: 'de' as SupportedLanguage, name: 'German', flag: 'de' },
{ code: 'fr' as SupportedLanguage, name: 'French', flag: 'fr' }, { code: 'fr' as SupportedLanguage, name: 'French', flag: 'fr' },
].filter(lang => supportedLanguages.includes(lang.code)); ].filter((lang) => supportedLanguages.includes(lang.code));
const currentLanguage = languages.find(lang => lang.code === currentLang) || languages[0]; const currentLanguage = languages.find((lang) => lang.code === currentLang) || languages[0];
--- ---
<div class="relative inline-block text-left language-dropdown"> <div class="relative inline-block text-left language-dropdown">
@@ -32,9 +32,9 @@ const currentLanguage = languages.find(lang => lang.code === currentLang) || lan
> >
<Icon name={`circle-flags:${currentLanguage.flag}`} class="inline-block w-5 h-5 mr-2" /> <Icon name={`circle-flags:${currentLanguage.flag}`} class="inline-block w-5 h-5 mr-2" />
<span id="selected-language">{currentLanguage.name}</span> <span id="selected-language">{currentLanguage.name}</span>
<Icon <Icon
name="tabler:chevron-down" name="tabler:chevron-down"
class="ml-2 -mr-1 h-5 w-5 transition-transform duration-200" class="ml-2 -mr-1 h-5 w-5 transition-transform duration-200"
aria-hidden="true" aria-hidden="true"
id="chevron-icon" id="chevron-icon"
/> />
@@ -51,28 +51,35 @@ const currentLanguage = languages.find(lang => lang.code === currentLang) || lan
style="max-height: min(300px, 70vh);" style="max-height: min(300px, 70vh);"
> >
<div class="py-1" role="none"> <div class="py-1" role="none">
{languages.map(lang => ( {
<button languages.map((lang) => (
type="button" <button
data-lang-code={lang.code} type="button"
class="text-gray-700 dark:text-gray-300 block w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-white transition-colors duration-200" data-lang-code={lang.code}
role="menuitem" class="text-gray-700 dark:text-gray-300 block w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-white transition-colors duration-200"
tabindex="-1" role="menuitem"
aria-label={`Switch to ${lang.name} language`} tabindex="-1"
> aria-label={`Switch to ${lang.name} language`}
<Icon name={`circle-flags:${lang.flag}`} class="inline-block w-5 h-5 mr-2" /> >
{lang.name} <Icon name={`circle-flags:${lang.flag}`} class="inline-block w-5 h-5 mr-2" />
</button> {lang.name}
))} </button>
))
}
</div> </div>
</div> </div>
<select id="language-select" class="block w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-white focus:bg-gray-100 dark:focus:bg-gray-700 focus:text-gray-900 dark:focus:text-white focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:focus:ring-indigo-400 transition-colors duration-200 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 language-select"> <select
{languages.map(lang => ( id="language-select"
<option value={lang.code} selected={lang.code === currentLang}> class="block w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-white focus:bg-gray-100 dark:focus:bg-gray-700 focus:text-gray-900 dark:focus:text-white focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:focus:ring-indigo-400 transition-colors duration-200 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 language-select"
{lang.name} >
</option> {
))} languages.map((lang) => (
<option value={lang.code} selected={lang.code === currentLang}>
{lang.name}
</option>
))
}
</select> </select>
</div> </div>
@@ -89,7 +96,7 @@ const currentLanguage = languages.find(lang => lang.code === currentLang) || lan
.language-dropdown { .language-dropdown {
display: inline-block; display: inline-block;
} }
/* Keep the native select hidden even on mobile */ /* Keep the native select hidden even on mobile */
.language-select { .language-select {
display: none; display: none;
@@ -168,7 +175,7 @@ const currentLanguage = languages.find(lang => lang.code === currentLang) || lan
// First show the menu to calculate its height // First show the menu to calculate its height
menu.classList.remove('hidden'); menu.classList.remove('hidden');
menu.classList.remove('open-upward', 'open-downward'); menu.classList.remove('open-upward', 'open-downward');
// Calculate available space // Calculate available space
const buttonRect = button.getBoundingClientRect(); const buttonRect = button.getBoundingClientRect();
const menuRect = menu.getBoundingClientRect(); const menuRect = menu.getBoundingClientRect();
@@ -176,15 +183,12 @@ const currentLanguage = languages.find(lang => lang.code === currentLang) || lan
const spaceBelow = viewportHeight - buttonRect.bottom; const spaceBelow = viewportHeight - buttonRect.bottom;
const spaceAbove = buttonRect.top; const spaceAbove = buttonRect.top;
const menuHeight = Math.min(menuRect.height, 300); // Cap at 300px const menuHeight = Math.min(menuRect.height, 300); // Cap at 300px
// Determine if menu should open upward // Determine if menu should open upward
const shouldOpenUpward = spaceBelow < menuHeight && spaceAbove > spaceBelow; const shouldOpenUpward = spaceBelow < menuHeight && spaceAbove > spaceBelow;
// Position menu // Position menu
menu.style.maxHeight = `${Math.min( menu.style.maxHeight = `${Math.min(shouldOpenUpward ? spaceAbove - 8 : spaceBelow - 8, 300)}px`;
shouldOpenUpward ? spaceAbove - 8 : spaceBelow - 8,
300
)}px`;
if (shouldOpenUpward) { if (shouldOpenUpward) {
menu.classList.add('open-upward'); menu.classList.add('open-upward');
@@ -214,7 +218,7 @@ const currentLanguage = languages.find(lang => lang.code === currentLang) || lan
}); });
// Handle language selection // Handle language selection
languageButtons.forEach(langButton => { languageButtons.forEach((langButton) => {
langButton.addEventListener('click', () => { langButton.addEventListener('click', () => {
const langCode = langButton.dataset.langCode; const langCode = langButton.dataset.langCode;
if (!langCode) return; if (!langCode) return;
@@ -238,13 +242,13 @@ const currentLanguage = languages.find(lang => lang.code === currentLang) || lan
const currentPath = currentUrl.pathname.replace(/\/$/, ''); const currentPath = currentUrl.pathname.replace(/\/$/, '');
const currentHash = currentUrl.hash; const currentHash = currentUrl.hash;
const pathSegments = currentPath.split('/').filter(Boolean); const pathSegments = currentPath.split('/').filter(Boolean);
// Check if we're on a language-specific path // Check if we're on a language-specific path
const isLangPath = supportedLanguages.includes(pathSegments[0]); const isLangPath = supportedLanguages.includes(pathSegments[0]);
// Get the previous language code // Get the previous language code
const previousLangCode = isLangPath ? pathSegments[0] : 'en'; const previousLangCode = isLangPath ? pathSegments[0] : 'en';
// Extract the page path without language // Extract the page path without language
let pagePath = ''; let pagePath = '';
if (isLangPath && pathSegments.length > 1) { if (isLangPath && pathSegments.length > 1) {
@@ -254,48 +258,48 @@ const currentLanguage = languages.find(lang => lang.code === currentLang) || lan
// If we're not on a language-specific path, use the current path // If we're not on a language-specific path, use the current path
pagePath = `/${pathSegments.join('/')}`; pagePath = `/${pathSegments.join('/')}`;
} }
// Handle special case for root path // Handle special case for root path
const isRootPath = pathSegments.length === 0 || (isLangPath && pathSegments.length === 1); const isRootPath = pathSegments.length === 0 || (isLangPath && pathSegments.length === 1);
// Construct the new URL // Construct the new URL
let newUrl = isRootPath ? `/${langCode}` : `/${langCode}${pagePath}`; let newUrl = isRootPath ? `/${langCode}` : `/${langCode}${pagePath}`;
// Clean up any potential double slashes // Clean up any potential double slashes
newUrl = newUrl.replace(/\/+/g, '/'); newUrl = newUrl.replace(/\/+/g, '/');
// Append hash fragment if it exists // Append hash fragment if it exists
if (currentHash) { if (currentHash) {
newUrl += currentHash; newUrl += currentHash;
} }
// Store the language preference in localStorage and cookies // Store the language preference in localStorage and cookies
if (window.languageUtils) { if (window.languageUtils) {
window.languageUtils.storeLanguagePreference(langCode); window.languageUtils.storeLanguagePreference(langCode);
} else { } else {
// Fallback if languageUtils is not available // Fallback if languageUtils is not available
localStorage.setItem('preferredLanguage', langCode); localStorage.setItem('preferredLanguage', langCode);
// Also set a cookie for server-side detection // Also set a cookie for server-side detection
const expirationDate = new Date(); const expirationDate = new Date();
expirationDate.setFullYear(expirationDate.getFullYear() + 1); expirationDate.setFullYear(expirationDate.getFullYear() + 1);
document.cookie = `preferredLanguage=${langCode}; expires=${expirationDate.toUTCString()}; path=/; SameSite=Lax`; document.cookie = `preferredLanguage=${langCode}; expires=${expirationDate.toUTCString()}; path=/; SameSite=Lax`;
} }
// Dispatch the language changed event // Dispatch the language changed event
const reloadEvent = new CustomEvent('languageChanged', { const reloadEvent = new CustomEvent('languageChanged', {
detail: { detail: {
langCode, langCode,
previousLangCode, previousLangCode,
path: newUrl, path: newUrl,
willReload: true willReload: true,
} },
}); });
document.dispatchEvent(reloadEvent); document.dispatchEvent(reloadEvent);
// Construct the full URL // Construct the full URL
const newFullUrl = `${window.location.origin}${newUrl}`; const newFullUrl = `${window.location.origin}${newUrl}`;
// Force a complete page reload to ensure all content is updated to the new language // Force a complete page reload to ensure all content is updated to the new language
// This bypasses any client-side caching and ensures a fresh server render // This bypasses any client-side caching and ensures a fresh server render
window.location.href = newFullUrl + '?t=' + Date.now(); window.location.href = newFullUrl + '?t=' + Date.now();
@@ -312,13 +316,13 @@ const currentLanguage = languages.find(lang => lang.code === currentLang) || lan
const currentPath = currentUrl.pathname.replace(/\/$/, ''); const currentPath = currentUrl.pathname.replace(/\/$/, '');
const currentHash = currentUrl.hash; const currentHash = currentUrl.hash;
const pathSegments = currentPath.split('/').filter(Boolean); const pathSegments = currentPath.split('/').filter(Boolean);
// Check if we're on a language-specific path // Check if we're on a language-specific path
const isLangPath = supportedLanguages.includes(pathSegments[0]); const isLangPath = supportedLanguages.includes(pathSegments[0]);
// Get the previous language code // Get the previous language code
const previousLangCode = isLangPath ? pathSegments[0] : 'en'; const previousLangCode = isLangPath ? pathSegments[0] : 'en';
// Extract the page path without language // Extract the page path without language
let pagePath = ''; let pagePath = '';
if (isLangPath && pathSegments.length > 1) { if (isLangPath && pathSegments.length > 1) {
@@ -328,48 +332,48 @@ const currentLanguage = languages.find(lang => lang.code === currentLang) || lan
// If we're not on a language-specific path, use the current path // If we're not on a language-specific path, use the current path
pagePath = `/${pathSegments.join('/')}`; pagePath = `/${pathSegments.join('/')}`;
} }
// Handle special case for root path // Handle special case for root path
const isRootPath = pathSegments.length === 0 || (isLangPath && pathSegments.length === 1); const isRootPath = pathSegments.length === 0 || (isLangPath && pathSegments.length === 1);
// Construct the new URL // Construct the new URL
let newUrl = isRootPath ? `/${langCode}` : `/${langCode}${pagePath}`; let newUrl = isRootPath ? `/${langCode}` : `/${langCode}${pagePath}`;
// Clean up any potential double slashes // Clean up any potential double slashes
newUrl = newUrl.replace(/\/+/g, '/'); newUrl = newUrl.replace(/\/+/g, '/');
// Append hash fragment if it exists // Append hash fragment if it exists
if (currentHash) { if (currentHash) {
newUrl += currentHash; newUrl += currentHash;
} }
// Store the language preference in localStorage and cookies // Store the language preference in localStorage and cookies
if (window.languageUtils) { if (window.languageUtils) {
window.languageUtils.storeLanguagePreference(langCode); window.languageUtils.storeLanguagePreference(langCode);
} else { } else {
// Fallback if languageUtils is not available // Fallback if languageUtils is not available
localStorage.setItem('preferredLanguage', langCode); localStorage.setItem('preferredLanguage', langCode);
// Also set a cookie for server-side detection // Also set a cookie for server-side detection
const expirationDate = new Date(); const expirationDate = new Date();
expirationDate.setFullYear(expirationDate.getFullYear() + 1); expirationDate.setFullYear(expirationDate.getFullYear() + 1);
document.cookie = `preferredLanguage=${langCode}; expires=${expirationDate.toUTCString()}; path=/; SameSite=Lax`; document.cookie = `preferredLanguage=${langCode}; expires=${expirationDate.toUTCString()}; path=/; SameSite=Lax`;
} }
// Dispatch the language changed event // Dispatch the language changed event
const reloadEvent = new CustomEvent('languageChanged', { const reloadEvent = new CustomEvent('languageChanged', {
detail: { detail: {
langCode, langCode,
previousLangCode, previousLangCode,
path: newUrl, path: newUrl,
willReload: true willReload: true,
} },
}); });
document.dispatchEvent(reloadEvent); document.dispatchEvent(reloadEvent);
// Construct the full URL // Construct the full URL
const newFullUrl = `${window.location.origin}${newUrl}`; const newFullUrl = `${window.location.origin}${newUrl}`;
// Force a complete page reload to ensure all content is updated to the new language // Force a complete page reload to ensure all content is updated to the new language
// This bypasses any client-side caching and ensures a fresh server render // This bypasses any client-side caching and ensures a fresh server render
window.location.href = newFullUrl + '?t=' + Date.now(); window.location.href = newFullUrl + '?t=' + Date.now();
@@ -385,10 +389,10 @@ const currentLanguage = languages.find(lang => lang.code === currentLang) || lan
// Re-run setup when the page content is updated (e.g., after navigation) // Re-run setup when the page content is updated (e.g., after navigation)
document.addEventListener('astro:page-load', setupLanguageDropdown); document.addEventListener('astro:page-load', setupLanguageDropdown);
// Listen for popstate events (browser back/forward buttons) // Listen for popstate events (browser back/forward buttons)
window.addEventListener('popstate', (_event) => { window.addEventListener('popstate', (_event) => {
// No need to manually update anything here as the browser will // No need to manually update anything here as the browser will
// automatically load the correct URL, and Astro will handle the rendering // automatically load the correct URL, and Astro will handle the rendering
}); });
</script> </script>

View File

@@ -96,7 +96,7 @@ declare global {
storeLanguagePreference: (langCode) => { storeLanguagePreference: (langCode) => {
localStorage.setItem('preferredLanguage', langCode); localStorage.setItem('preferredLanguage', langCode);
setLanguageCookie(langCode); setLanguageCookie(langCode);
} },
}; };
} }
@@ -109,4 +109,4 @@ declare global {
// Re-run setup when the page content is updated (e.g., after navigation) // Re-run setup when the page content is updated (e.g., after navigation)
document.addEventListener('astro:page-load', setupLanguagePersistence); document.addEventListener('astro:page-load', setupLanguagePersistence);
</script> </script>

View File

@@ -8,4 +8,4 @@ interface Props {
const { defaultLang } = Astro.props; const { defaultLang } = Astro.props;
--- ---
<LanguageSelectorComponent client:load defaultLang={defaultLang} /> <LanguageSelectorComponent client:load defaultLang={defaultLang} />

View File

@@ -41,21 +41,14 @@ export default function LanguageSelectorComponent({ defaultLang }: LanguageSelec
className={` className={`
inline-flex items-center px-3 py-2 text-sm font-medium rounded-md inline-flex items-center px-3 py-2 text-sm font-medium rounded-md
transition-colors duration-200 hover:bg-gray-100 transition-colors duration-200 hover:bg-gray-100
${language.code === currentLang ${language.code === currentLang ? 'text-blue-600 bg-blue-50' : 'text-gray-600 hover:text-gray-900'}
? 'text-blue-600 bg-blue-50'
: 'text-gray-600 hover:text-gray-900'
}
`} `}
aria-current={language.code === currentLang ? 'page' : undefined} aria-current={language.code === currentLang ? 'page' : undefined}
> >
<Icon <Icon name={`circle-flags:${language.flag}`} className="w-5 h-5 mr-2" aria-hidden="true" />
name={`circle-flags:${language.flag}`}
className="w-5 h-5 mr-2"
aria-hidden="true"
/>
<span>{language.name}</span> <span>{language.name}</span>
</button> </button>
))} ))}
</div> </div>
); );
} }

View File

@@ -4,6 +4,7 @@ import { SITE } from 'astrowind:config';
<span <span
class="self-center ml-2 rtl:ml-0 rtl:mr-2 text-2xl md:text-xl font-bold text-gray-900 whitespace-nowrap dark:text-white" class="self-center ml-2 rtl:ml-0 rtl:mr-2 text-2xl md:text-xl font-bold text-gray-900 whitespace-nowrap dark:text-white"
aria-label={SITE?.name}
> >
{SITE?.name} {SITE?.name}
</span> </span>

View File

@@ -163,7 +163,7 @@ import { UI } from 'astrowind:config';
// Handle smooth scrolling for anchor links across all pages // Handle smooth scrolling for anchor links across all pages
function setupSmoothScrolling() { function setupSmoothScrolling() {
// Handle links that start with # (pure anchor links) // Handle links that start with # (pure anchor links)
document.querySelectorAll('a[href^="#"]:not([href="#"])').forEach(anchor => { document.querySelectorAll('a[href^="#"]:not([href="#"])').forEach((anchor) => {
anchor.addEventListener('click', function (e) { anchor.addEventListener('click', function (e) {
e.preventDefault(); e.preventDefault();
@@ -173,23 +173,23 @@ import { UI } from 'astrowind:config';
if (targetElement) { if (targetElement) {
window.scrollTo({ window.scrollTo({
top: targetElement.offsetTop - 50, // Offset for header top: targetElement.offsetTop - 50, // Offset for header
behavior: 'smooth' behavior: 'smooth',
}); });
} }
}); });
}); });
// Handle links that contain # but don't start with it (page + anchor) // Handle links that contain # but don't start with it (page + anchor)
document.querySelectorAll('a[href*="#"]:not([href^="#"])').forEach(anchor => { document.querySelectorAll('a[href*="#"]:not([href^="#"])').forEach((anchor) => {
anchor.addEventListener('click', function (e) { anchor.addEventListener('click', function (e) {
const href = this.getAttribute('href'); const href = this.getAttribute('href');
const isHashLink = this.getAttribute('data-hash-link') === 'true'; const isHashLink = this.getAttribute('data-hash-link') === 'true';
// Check if this is a link to the current page // Check if this is a link to the current page
// First, extract the path part (before the hash) // First, extract the path part (before the hash)
const hrefPath = href.split('#')[0]; const hrefPath = href.split('#')[0];
const currentPath = window.location.pathname; const currentPath = window.location.pathname;
// Consider it's the current page if: // Consider it's the current page if:
// 1. The path matches exactly // 1. The path matches exactly
// 2. The href is just a hash (like '/#services') // 2. The href is just a hash (like '/#services')
@@ -198,23 +198,23 @@ import { UI } from 'astrowind:config';
currentPath === hrefPath || currentPath === hrefPath ||
hrefPath === '' || hrefPath === '' ||
(hrefPath === '/' && (currentPath === '/' || currentPath.endsWith('/index.html'))); (hrefPath === '/' && (currentPath === '/' || currentPath.endsWith('/index.html')));
// For hash links, we want to update the URL and scroll to the element // For hash links, we want to update the URL and scroll to the element
if (isHashLink || isCurrentPage) { if (isHashLink || isCurrentPage) {
e.preventDefault(); e.preventDefault();
const hashIndex = href.indexOf('#'); const hashIndex = href.indexOf('#');
if (hashIndex !== -1) { if (hashIndex !== -1) {
const targetId = href.substring(hashIndex + 1); const targetId = href.substring(hashIndex + 1);
const targetElement = document.getElementById(targetId); const targetElement = document.getElementById(targetId);
if (targetElement) { if (targetElement) {
// Update the URL with the hash fragment // Update the URL with the hash fragment
history.pushState(null, null, href); history.pushState(null, null, href);
window.scrollTo({ window.scrollTo({
top: targetElement.offsetTop - 50, // Offset for header top: targetElement.offsetTop - 50, // Offset for header
behavior: 'smooth' behavior: 'smooth',
}); });
} else { } else {
// If the target element doesn't exist on the current page, navigate to the page // If the target element doesn't exist on the current page, navigate to the page
@@ -245,50 +245,48 @@ import { UI } from 'astrowind:config';
function updateUrlsWithLanguage() { function updateUrlsWithLanguage() {
const currentLang = getStoredLanguage(); const currentLang = getStoredLanguage();
const supportedLanguages = ['en', 'nl', 'de', 'fr']; const supportedLanguages = ['en', 'nl', 'de', 'fr'];
// Update all internal links to include the language prefix // Update all internal links to include the language prefix
document.querySelectorAll('a[href^="/"]:not([href^="//"])').forEach(link => { document.querySelectorAll('a[href^="/"]:not([href^="//"])').forEach((link) => {
const href = link.getAttribute('href'); const href = link.getAttribute('href');
if (!href) return; if (!href) return;
// Skip hash-only links (e.g., "#services") // Skip hash-only links (e.g., "#services")
if (href.startsWith('#')) { if (href.startsWith('#')) {
return; return;
} }
// Extract hash fragment if present // Extract hash fragment if present
let hashFragment = ''; let hashFragment = '';
let pathWithoutHash = href; let pathWithoutHash = href;
if (href.includes('#')) { if (href.includes('#')) {
const parts = href.split('#'); const parts = href.split('#');
pathWithoutHash = parts[0]; pathWithoutHash = parts[0];
hashFragment = '#' + parts[1]; hashFragment = '#' + parts[1];
} }
// Parse the URL path (without hash) to check for existing language code // Parse the URL path (without hash) to check for existing language code
const pathSegments = pathWithoutHash.split('/').filter(Boolean); const pathSegments = pathWithoutHash.split('/').filter(Boolean);
const hasLanguagePrefix = pathSegments.length > 0 && supportedLanguages.includes(pathSegments[0]); const hasLanguagePrefix = pathSegments.length > 0 && supportedLanguages.includes(pathSegments[0]);
// If it already has a language prefix but it's different from the current language, // If it already has a language prefix but it's different from the current language,
// update it to the current language // update it to the current language
if (hasLanguagePrefix && pathSegments[0] !== currentLang) { if (hasLanguagePrefix && pathSegments[0] !== currentLang) {
// Replace the existing language prefix with the current one // Replace the existing language prefix with the current one
pathSegments[0] = currentLang; pathSegments[0] = currentLang;
const newPath = '/' + pathSegments.join('/'); const newPath = '/' + pathSegments.join('/');
// Set the new href with the hash fragment (if any) // Set the new href with the hash fragment (if any)
link.setAttribute('href', newPath + hashFragment); link.setAttribute('href', newPath + hashFragment);
return; return;
} }
// If it doesn't have a language prefix, add the current language // If it doesn't have a language prefix, add the current language
if (!hasLanguagePrefix) { if (!hasLanguagePrefix) {
// Create the new path with the language prefix // Create the new path with the language prefix
const newPath = pathWithoutHash === '/' ? const newPath = pathWithoutHash === '/' ? `/${currentLang}` : `/${currentLang}${pathWithoutHash}`;
`/${currentLang}` :
`/${currentLang}${pathWithoutHash}`;
// Set the new href with the hash fragment (if any) // Set the new href with the hash fragment (if any)
link.setAttribute('href', newPath + hashFragment); link.setAttribute('href', newPath + hashFragment);
} }
@@ -300,11 +298,11 @@ import { UI } from 'astrowind:config';
// Store the selected language in localStorage // Store the selected language in localStorage
if (event.detail && event.detail.langCode) { if (event.detail && event.detail.langCode) {
storeLanguagePreference(event.detail.langCode); storeLanguagePreference(event.detail.langCode);
// Always update all internal links with the new language // Always update all internal links with the new language
// regardless of whether we're doing a full page reload // regardless of whether we're doing a full page reload
updateUrlsWithLanguage(); updateUrlsWithLanguage();
// If this event includes a path but doesn't indicate it will reload, // If this event includes a path but doesn't indicate it will reload,
// force a full page reload to ensure all content is properly translated // force a full page reload to ensure all content is properly translated
if (event.detail.path && !event.detail.willReload) { if (event.detail.path && !event.detail.willReload) {
@@ -312,13 +310,13 @@ import { UI } from 'astrowind:config';
} }
} }
}); });
// Process links when the page loads // Process links when the page loads
updateUrlsWithLanguage(); updateUrlsWithLanguage();
// Process links after client-side navigation // Process links after client-side navigation
document.addEventListener('astro:page-load', updateUrlsWithLanguage); document.addEventListener('astro:page-load', updateUrlsWithLanguage);
// Also update links when the DOM content is loaded // Also update links when the DOM content is loaded
document.addEventListener('DOMContentLoaded', updateUrlsWithLanguage); document.addEventListener('DOMContentLoaded', updateUrlsWithLanguage);
@@ -327,25 +325,25 @@ import { UI } from 'astrowind:config';
if (window.location.hash) { if (window.location.hash) {
const targetId = window.location.hash.substring(1); const targetId = window.location.hash.substring(1);
const targetElement = document.getElementById(targetId); const targetElement = document.getElementById(targetId);
if (targetElement) { if (targetElement) {
// Use setTimeout to ensure the page has fully loaded // Use setTimeout to ensure the page has fully loaded
setTimeout(() => { setTimeout(() => {
window.scrollTo({ window.scrollTo({
top: targetElement.offsetTop - 50, // Offset for header top: targetElement.offsetTop - 50, // Offset for header
behavior: 'smooth' behavior: 'smooth',
}); });
}, 100); }, 100);
} }
} }
} }
scrollToHashOnLoad(); scrollToHashOnLoad();
// Make language functions available globally // Make language functions available globally
window.languageUtils = { window.languageUtils = {
getStoredLanguage, getStoredLanguage,
storeLanguagePreference storeLanguagePreference,
}; };
} }
@@ -471,36 +469,36 @@ import { UI } from 'astrowind:config';
if (!contactForm) return; if (!contactForm) return;
// Form validation and submission // Form validation and submission
contactForm.addEventListener('submit', async function(e) { contactForm.addEventListener('submit', async function (e) {
e.preventDefault(); e.preventDefault();
// Reset previous error messages // Reset previous error messages
resetFormErrors(); resetFormErrors();
// Client-side validation // Client-side validation
if (!validateForm(contactForm)) { if (!validateForm(contactForm)) {
return; return;
} }
// Show loading state // Show loading state
const submitButton = contactForm.querySelector('button[type="submit"]'); const submitButton = contactForm.querySelector('button[type="submit"]');
const originalButtonText = submitButton.innerHTML; const originalButtonText = submitButton.innerHTML;
submitButton.disabled = true; submitButton.disabled = true;
submitButton.innerHTML = 'Sending...'; submitButton.innerHTML = 'Sending...';
try { try {
const formData = new FormData(contactForm); const formData = new FormData(contactForm);
// Add timestamp to help prevent duplicate submissions // Add timestamp to help prevent duplicate submissions
formData.append('timestamp', Date.now().toString()); formData.append('timestamp', Date.now().toString());
// Submit the form to Netlify // Submit the form to Netlify
const response = await fetch('/', { const response = await fetch('/', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(formData).toString() body: new URLSearchParams(formData).toString(),
}); });
if (response.ok) { if (response.ok) {
// Show success message // Show success message
document.getElementById('form-success').classList.remove('hidden'); document.getElementById('form-success').classList.remove('hidden');
@@ -516,14 +514,14 @@ import { UI } from 'astrowind:config';
submitButton.innerHTML = originalButtonText; submitButton.innerHTML = originalButtonText;
} }
}); });
// Add input validation on blur // Add input validation on blur
contactForm.querySelectorAll('input, textarea').forEach(input => { contactForm.querySelectorAll('input, textarea').forEach((input) => {
input.addEventListener('blur', function() { input.addEventListener('blur', function () {
validateInput(this); validateInput(this);
}); });
input.addEventListener('input', function() { input.addEventListener('input', function () {
// Remove error styling when user starts typing // Remove error styling when user starts typing
this.classList.remove('border-red-500'); this.classList.remove('border-red-500');
const feedbackElement = this.closest('div').querySelector('.invalid-feedback'); const feedbackElement = this.closest('div').querySelector('.invalid-feedback');
@@ -536,29 +534,29 @@ import { UI } from 'astrowind:config';
function validateForm(form) { function validateForm(form) {
let isValid = true; let isValid = true;
// Validate all inputs // Validate all inputs
form.querySelectorAll('input, textarea').forEach(input => { form.querySelectorAll('input, textarea').forEach((input) => {
if (!validateInput(input)) { if (!validateInput(input)) {
isValid = false; isValid = false;
} }
}); });
return isValid; return isValid;
} }
function validateInput(input) { function validateInput(input) {
if (!input.required) return true; if (!input.required) return true;
let isValid = true; let isValid = true;
const feedbackElement = input.closest('div').querySelector('.invalid-feedback'); const feedbackElement = input.closest('div').querySelector('.invalid-feedback');
// Reset previous error // Reset previous error
if (feedbackElement) { if (feedbackElement) {
feedbackElement.classList.add('hidden'); feedbackElement.classList.add('hidden');
} }
input.classList.remove('border-red-500'); input.classList.remove('border-red-500');
// Checkbox validation (special case for disclaimer) // Checkbox validation (special case for disclaimer)
if (input.type === 'checkbox') { if (input.type === 'checkbox') {
if (input.required && !input.checked) { if (input.required && !input.checked) {
@@ -582,7 +580,7 @@ import { UI } from 'astrowind:config';
} }
return isValid; return isValid;
} }
// Check if empty // Check if empty
if (input.required && !input.value.trim()) { if (input.required && !input.value.trim()) {
isValid = false; isValid = false;
@@ -592,7 +590,7 @@ import { UI } from 'astrowind:config';
} }
input.classList.add('border-red-500'); input.classList.add('border-red-500');
} }
// Email validation // Email validation
if (input.type === 'email' && input.value.trim()) { if (input.type === 'email' && input.value.trim()) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
@@ -605,7 +603,7 @@ import { UI } from 'astrowind:config';
input.classList.add('border-red-500'); input.classList.add('border-red-500');
} }
} }
// Textarea minimum length // Textarea minimum length
if (input.tagName === 'TEXTAREA' && input.value.trim().length < 10) { if (input.tagName === 'TEXTAREA' && input.value.trim().length < 10) {
isValid = false; isValid = false;
@@ -615,26 +613,26 @@ import { UI } from 'astrowind:config';
} }
input.classList.add('border-red-500'); input.classList.add('border-red-500');
} }
return isValid; return isValid;
} }
function resetFormErrors() { function resetFormErrors() {
// Hide all error messages // Hide all error messages
document.querySelectorAll('.invalid-feedback').forEach(el => { document.querySelectorAll('.invalid-feedback').forEach((el) => {
el.classList.add('hidden'); el.classList.add('hidden');
}); });
// Remove error styling // Remove error styling
document.querySelectorAll('input, textarea').forEach(input => { document.querySelectorAll('input, textarea').forEach((input) => {
input.classList.remove('border-red-500'); input.classList.remove('border-red-500');
}); });
// Remove checkbox container error styling // Remove checkbox container error styling
document.querySelectorAll('.flex.items-start').forEach(container => { document.querySelectorAll('.flex.items-start').forEach((container) => {
container.classList.remove('checkbox-error'); container.classList.remove('checkbox-error');
}); });
// Hide form-level messages // Hide form-level messages
document.getElementById('form-success')?.classList.add('hidden'); document.getElementById('form-success')?.classList.add('hidden');
document.getElementById('form-error')?.classList.add('hidden'); document.getElementById('form-error')?.classList.add('hidden');

View File

@@ -2,4 +2,4 @@
const { data } = Astro.props; const { data } = Astro.props;
--- ---
<script type="application/ld+json" set:html={JSON.stringify(data)} /> <script type="application/ld+json" set:html={JSON.stringify(data)} />

View File

@@ -10,25 +10,33 @@ const { items } = Astro.props;
--- ---
<div class="accordion"> <div class="accordion">
{items.map((item, index) => ( {
<div class="accordion-item"> items.map((item, index) => (
<button class="accordion-header" data-accordion-target={`#accordion-body-${index}`}> <div class="accordion-item">
{item.title} <button class="accordion-header" data-accordion-target={`#accordion-body-${index}`}>
<svg class="w-4 h-4 ml-2 shrink-0" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd"></path></svg> {item.title}
</button> <svg class="w-4 h-4 ml-2 shrink-0" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<div id={`accordion-body-${index}`} class="accordion-body" aria-labelledby={`accordion-header-${index}`}> <path
<div class="p-5 border border-b-0 dark:border-gray-700 dark:bg-gray-900"> fill-rule="evenodd"
<p class="text-gray-500 dark:text-gray-400">{item.description}</p> d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
clip-rule="evenodd"
/>
</svg>
</button>
<div id={`accordion-body-${index}`} class="accordion-body" aria-labelledby={`accordion-header-${index}`}>
<div class="p-5 border border-b-0 dark:border-gray-700 dark:bg-gray-900">
<p class="text-gray-500 dark:text-gray-400">{item.description}</p>
</div>
</div> </div>
</div> </div>
</div> ))
))} }
</div> </div>
<script> <script>
const accordionHeaders = document.querySelectorAll('.accordion-header'); const accordionHeaders = document.querySelectorAll('.accordion-header');
accordionHeaders.forEach(header => { accordionHeaders.forEach((header) => {
header.addEventListener('click', () => { header.addEventListener('click', () => {
const target = header.getAttribute('data-accordion-target'); const target = header.getAttribute('data-accordion-target');
const body = document.querySelector(target); const body = document.querySelector(target);
@@ -37,7 +45,7 @@ const { items } = Astro.props;
const expanded = (body as HTMLElement).classList.contains('expanded'); const expanded = (body as HTMLElement).classList.contains('expanded');
// Close all accordion items // Close all accordion items
document.querySelectorAll('.accordion-body.expanded').forEach(item => { document.querySelectorAll('.accordion-body.expanded').forEach((item) => {
if (item && 'style' in item) { if (item && 'style' in item) {
(item as any).style.display = 'none'; (item as any).style.display = 'none';
item.classList.remove('expanded'); item.classList.remove('expanded');
@@ -57,7 +65,7 @@ const { items } = Astro.props;
</script> </script>
<style> <style>
.accordion-header { .accordion-header {
display: flex; display: flex;
align-items: center; align-items: center;
width: 100%; width: 100%;
@@ -69,17 +77,17 @@ const { items } = Astro.props;
border-bottom: 1px solid #e5e7eb; border-bottom: 1px solid #e5e7eb;
cursor: pointer; cursor: pointer;
transition: background-color 0.15s ease; transition: background-color 0.15s ease;
} }
.accordion-header:hover { .accordion-header:hover {
background-color: #f9fafb; background-color: #f9fafb;
} }
.accordion-body { .accordion-body {
display: none; display: none;
} }
.accordion-body.expanded { .accordion-body.expanded {
display: block; display: block;
} }
</style> </style>

View File

@@ -1,12 +1,8 @@
--- ---
---
<button ---
id="back-to-top"
class="back-to-top" <button id="back-to-top" class="back-to-top" aria-label="Back to top" title="Return to Top">
aria-label="Back to top"
title="Return to Top"
>
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="24" width="24"
@@ -81,7 +77,7 @@
// Function to initialize the back to top button // Function to initialize the back to top button
function initBackToTop() { function initBackToTop() {
const backToTopButton = document.getElementById('back-to-top'); const backToTopButton = document.getElementById('back-to-top');
if (!backToTopButton) return; if (!backToTopButton) return;
// Show button when scrolling down // Show button when scrolling down
@@ -98,7 +94,7 @@
e.preventDefault(); e.preventDefault();
window.scrollTo({ window.scrollTo({
top: 0, top: 0,
behavior: 'smooth' behavior: 'smooth',
}); });
}; };
@@ -116,4 +112,4 @@
// Re-initialize on view transitions (for Astro's View Transitions) // Re-initialize on view transitions (for Astro's View Transitions)
document.addEventListener('astro:page-load', initBackToTop); document.addEventListener('astro:page-load', initBackToTop);
document.addEventListener('astro:after-swap', initBackToTop); document.addEventListener('astro:after-swap', initBackToTop);
</script> </script>

View File

@@ -70,7 +70,7 @@ const iconNames: string[] = [
'tabler:brand-github', 'tabler:brand-github',
'tabler:device-desktop', 'tabler:device-desktop',
'tabler:brand-azure', 'tabler:brand-azure',
// Additional tech-related icons // Additional tech-related icons
'tabler:cpu', 'tabler:cpu',
'tabler:device-mobile', 'tabler:device-mobile',
@@ -79,7 +79,7 @@ const iconNames: string[] = [
'tabler:shield', 'tabler:shield',
'tabler:lock', 'tabler:lock',
'tabler:key', 'tabler:key',
// Tangentially related icons for visual diversity // Tangentially related icons for visual diversity
'tabler:bulb', 'tabler:bulb',
'tabler:compass', 'tabler:compass',
@@ -101,17 +101,13 @@ const getRandomRotation = (): string => {
// Function to get a random size // Function to get a random size
const getRandomSize = (isDarkMode: boolean = false): string => { const getRandomSize = (isDarkMode: boolean = false): string => {
// Slightly larger size range for dark mode for better visibility // Slightly larger size range for dark mode for better visibility
return isDarkMode return isDarkMode ? `${getRandomInRange(160, 200)}px` : `${getRandomInRange(140, 180)}px`;
? `${getRandomInRange(160, 200)}px`
: `${getRandomInRange(140, 180)}px`;
}; };
// Function to get a random opacity // Function to get a random opacity
const getRandomOpacity = (isDarkMode: boolean = false): string => { const getRandomOpacity = (isDarkMode: boolean = false): string => {
// Higher opacity range for dark mode for better visibility // Higher opacity range for dark mode for better visibility
return isDarkMode return isDarkMode ? getRandomInRange(0.45, 0.55).toFixed(2) : getRandomInRange(0.32, 0.38).toFixed(2);
? getRandomInRange(0.45, 0.55).toFixed(2)
: getRandomInRange(0.32, 0.38).toFixed(2);
}; };
// Create a spacious layout with well-separated icons // Create a spacious layout with well-separated icons
@@ -119,14 +115,14 @@ const createSpacedIcons = (): BaseIconObject[] => {
const icons: BaseIconObject[] = []; const icons: BaseIconObject[] = [];
const rows = 6; // Reduced from 8 to 6 for fewer potential positions const rows = 6; // Reduced from 8 to 6 for fewer potential positions
const cols = 6; // Reduced from 8 to 6 for fewer potential positions const cols = 6; // Reduced from 8 to 6 for fewer potential positions
// Define larger margins to keep icons away from edges (in percentage) // Define larger margins to keep icons away from edges (in percentage)
const marginX = 10; // 10% margin from left and right edges (increased from 5%) const marginX = 10; // 10% margin from left and right edges (increased from 5%)
const marginY = 10; // 10% margin from top and bottom edges (increased from 5%) const marginY = 10; // 10% margin from top and bottom edges (increased from 5%)
// Minimum distance between icons (in percentage points) // Minimum distance between icons (in percentage points)
const minDistance = 20; // Ensure at least 20% distance between any two icons const minDistance = 20; // Ensure at least 20% distance between any two icons
// Create a base grid of positions // Create a base grid of positions
for (let row = 0; row < rows; row++) { for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) { for (let col = 0; col < cols; col++) {
@@ -134,46 +130,43 @@ const createSpacedIcons = (): BaseIconObject[] => {
if (Math.random() < 0.75) { if (Math.random() < 0.75) {
continue; continue;
} }
// Base position in the grid with margins applied // Base position in the grid with margins applied
const baseX = marginX + ((col / cols) * (100 - 2 * marginX)); const baseX = marginX + (col / cols) * (100 - 2 * marginX);
const baseY = marginY + ((row / rows) * (100 - 2 * marginY)); const baseY = marginY + (row / rows) * (100 - 2 * marginY);
// Add limited randomness to the position (±5%) to maintain spacing // Add limited randomness to the position (±5%) to maintain spacing
const randomOffsetX = getRandomInRange(-5, 5); const randomOffsetX = getRandomInRange(-5, 5);
const randomOffsetY = getRandomInRange(-5, 5); const randomOffsetY = getRandomInRange(-5, 5);
// Ensure positions stay within margins // Ensure positions stay within margins
const x = Math.max(marginX, Math.min(100 - marginX, baseX + randomOffsetX)); const x = Math.max(marginX, Math.min(100 - marginX, baseX + randomOffsetX));
const y = Math.max(marginY, Math.min(100 - marginY, baseY + randomOffsetY)); const y = Math.max(marginY, Math.min(100 - marginY, baseY + randomOffsetY));
// Check if this position is too close to any existing icon // Check if this position is too close to any existing icon
let tooClose = false; let tooClose = false;
for (const existingIcon of icons) { for (const existingIcon of icons) {
// Extract numeric values from percentage strings // Extract numeric values from percentage strings
const existingX = parseFloat(existingIcon.x); const existingX = parseFloat(existingIcon.x);
const existingY = parseFloat(existingIcon.y); const existingY = parseFloat(existingIcon.y);
// Calculate distance between points // Calculate distance between points
const distance = Math.sqrt( const distance = Math.sqrt(Math.pow(x - existingX, 2) + Math.pow(y - existingY, 2));
Math.pow(x - existingX, 2) +
Math.pow(y - existingY, 2)
);
// If too close to an existing icon, skip this position // If too close to an existing icon, skip this position
if (distance < minDistance) { if (distance < minDistance) {
tooClose = true; tooClose = true;
break; break;
} }
} }
if (tooClose) { if (tooClose) {
continue; continue;
} }
// Randomly select an icon from the expanded set // Randomly select an icon from the expanded set
const iconIndex = Math.floor(Math.random() * iconNames.length); const iconIndex = Math.floor(Math.random() * iconNames.length);
icons.push({ icons.push({
icon: iconNames[iconIndex], icon: iconNames[iconIndex],
x: `${x}%`, x: `${x}%`,
@@ -184,14 +177,14 @@ const createSpacedIcons = (): BaseIconObject[] => {
}); });
} }
} }
return icons; return icons;
}; };
const icons: BaseIconObject[] = createSpacedIcons(); const icons: BaseIconObject[] = createSpacedIcons();
// Assign random colors to each icon // Assign random colors to each icon
const iconsWithColors: IconWithColors[] = icons.map(icon => ({ const iconsWithColors: IconWithColors[] = icons.map((icon) => ({
icon: icon.icon, icon: icon.icon,
x: icon.x, x: icon.x,
y: icon.y, y: icon.y,
@@ -202,85 +195,89 @@ const iconsWithColors: IconWithColors[] = icons.map(icon => ({
darkColor: getRandomColor(darkModeColors), darkColor: getRandomColor(darkModeColors),
})); }));
--- ---
<div class:list={['absolute inset-0', { 'backdrop-blur-sm bg-white/5 dark:bg-gray-900/10': isDark }]}> <div class:list={['absolute inset-0', { 'backdrop-blur-sm bg-white/5 dark:bg-gray-900/10': isDark }]}>
<slot /> <slot />
{showIcons && ( {
/* Decorative background icons with random placement */ showIcons && (
<div id="background-icons" class="absolute inset-0 overflow-hidden pointer-events-none z-[-5]"> /* Decorative background icons with random placement */
{iconsWithColors.map(({ icon, x, y, size, opacity, rotate, lightColor, darkColor }) => ( <div id="background-icons" class="absolute inset-0 overflow-hidden pointer-events-none z-[-5]">
<div {iconsWithColors.map(({ icon, x, y, size, opacity, rotate, lightColor, darkColor }) => (
class={`absolute ${lightColor} ${darkColor} background-icon`} <div
style={`left: ${x}; top: ${y}; opacity: ${opacity}; transform: rotate(${rotate});`} class={`absolute ${lightColor} ${darkColor} background-icon`}
> style={`left: ${x}; top: ${y}; opacity: ${opacity}; transform: rotate(${rotate});`}
<Icon name={icon} style={`width: ${size}; height: ${size};`} /> >
</div> <Icon name={icon} style={`width: ${size}; height: ${size};`} />
))} </div>
</div> ))}
)} </div>
)
}
</div> </div>
{showIcons && ( <script define:vars={{ disableParallax }}>
<script define:vars={{ disableParallax }}> // Parallax scrolling effect for background icons
// Parallax scrolling effect for background icons document.addEventListener('DOMContentLoaded', () => {
document.addEventListener('DOMContentLoaded', () => { // Get all parallax icons
// Get all parallax icons const parallaxIcons = document.querySelectorAll('.parallax-icon');
const parallaxIcons = document.querySelectorAll('.parallax-icon');
// Skip parallax on mobile devices for better performance or if parallax is disabled
// Skip parallax on mobile devices for better performance or if parallax is disabled const isMobile = window.matchMedia('(max-width: 768px)').matches;
const isMobile = window.matchMedia('(max-width: 768px)').matches; if (isMobile || disableParallax) return;
if (isMobile || disableParallax) return;
// Variables to track scroll position
// Variables to track scroll position let lastScrollY = window.scrollY;
let lastScrollY = window.scrollY; let ticking = false;
let ticking = false;
// Function to update icon positions based on scroll
// Function to update icon positions based on scroll const updateParallax = () => {
const updateParallax = () => { parallaxIcons.forEach((icon) => {
parallaxIcons.forEach((icon) => { const depth = parseFloat(icon.getAttribute('data-depth') || '0.5');
const depth = parseFloat(icon.getAttribute('data-depth') || '0.5');
// Calculate parallax offset based on scroll position and depth
// Calculate parallax offset based on scroll position and depth // Lower depth value means the icon moves slower (appears further away)
// Lower depth value means the icon moves slower (appears further away) const yOffset = lastScrollY * depth * 0.15;
const yOffset = (lastScrollY * depth * 0.15);
// Get the original rotation
// Get the original rotation const transformValue = icon.style.transform;
const transformValue = icon.style.transform; const rotateMatch = transformValue.match(/rotate\([^)]+\)/);
const rotateMatch = transformValue.match(/rotate\([^)]+\)/); const rotateValue = rotateMatch ? rotateMatch[0] : 'rotate(0deg)';
const rotateValue = rotateMatch ? rotateMatch[0] : 'rotate(0deg)';
// Apply transform with the original rotation plus the parallax offset
// Apply transform with the original rotation plus the parallax offset icon.style.transform = `${rotateValue} translate3d(0, ${yOffset}px, 0)`;
icon.style.transform = `${rotateValue} translate3d(0, ${yOffset}px, 0)`; });
ticking = false;
};
// Throttle scroll events for better performance
const onScroll = () => {
lastScrollY = window.scrollY;
if (!ticking) {
window.requestAnimationFrame(() => {
updateParallax();
ticking = false;
}); });
ticking = false; ticking = true;
}; }
};
// Throttle scroll events for better performance
const onScroll = () => { // Add scroll event listener
lastScrollY = window.scrollY; window.addEventListener('scroll', onScroll, { passive: true });
if (!ticking) { // Update on resize (debounced)
window.requestAnimationFrame(() => { let resizeTimer;
updateParallax(); window.addEventListener(
ticking = false; 'resize',
}); () => {
ticking = true;
}
};
// Add scroll event listener
window.addEventListener('scroll', onScroll, { passive: true });
// Update on resize (debounced)
let resizeTimer;
window.addEventListener('resize', () => {
clearTimeout(resizeTimer); clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => { resizeTimer = setTimeout(() => {
// Check if device is now mobile and disable parallax if needed // Check if device is now mobile and disable parallax if needed
const isMobileNow = window.matchMedia('(max-width: 768px)').matches; const isMobileNow = window.matchMedia('(max-width: 768px)').matches;
if (isMobileNow) { if (isMobileNow) {
// Reset positions on mobile // Reset positions on mobile
parallaxIcons.forEach((icon) => { parallaxIcons.forEach((icon) => {
@@ -294,10 +291,11 @@ const iconsWithColors: IconWithColors[] = icons.map(icon => ({
updateParallax(); updateParallax();
} }
}, 200); }, 200);
}, { passive: true }); },
{ passive: true }
// Initial update );
updateParallax();
}); // Initial update
</script> updateParallax();
)} });
</script>

View File

@@ -22,7 +22,7 @@ const {
{ {
items && items.length && ( items && items.length && (
<div class={twMerge("grid grid-cols-1 md:grid-cols-2 gap-4", containerClass)}> <div class={twMerge('grid grid-cols-1 md:grid-cols-2 gap-4', containerClass)}>
{items.map(({ title, description, icon, classes: itemClasses = {} }) => ( {items.map(({ title, description, icon, classes: itemClasses = {} }) => (
<div <div
class={twMerge( class={twMerge(
@@ -44,7 +44,11 @@ const {
{description && ( {description && (
<div class="text-muted mt-2 overflow-hidden"> <div class="text-muted mt-2 overflow-hidden">
<div <div
class={twMerge('text-sm max-h-[6rem] hover:max-h-[300px] transition-all duration-500 ease-description', descriptionClass, itemClasses?.description)} class={twMerge(
'text-sm max-h-[6rem] hover:max-h-[300px] transition-all duration-500 ease-description',
descriptionClass,
itemClasses?.description
)}
set:html={description} set:html={description}
/> />
</div> </div>
@@ -61,9 +65,9 @@ const {
.ease-description { .ease-description {
transition-timing-function: cubic-bezier(0.25, 0.46, 0.45, 0.94); transition-timing-function: cubic-bezier(0.25, 0.46, 0.45, 0.94);
} }
/* Fade effect for the gradient overlay */ /* Fade effect for the gradient overlay */
.hover\:opacity-0:hover { .hover\:opacity-0:hover {
opacity: 0; opacity: 0;
} }
</style> </style>

View File

@@ -15,20 +15,27 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '' }
} }
</style> </style>
<form id="contact-form" name="contact" method="POST" class="needs-validation" data-netlify="true" data-netlify-honeypot="bot-field" novalidate> <form
id="contact-form"
name="contact"
method="POST"
class="needs-validation"
data-netlify="true"
data-netlify-honeypot="bot-field"
novalidate
>
<!-- Form status messages --> <!-- Form status messages -->
<div id="form-success" class="hidden mb-6 p-4 bg-green-100 border border-green-200 text-green-700 rounded-lg"> <div id="form-success" class="hidden mb-6 p-4 bg-green-100 border border-green-200 text-green-700 rounded-lg">
Your message has been sent successfully. We will get back to you soon! Your message has been sent successfully. We will get back to you soon!
</div> </div>
<div id="form-error" class="hidden mb-6 p-4 bg-red-100 border border-red-200 text-red-700 rounded-lg"> <div id="form-error" class="hidden mb-6 p-4 bg-red-100 border border-red-200 text-red-700 rounded-lg">
There was an error sending your message. Please check all fields and try again. There was an error sending your message. Please check all fields and try again.
</div> </div>
<!-- Netlify form name --> <!-- Netlify form name -->
<input type="hidden" name="form-name" value="contact" /> <input type="hidden" name="form-name" value="contact" />
<!-- Honeypot field to prevent spam --> <!-- Honeypot field to prevent spam -->
<p class="hidden"> <p class="hidden">
<label>Don't fill this out if you're human: <input name="bot-field" /></label> <label>Don't fill this out if you're human: <input name="bot-field" /></label>
@@ -41,7 +48,8 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '' }
<div class="mb-6"> <div class="mb-6">
{label && ( {label && (
<label for={name} class="block text-sm font-medium"> <label for={name} class="block text-sm font-medium">
{label}{required && <span class="text-red-600">*</span>} {label}
{required && <span class="text-red-600">*</span>}
</label> </label>
)} )}
<input <input
@@ -53,7 +61,7 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '' }
class="py-3 px-4 block w-full text-md rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-slate-900" class="py-3 px-4 block w-full text-md rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-slate-900"
required={required} required={required}
/> />
<div class="invalid-feedback hidden text-red-600 text-sm mt-1"></div> <div class="invalid-feedback hidden text-red-600 text-sm mt-1" />
</div> </div>
) )
) )
@@ -63,7 +71,8 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '' }
textarea && ( textarea && (
<div class="mb-6"> <div class="mb-6">
<label for="textarea" class="block text-sm font-medium"> <label for="textarea" class="block text-sm font-medium">
{textarea.label}<span class="text-red-600">*</span> {textarea.label}
<span class="text-red-600">*</span>
</label> </label>
<textarea <textarea
id="textarea" id="textarea"
@@ -73,7 +82,7 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '' }
class="py-3 px-4 block w-full text-md rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-slate-900" class="py-3 px-4 block w-full text-md rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-slate-900"
required required
/> />
<div class="invalid-feedback hidden text-red-600 text-sm mt-1"></div> <div class="invalid-feedback hidden text-red-600 text-sm mt-1" />
</div> </div>
) )
} }
@@ -92,9 +101,10 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '' }
</div> </div>
<div class="ml-3"> <div class="ml-3">
<label for="disclaimer" class="cursor-pointer select-none text-sm text-gray-600 dark:text-gray-400"> <label for="disclaimer" class="cursor-pointer select-none text-sm text-gray-600 dark:text-gray-400">
{disclaimer.label}<span class="text-red-600">*</span> {disclaimer.label}
<span class="text-red-600">*</span>
</label> </label>
<div class="invalid-feedback hidden text-red-600 text-sm mt-1"></div> <div class="invalid-feedback hidden text-red-600 text-sm mt-1" />
</div> </div>
</div> </div>
) )
@@ -118,3 +128,57 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '' }
) )
} }
</form> </form>
<script>
const form = document.getElementById('contact-form') as HTMLFormElement;
if (form) {
form.addEventListener('submit', async (event) => {
event.preventDefault();
const formData = new FormData(form);
const data = Object.fromEntries(formData.entries());
try {
const response = await fetch('/.netlify/functions/contact', {
method: 'POST',
body: JSON.stringify(data),
});
if (response.ok) {
const result = await response.json();
console.log(result.message); // Log success message
const successElement = document.getElementById('form-success');
if (successElement) {
successElement.classList.remove('hidden');
}
const errorElement = document.getElementById('form-error');
if (errorElement) {
errorElement.classList.add('hidden');
}
form.reset(); // Clear the form
} else {
console.error('Error:', response.status);
const errorElement = document.getElementById('form-error');
if (errorElement) {
errorElement.classList.remove('hidden');
}
const successElement = document.getElementById('form-success');
if (successElement) {
successElement.classList.add('hidden');
}
}
} catch (error) {
console.error('Error:', error);
const errorElement = document.getElementById('form-error');
if (errorElement) {
errorElement.classList.remove('hidden');
}
const successElement = document.getElementById('form-success');
if (successElement) {
successElement.classList.add('hidden');
}
}
});
}
</script>

View File

@@ -70,7 +70,7 @@ const iconNames: string[] = [
'tabler:brand-github', 'tabler:brand-github',
'tabler:device-desktop', 'tabler:device-desktop',
'tabler:brand-azure', 'tabler:brand-azure',
// Additional tech-related icons // Additional tech-related icons
'tabler:cpu', 'tabler:cpu',
'tabler:device-mobile', 'tabler:device-mobile',
@@ -79,7 +79,7 @@ const iconNames: string[] = [
'tabler:shield', 'tabler:shield',
'tabler:lock', 'tabler:lock',
'tabler:key', 'tabler:key',
// Tangentially related icons for visual diversity // Tangentially related icons for visual diversity
'tabler:bulb', 'tabler:bulb',
'tabler:compass', 'tabler:compass',
@@ -101,17 +101,13 @@ const getRandomRotation = (): string => {
// Function to get a random size // Function to get a random size
const getRandomSize = (isDarkMode: boolean = false): string => { const getRandomSize = (isDarkMode: boolean = false): string => {
// Slightly larger size range for dark mode for better visibility // Slightly larger size range for dark mode for better visibility
return isDarkMode return isDarkMode ? `${getRandomInRange(160, 200)}px` : `${getRandomInRange(140, 180)}px`;
? `${getRandomInRange(160, 200)}px`
: `${getRandomInRange(140, 180)}px`;
}; };
// Function to get a random opacity // Function to get a random opacity
const getRandomOpacity = (isDarkMode: boolean = false): string => { const getRandomOpacity = (isDarkMode: boolean = false): string => {
// Higher opacity range for dark mode for better visibility // Higher opacity range for dark mode for better visibility
return isDarkMode return isDarkMode ? getRandomInRange(0.45, 0.55).toFixed(2) : getRandomInRange(0.32, 0.38).toFixed(2);
? getRandomInRange(0.45, 0.55).toFixed(2)
: getRandomInRange(0.32, 0.38).toFixed(2);
}; };
// Create a spacious layout with well-separated icons // Create a spacious layout with well-separated icons
@@ -119,14 +115,14 @@ const createSpacedIcons = (): BaseIconObject[] => {
const icons: BaseIconObject[] = []; const icons: BaseIconObject[] = [];
const rows = 10; // Increased from 6 to 10 for more coverage across the entire page const rows = 10; // Increased from 6 to 10 for more coverage across the entire page
const cols = 6; const cols = 6;
// Define larger margins to keep icons away from edges (in percentage) // Define larger margins to keep icons away from edges (in percentage)
const marginX = 10; const marginX = 10;
const marginY = 5; // Reduced from 10 to 5 to allow icons to span more of the page height const marginY = 5; // Reduced from 10 to 5 to allow icons to span more of the page height
// Minimum distance between icons (in percentage points) // Minimum distance between icons (in percentage points)
const minDistance = 15; // Reduced from 20 to 15 to allow more icons const minDistance = 15; // Reduced from 20 to 15 to allow more icons
// Create a base grid of positions // Create a base grid of positions
for (let row = 0; row < rows; row++) { for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) { for (let col = 0; col < cols; col++) {
@@ -134,62 +130,59 @@ const createSpacedIcons = (): BaseIconObject[] => {
// For small screens: 88% chance to skip (showing ~1/3 of the icons) // For small screens: 88% chance to skip (showing ~1/3 of the icons)
// For larger screens: 65% chance to skip (original density) // For larger screens: 65% chance to skip (original density)
const skipProbability = 0.65; const skipProbability = 0.65;
// We'll add a data attribute to each icon to control visibility based on screen size // We'll add a data attribute to each icon to control visibility based on screen size
let visibilityClass = ''; let visibilityClass = '';
// Generate a random number to determine if we skip this position // Generate a random number to determine if we skip this position
const randomValue = Math.random(); const randomValue = Math.random();
// Skip for all screen sizes if below the base threshold // Skip for all screen sizes if below the base threshold
if (randomValue < skipProbability) { if (randomValue < skipProbability) {
continue; continue;
} }
// For values between 0.65 and 0.88, create the icon but mark it to be hidden on small screens // For values between 0.65 and 0.88, create the icon but mark it to be hidden on small screens
if (randomValue < 0.88) { if (randomValue < 0.88) {
visibilityClass = 'hidden sm:block'; // Hidden on small screens, visible on sm and up visibilityClass = 'hidden sm:block'; // Hidden on small screens, visible on sm and up
} }
// Base position in the grid with margins applied // Base position in the grid with margins applied
const baseX = marginX + ((col / cols) * (100 - 2 * marginX)); const baseX = marginX + (col / cols) * (100 - 2 * marginX);
const baseY = marginY + ((row / rows) * (100 - 2 * marginY)); const baseY = marginY + (row / rows) * (100 - 2 * marginY);
// Add limited randomness to the position (±5%) to maintain spacing // Add limited randomness to the position (±5%) to maintain spacing
const randomOffsetX = getRandomInRange(-5, 5); const randomOffsetX = getRandomInRange(-5, 5);
const randomOffsetY = getRandomInRange(-5, 5); const randomOffsetY = getRandomInRange(-5, 5);
// Ensure positions stay within margins // Ensure positions stay within margins
const x = Math.max(marginX, Math.min(100 - marginX, baseX + randomOffsetX)); const x = Math.max(marginX, Math.min(100 - marginX, baseX + randomOffsetX));
const y = Math.max(marginY, Math.min(100 - marginY, baseY + randomOffsetY)); const y = Math.max(marginY, Math.min(100 - marginY, baseY + randomOffsetY));
// Check if this position is too close to any existing icon // Check if this position is too close to any existing icon
let tooClose = false; let tooClose = false;
for (const existingIcon of icons) { for (const existingIcon of icons) {
// Extract numeric values from percentage strings // Extract numeric values from percentage strings
const existingX = parseFloat(existingIcon.x); const existingX = parseFloat(existingIcon.x);
const existingY = parseFloat(existingIcon.y); const existingY = parseFloat(existingIcon.y);
// Calculate distance between points // Calculate distance between points
const distance = Math.sqrt( const distance = Math.sqrt(Math.pow(x - existingX, 2) + Math.pow(y - existingY, 2));
Math.pow(x - existingX, 2) +
Math.pow(y - existingY, 2)
);
// If too close to an existing icon, skip this position // If too close to an existing icon, skip this position
if (distance < minDistance) { if (distance < minDistance) {
tooClose = true; tooClose = true;
break; break;
} }
} }
if (tooClose) { if (tooClose) {
continue; continue;
} }
// Randomly select an icon from the expanded set // Randomly select an icon from the expanded set
const iconIndex = Math.floor(Math.random() * iconNames.length); const iconIndex = Math.floor(Math.random() * iconNames.length);
icons.push({ icons.push({
icon: iconNames[iconIndex], icon: iconNames[iconIndex],
x: `${x}%`, x: `${x}%`,
@@ -201,15 +194,14 @@ const createSpacedIcons = (): BaseIconObject[] => {
}); });
} }
} }
return icons; return icons;
}; };
const icons: BaseIconObject[] = createSpacedIcons(); const icons: BaseIconObject[] = createSpacedIcons();
// Assign random colors to each icon // Assign random colors to each icon
const iconsWithColors: IconWithColors[] = icons.map(icon => ({ const iconsWithColors: IconWithColors[] = icons.map((icon) => ({
icon: icon.icon, icon: icon.icon,
x: icon.x, x: icon.x,
y: icon.y, y: icon.y,
@@ -224,18 +216,20 @@ const iconsWithColors: IconWithColors[] = icons.map(icon => ({
<div class="fixed inset-0 overflow-hidden pointer-events-none z-[-5]" aria-hidden="true"> <div class="fixed inset-0 overflow-hidden pointer-events-none z-[-5]" aria-hidden="true">
<div class:list={['absolute inset-0', { 'backdrop-blur-sm bg-white/5 dark:bg-gray-900/10': isDark }]}></div> <div class:list={['absolute inset-0', { 'backdrop-blur-sm bg-white/5 dark:bg-gray-900/10': isDark }]}></div>
{/* Decorative background icons with random placement */} {/* Decorative background icons with random placement */}
<div id="background-icons" class="absolute inset-0 overflow-hidden"> <div id="background-icons" class="absolute inset-0 overflow-hidden">
{iconsWithColors.map(({ icon, x, y, size, opacity, rotate, lightColor, darkColor, visibilityClass }) => ( {
<div iconsWithColors.map(({ icon, x, y, size, opacity, rotate, lightColor, darkColor, visibilityClass }) => (
class={`absolute ${lightColor} ${darkColor} ${visibilityClass}`} <div
style={`left: ${x}; top: ${y}; opacity: ${opacity}; transform: rotate(${rotate});`} class={`absolute ${lightColor} ${darkColor} ${visibilityClass}`}
> style={`left: ${x}; top: ${y}; opacity: ${opacity}; transform: rotate(${rotate});`}
<Icon name={icon} style={`width: ${size}; height: ${size};`} /> >
</div> <Icon name={icon} style={`width: ${size}; height: ${size};`} />
))} </div>
))
}
</div> </div>
</div> </div>
<!-- Parallax effect removed while maintaining random icon placement --> <!-- Parallax effect removed while maintaining random icon placement -->

View File

@@ -20,16 +20,24 @@ const {
(title || subtitle || tagline) && ( (title || subtitle || tagline) && (
<div class={twMerge('mb-8 md:mx-auto md:mb-12 text-center', containerClass)}> <div class={twMerge('mb-8 md:mx-auto md:mb-12 text-center', containerClass)}>
{tagline && ( {tagline && (
<p class="text-base text-secondary dark:text-blue-200 font-bold tracking-wide uppercase content-backdrop p-2 inline-block rounded-md" set:html={tagline} /> <p
class="text-base text-secondary dark:text-blue-200 font-bold tracking-wide uppercase content-backdrop p-2 inline-block rounded-md"
set:html={tagline}
/>
)} )}
{title && ( {title && (
<h2 <h2
class={twMerge('font-bold leading-tighter tracking-tighter font-heading text-heading text-3xl content-backdrop p-2 block rounded-md', titleClass)} class={twMerge(
'font-bold leading-tighter tracking-tighter font-heading text-heading text-3xl content-backdrop p-2 block rounded-md',
titleClass
)}
set:html={title} set:html={title}
/> />
)} )}
{subtitle && <p class={twMerge('mt-2 text-muted content-backdrop p-2 rounded-md', subtitleClass)} set:html={subtitle} />} {subtitle && (
<p class={twMerge('mt-2 text-muted content-backdrop p-2 rounded-md', subtitleClass)} set:html={subtitle} />
)}
</div> </div>
) )
} }

View File

@@ -2,25 +2,44 @@
// ImageModal.astro - A reusable modal component for displaying enlarged images // ImageModal.astro - A reusable modal component for displaying enlarged images
--- ---
<div id="image-modal" class="fixed inset-0 z-50 flex items-center justify-center opacity-0 pointer-events-none transition-opacity duration-300 ease-in-out"> <div
id="image-modal"
class="fixed inset-0 z-50 flex items-center justify-center opacity-0 pointer-events-none transition-opacity duration-300 ease-in-out"
>
<!-- Backdrop overlay --> <!-- Backdrop overlay -->
<div id="modal-backdrop" class="absolute inset-0 bg-black bg-opacity-75 backdrop-blur-sm transition-opacity duration-300"></div> <div
id="modal-backdrop"
class="absolute inset-0 bg-black bg-opacity-75 backdrop-blur-sm transition-opacity duration-300"
>
</div>
<!-- Modal container with animation --> <!-- Modal container with animation -->
<div id="modal-container" class="relative max-w-4xl mx-auto p-4 transform scale-95 transition-all duration-300 ease-in-out flex flex-col items-center"> <div
id="modal-container"
class="relative max-w-4xl mx-auto p-4 transform scale-95 transition-all duration-300 ease-in-out flex flex-col items-center"
>
<!-- Close button --> <!-- Close button -->
<button <button
id="modal-close" id="modal-close"
class="absolute top-2 right-2 z-10 bg-white dark:bg-gray-800 rounded-full p-2 shadow-md hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors duration-200" class="absolute top-2 right-2 z-10 bg-white dark:bg-gray-800 rounded-full p-2 shadow-md hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors duration-200"
aria-label="Close modal" aria-label="Close modal"
> >
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-gray-600 dark:text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /> xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 text-gray-600 dark:text-gray-300"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg> </svg>
</button> </button>
<!-- Image container --> <!-- Image container -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl overflow-hidden" style="min-height: 300px; max-height: 75vh;"> <div
class="bg-white dark:bg-gray-800 rounded-lg shadow-xl overflow-hidden"
style="min-height: 300px; max-height: 75vh;"
>
<div class="flex items-center justify-center h-full w-full"> <div class="flex items-center justify-center h-full w-full">
<img <img
id="modal-image" id="modal-image"
@@ -31,7 +50,7 @@
/> />
</div> </div>
</div> </div>
<!-- Caption --> <!-- Caption -->
<div id="modal-caption" class="mt-2 text-center text-white text-lg font-medium"></div> <div id="modal-caption" class="mt-2 text-center text-white text-lg font-medium"></div>
</div> </div>
@@ -53,77 +72,77 @@
const modalImage = document.getElementById('modal-image') as HTMLImageElement; const modalImage = document.getElementById('modal-image') as HTMLImageElement;
const modalCaption = document.getElementById('modal-caption'); const modalCaption = document.getElementById('modal-caption');
const closeButton = document.getElementById('modal-close'); const closeButton = document.getElementById('modal-close');
// Function to open the modal with a specific image // Function to open the modal with a specific image
function openModal(imgSrc, imgAlt) { function openModal(imgSrc, imgAlt) {
if (!modal || !modalImage || !modalCaption) return; if (!modal || !modalImage || !modalCaption) return;
// Create a temporary image to get the natural dimensions // Create a temporary image to get the natural dimensions
const tempImg = new Image(); const tempImg = new Image();
tempImg.onload = function() { tempImg.onload = function () {
// Calculate the certification section height (75% of viewport height as defined in the container) // Calculate the certification section height (75% of viewport height as defined in the container)
const certSectionHeight = window.innerHeight * 0.75; const certSectionHeight = window.innerHeight * 0.75;
const maxHeight = certSectionHeight * 0.75; // 75% of the certification section height const maxHeight = certSectionHeight * 0.75; // 75% of the certification section height
// If the natural image height is smaller than the max height, use the natural height // If the natural image height is smaller than the max height, use the natural height
if (tempImg.height < maxHeight) { if (tempImg.height < maxHeight) {
modalImage.style.setProperty('--cert-max-height', `${tempImg.height}px`); modalImage.style.setProperty('--cert-max-height', `${tempImg.height}px`);
} else { } else {
modalImage.style.setProperty('--cert-max-height', `${maxHeight}px`); modalImage.style.setProperty('--cert-max-height', `${maxHeight}px`);
} }
// Set the image source and alt text // Set the image source and alt text
modalImage.src = imgSrc; modalImage.src = imgSrc;
modalCaption.textContent = imgAlt; modalCaption.textContent = imgAlt;
// Make the modal visible // Make the modal visible
modal.classList.remove('opacity-0', 'pointer-events-none'); modal.classList.remove('opacity-0', 'pointer-events-none');
modal.classList.add('opacity-100', 'pointer-events-auto'); modal.classList.add('opacity-100', 'pointer-events-auto');
// Animate the container // Animate the container
if (modalContainer) { if (modalContainer) {
modalContainer.classList.remove('scale-95'); modalContainer.classList.remove('scale-95');
modalContainer.classList.add('scale-100'); modalContainer.classList.add('scale-100');
} }
// Set focus to the close button for accessibility // Set focus to the close button for accessibility
if (closeButton) { if (closeButton) {
setTimeout(() => closeButton.focus(), 100); setTimeout(() => closeButton.focus(), 100);
} }
}; };
// Start loading the image // Start loading the image
tempImg.src = imgSrc; tempImg.src = imgSrc;
// Prevent scrolling on the body // Prevent scrolling on the body
document.body.style.overflow = 'hidden'; document.body.style.overflow = 'hidden';
} }
// Function to close the modal // Function to close the modal
function closeModal() { function closeModal() {
if (!modal || !modalContainer) return; if (!modal || !modalContainer) return;
// Hide the modal with animation // Hide the modal with animation
modal.classList.remove('opacity-100', 'pointer-events-auto'); modal.classList.remove('opacity-100', 'pointer-events-auto');
modal.classList.add('opacity-0', 'pointer-events-none'); modal.classList.add('opacity-0', 'pointer-events-none');
// Animate the container // Animate the container
modalContainer.classList.remove('scale-100'); modalContainer.classList.remove('scale-100');
modalContainer.classList.add('scale-95'); modalContainer.classList.add('scale-95');
// Re-enable scrolling // Re-enable scrolling
document.body.style.overflow = ''; document.body.style.overflow = '';
// Clear the image source after animation completes // Clear the image source after animation completes
setTimeout(() => { setTimeout(() => {
if (modalImage) modalImage.src = ''; if (modalImage) modalImage.src = '';
if (modalCaption) modalCaption.textContent = ''; if (modalCaption) modalCaption.textContent = '';
}, 300); }, 300);
} }
// Register the openImageModal function globally so it can be called from anywhere // Register the openImageModal function globally so it can be called from anywhere
window.openImageModal = openModal; window.openImageModal = openModal;
// Close modal when clicking the close button // Close modal when clicking the close button
if (closeButton) { if (closeButton) {
closeButton.addEventListener('click', (e) => { closeButton.addEventListener('click', (e) => {
@@ -131,7 +150,7 @@
closeModal(); closeModal();
}); });
} }
// Close modal when clicking outside the image container // Close modal when clicking outside the image container
if (modal) { if (modal) {
modal.addEventListener('click', (e) => { modal.addEventListener('click', (e) => {
@@ -141,14 +160,14 @@
} }
}); });
} }
// Close modal when pressing Escape key // Close modal when pressing Escape key
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
closeModal(); closeModal();
} }
}); });
// Handle window resize to recalculate image dimensions // Handle window resize to recalculate image dimensions
let resizeTimeout; let resizeTimeout;
window.addEventListener('resize', () => { window.addEventListener('resize', () => {
@@ -158,7 +177,7 @@
// Get current image source and recalculate dimensions // Get current image source and recalculate dimensions
const currentSrc = modalImage.src; const currentSrc = modalImage.src;
const currentAlt = modalCaption?.textContent || ''; const currentAlt = modalCaption?.textContent || '';
// Close and reopen modal to trigger recalculation // Close and reopen modal to trigger recalculation
closeModal(); closeModal();
setTimeout(() => openModal(currentSrc, currentAlt), 350); setTimeout(() => openModal(currentSrc, currentAlt), 350);
@@ -166,4 +185,4 @@
}, 200); // Debounce resize events }, 200); // Debounce resize events
}); });
}); });
</script> </script>

View File

@@ -19,40 +19,56 @@ const {
{ {
items && ( items && (
<div <div
class={twMerge( class={twMerge(
`grid mx-auto gap-8 md:gap-y-12 ${ `grid mx-auto gap-8 md:gap-y-12 ${
columns === 4 columns === 4
? 'lg:grid-cols-4 md:grid-cols-3 sm:grid-cols-2' ? 'lg:grid-cols-4 md:grid-cols-3 sm:grid-cols-2'
: columns === 3 : columns === 3
? 'lg:grid-cols-3 sm:grid-cols-2' ? 'lg:grid-cols-3 sm:grid-cols-2'
: columns === 2 : columns === 2
? 'sm:grid-cols-2 ' ? 'sm:grid-cols-2 '
: '' : ''
} grid-flow-row auto-rows-fr text-center sm:text-left`, } grid-flow-row auto-rows-fr text-center sm:text-left`,
containerClass containerClass
)} )}
> >
{items.map(({ title, description, icon, callToAction, classes: itemClasses = {} }) => ( {items.map(({ title, description, icon, callToAction, classes: itemClasses = {} }) => (
<div class="intersect-once motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade h-full"> <div class="intersect-once motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade h-full">
<div class={twMerge('flex flex-col sm:flex-row max-w-md mx-auto sm:mx-0 h-full min-h-[220px] backdrop-blur-sm bg-white/15 dark:bg-transparent p-3 rounded-md border border-gray-200 dark:border-gray-700 hover:bg-white/30 hover:backdrop-blur-md dark:hover:backdrop-blur-md transition-all duration-300 ease-in-out', panelClass, itemClasses?.panel)}> <div
<div class="flex justify-center sm:justify-start flex-shrink-0 mb-2 sm:mb-0"> class={twMerge(
{(icon || defaultIcon) && ( 'flex flex-col sm:flex-row max-w-md mx-auto sm:mx-0 h-full min-h-[220px] backdrop-blur-sm bg-white/15 dark:bg-transparent p-3 rounded-md border border-gray-200 dark:border-gray-700 hover:bg-white/30 hover:backdrop-blur-md dark:hover:backdrop-blur-md transition-all duration-300 ease-in-out',
<Icon panelClass,
name={icon || defaultIcon} itemClasses?.panel
class={twMerge('w-7 h-7 sm:mr-2 rtl:sm:mr-0 rtl:sm:ml-2', defaultIconClass, itemClasses?.icon)} )}
/> >
)} <div class="flex justify-center sm:justify-start flex-shrink-0 mb-2 sm:mb-0">
</div> {(icon || defaultIcon) && (
<div class="mt-0.5 flex flex-col overflow-hidden flex-grow items-center sm:items-start"> <Icon
{title && <h3 class={twMerge('text-xl font-bold', titleClass, itemClasses?.title)}>{title}</h3>} name={icon || defaultIcon}
{description && ( class={twMerge('w-7 h-7 sm:mr-2 rtl:sm:mr-0 rtl:sm:ml-2', defaultIconClass, itemClasses?.icon)}
<p />
class={twMerge(`${title ? 'mt-3' : ''} text-muted text-base overflow-y-auto`, descriptionClass, itemClasses?.description)} )}
set:html={description} </div>
/> <div class="mt-0.5 flex flex-col overflow-hidden flex-grow items-center sm:items-start">
)} {title && <h3 class={twMerge('text-xl font-bold', titleClass, itemClasses?.title)}>{title}</h3>}
{description && (
<p
class={twMerge(
`${title ? 'mt-3' : ''} text-muted text-base overflow-y-auto`,
descriptionClass,
itemClasses?.description
)}
set:html={description}
/>
)}
{callToAction && ( {callToAction && (
<div class={twMerge(`${title || description ? 'mt-3' : ''} mt-auto`, actionClass, itemClasses?.actionClass)}> <div
class={twMerge(
`${title || description ? 'mt-3' : ''} mt-auto`,
actionClass,
itemClasses?.actionClass
)}
>
<Button variant="link" {...callToAction} /> <Button variant="link" {...callToAction} />
</div> </div>
)} )}

View File

@@ -45,7 +45,10 @@ const {
)} )}
<div class={twMerge('text-xl font-bold', titleClass, itemClasses?.title)}>{title}</div> <div class={twMerge('text-xl font-bold', titleClass, itemClasses?.title)}>{title}</div>
{description && ( {description && (
<p class={twMerge('text-muted text-base mt-2', descriptionClass, itemClasses?.description)} set:html={description} /> <p
class={twMerge('text-muted text-base mt-2', descriptionClass, itemClasses?.description)}
set:html={description}
/>
)} )}
{callToAction && ( {callToAction && (
<div class="mt-2"> <div class="mt-2">

View File

@@ -26,18 +26,21 @@ const {
{ {
items && items.length && ( items && items.length && (
<div class={twMerge("relative mx-auto max-w-5xl", containerClass)}> <div class={twMerge('relative mx-auto max-w-5xl', containerClass)}>
{/* Main timeline line */} {/* Main timeline line */}
<div class="absolute left-4 md:left-1/2 top-0 h-full w-1 transform -translate-x-1/2 z-0 transition-all duration-300 ease-in-out shadow-sm bg-blue-700/15 dark:bg-blue-700/30" class:list={[timelineClass]}></div> <div
class="absolute left-4 md:left-1/2 top-0 h-full w-1 transform -translate-x-1/2 z-0 transition-all duration-300 ease-in-out shadow-sm bg-blue-700/15 dark:bg-gray-800"
class:list={[timelineClass]}
/>
<div class="relative"> <div class="relative">
{items.map((item, index) => { {items.map((item, index) => {
const { title, description, icon, classes: itemClasses = {} } = item; const { title, description, icon, classes: itemClasses = {} } = item;
const isEven = index % 2 === 0; const isEven = index % 2 === 0;
// Use the year property if available, otherwise try to extract from date // Use the year property if available, otherwise try to extract from date
let year = item.year; let year = item.year;
// If year is not provided, try to extract from date in the description // If year is not provided, try to extract from date in the description
if (!year && description) { if (!year && description) {
// Look for a date pattern like MM-YYYY // Look for a date pattern like MM-YYYY
@@ -46,24 +49,32 @@ const {
year = dateMatch[1]; year = dateMatch[1];
} }
} }
return ( return (
<div class={`relative ${compact ? 'mb-6' : 'mb-12'}`}> <div class={`relative ${compact ? 'mb-6' : 'mb-12'}`}>
{/* Year marker (if available) */} {/* Year marker (if available) */}
{year && ( {year && (
<div class={twMerge("absolute left-4 md:left-1/2 transform -translate-x-1/2 -top-4 font-bold text-sm z-10", yearClass)}> <div
class={twMerge(
'absolute left-4 md:left-1/2 transform -translate-x-1/2 -top-4 font-bold text-sm z-10',
yearClass
)}
>
<span class="relative"> <span class="relative">
<span class="absolute inset-0 bg-white/20 backdrop-blur-sm rounded-md -z-10"></span> <span class="absolute inset-0 bg-white/20 backdrop-blur-sm rounded-md -z-10" />
{year} {year}
</span> </span>
</div> </div>
)} )}
{/* Timeline dot */} {/* Timeline dot */}
<div class={`absolute left-4 md:left-1/2 transform -translate-x-1/2 top-5 ${compact ? 'w-3 h-3' : 'w-4 h-4'} rounded-full z-10 shadow-md transition-all duration-300 ease-in-out`} class:list={[timelineDotClass]}></div> <div
class={`absolute left-4 md:left-1/2 transform -translate-x-1/2 top-5 ${compact ? 'w-3 h-3' : 'w-4 h-4'} rounded-full z-10 shadow-md transition-all duration-300 ease-in-out`}
class:list={[timelineDotClass]}
/>
{/* Content card */} {/* Content card */}
<div <div
class={twMerge( class={twMerge(
'relative ml-10 md:ml-0 md:w-[45%]', 'relative ml-10 md:ml-0 md:w-[45%]',
isEven ? 'md:mr-auto md:pr-6' : 'md:ml-auto md:pl-6', isEven ? 'md:mr-auto md:pr-6' : 'md:ml-auto md:pl-6',
@@ -81,29 +92,51 @@ const {
{(icon || defaultIcon) && ( {(icon || defaultIcon) && (
<Icon <Icon
name={icon || defaultIcon} name={icon || defaultIcon}
class={twMerge(`${compact ? 'w-6 h-6 p-1' : 'w-7 h-7 p-1.5'} rounded-full border-2 mr-2 flex-shrink-0`, defaultIconClass, itemClasses?.icon)} class={twMerge(
`${compact ? 'w-6 h-6 p-1' : 'w-7 h-7 p-1.5'} rounded-full border-2 mr-2 flex-shrink-0`,
defaultIconClass,
itemClasses?.icon
)}
/>
)}
{title && (
<p
class={twMerge(
`${compact ? 'text-base' : 'text-lg'} font-bold`,
titleClass,
itemClasses?.title
)}
set:html={title}
/> />
)} )}
{title && <p class={twMerge(`${compact ? 'text-base' : 'text-lg'} font-bold`, titleClass, itemClasses?.title)} set:html={title} />}
</div> </div>
{description && ( {description && (
<div class="max-h-0 group-hover:max-h-[500px] overflow-hidden transition-all duration-500 ease-in-out" data-details> <div
class="max-h-0 group-hover:max-h-[500px] overflow-hidden transition-all duration-500 ease-in-out"
data-details
>
<div class="flex items-center justify-center mb-1 opacity-70 group-hover:opacity-0 transition-opacity duration-200 h-4"> <div class="flex items-center justify-center mb-1 opacity-70 group-hover:opacity-0 transition-opacity duration-200 h-4">
<div class="w-6 h-1 bg-gray-300 dark:bg-gray-700 rounded-full"></div> <div class="w-6 h-1 bg-gray-300 dark:bg-gray-700 rounded-full" />
</div> </div>
<div <div
class={twMerge(`text-muted ${compact ? 'text-sm' : 'text-sm'} opacity-0 group-hover:opacity-100 transition-all duration-500 ease-in-out`, descriptionClass, itemClasses?.description)} class={twMerge(
`text-muted ${compact ? 'text-sm' : 'text-sm'} opacity-0 group-hover:opacity-100 transition-all duration-500 ease-in-out`,
descriptionClass,
itemClasses?.description
)}
set:html={description} set:html={description}
/> />
</div> </div>
)} )}
</div> </div>
{/* Connector line to timeline (visible only on desktop) */} {/* Connector line to timeline (visible only on desktop) */}
<div class={twMerge( <div
'absolute top-5 hidden md:block h-0.5 w-10 z-0', class={twMerge(
isEven ? 'right-0 bg-gradient-to-r' : 'left-0 bg-gradient-to-l', 'absolute top-5 hidden md:block h-0.5 w-10 z-0',
'from-transparent to-blue-700 dark:to-blue-700' isEven ? 'right-0 bg-gradient-to-r' : 'left-0 bg-gradient-to-l',
)}></div> 'from-transparent to-blue-700 dark:to-blue-700'
)}
/>
</div> </div>
</div> </div>
); );
@@ -125,37 +158,42 @@ const {
transform: translateY(0); transform: translateY(0);
} }
} }
/* Card container styles */ /* Card container styles */
.card-container { .card-container {
min-height: 0; min-height: 0;
height: auto; height: auto;
transition: transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94), box-shadow 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94); transition:
transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94),
box-shadow 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
will-change: transform, box-shadow; will-change: transform, box-shadow;
} }
/* Hover effect for details */ /* Hover effect for details */
[data-details] { [data-details] {
transform-origin: top; transform-origin: top;
transition: max-height 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94), transition:
opacity 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94); max-height 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94),
opacity 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
will-change: max-height, opacity; will-change: max-height, opacity;
} }
/* Add a subtle indicator that more content is available */ /* Add a subtle indicator that more content is available */
.group:not(:hover) [data-details] { .group:not(:hover) [data-details] {
max-height: 0 !important; max-height: 0 !important;
overflow: hidden; overflow: hidden;
} }
.group:hover [data-details] { .group:hover [data-details] {
max-height: 550px; /* Large enough to fit content but still allows animation */ max-height: 550px; /* Large enough to fit content but still allows animation */
} }
@keyframes fadeIn { @keyframes fadeIn {
to { opacity: 1; } to {
opacity: 1;
}
} }
/* Responsive adjustments */ /* Responsive adjustments */
@media (max-width: 768px) { @media (max-width: 768px) {
.timeline-item { .timeline-item {
@@ -163,4 +201,4 @@ const {
margin-left: 2.5rem; margin-left: 2.5rem;
} }
} }
</style> </style>

View File

@@ -100,28 +100,22 @@ const {
{/* Responsive styles for small screens */} {/* Responsive styles for small screens */}
<style> <style>
.ease-staggered {
transition-timing-function: cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
.hover\:opacity-0:hover {
opacity: 0;
}
@media (max-width: 768px) { @media (max-width: 768px) {
#timeline-item-0, [id^="timeline-item-"] {
#timeline-item-1,
#timeline-item-2,
#timeline-item-3,
#timeline-item-4 {
margin-top: 0 !important; margin-top: 0 !important;
margin-left: 2rem !important; margin-left: 2rem !important;
margin-right: 0 !important; margin-right: 0 !important;
width: calc(100% - 2rem) !important; width: calc(100% - 2rem) !important;
} }
} }
/* Custom easing function for staggered timeline */
.ease-staggered {
transition-timing-function: cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
/* Fade effect for the gradient overlay */
.hover\:opacity-0:hover {
opacity: 0;
}
</style> </style>
</div> </div>
) )

View File

@@ -16,13 +16,13 @@ const WrapperTag = as;
--- ---
<WrapperTag class="relative not-prose scroll-mt-[72px]" {...id ? { id } : {}}> <WrapperTag class="relative not-prose scroll-mt-[72px]" {...id ? { id } : {}}>
{!disableBackground && ( {
<div class="absolute inset-0 pointer-events-none -z-[1]" aria-hidden="true"> !disableBackground && (
<slot name="bg"> <div class="absolute inset-0 pointer-events-none -z-[1]" aria-hidden="true">
{bg ? <Fragment set:html={bg} /> : <Background isDark={isDark} />} <slot name="bg">{bg ? <Fragment set:html={bg} /> : <Background isDark={isDark} />}</slot>
</slot> </div>
</div> )
)} }
<div <div
class:list={[ class:list={[
twMerge( twMerge(

View File

@@ -52,7 +52,9 @@ const posts = APP_BLOG.isEnabled ? await findLatestPosts({ count }) : [];
</div> </div>
)} )}
{information && <p class="text-muted dark:text-slate-400 lg:text-sm lg:max-w-md text-sm" set:html={information} />} {information && (
<p class="text-muted dark:text-slate-400 lg:text-sm lg:max-w-md text-sm" set:html={information} />
)}
</div> </div>
<Grid posts={posts} /> <Grid posts={posts} />

View File

@@ -19,10 +19,10 @@ const {
--- ---
<WidgetWrapper id={id} isDark={isDark} containerClass={`max-w-7xl mx-auto ${classes?.container ?? ''}`} bg={bg}> <WidgetWrapper id={id} isDark={isDark} containerClass={`max-w-7xl mx-auto ${classes?.container ?? ''}`} bg={bg}>
<Headline <Headline
title={title} title={title}
subtitle={subtitle} subtitle={subtitle}
tagline={tagline} tagline={tagline}
classes={{ classes={{
container: 'max-w-3xl', container: 'max-w-3xl',
title: 'text-3xl lg:text-4xl', title: 'text-3xl lg:text-4xl',
@@ -30,17 +30,24 @@ const {
/> />
<div class="mt-6 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4"> <div class="mt-6 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{items.map(({ title, description, icon }) => ( {
<div class="group backdrop-blur-sm bg-white/15 dark:bg-transparent p-3 rounded-lg shadow-sm hover:shadow-md transition-all duration-300 ease-in-out hover:bg-white/30 hover:backdrop-blur-md dark:hover:backdrop-blur-md border border-gray-200 dark:border-gray-700 hover:translate-y-[-2px]"> items.map(({ title, description, icon }) => (
<div class="flex items-center mb-2"> <div class="group backdrop-blur-sm bg-white/15 dark:bg-transparent p-3 rounded-lg shadow-sm hover:shadow-md transition-all duration-300 ease-in-out hover:bg-white/30 hover:backdrop-blur-md dark:hover:backdrop-blur-md border border-gray-200 dark:border-gray-700 hover:translate-y-[-2px]">
<Icon name={icon || defaultIcon} class="w-5 h-5 mr-2 text-primary dark:text-blue-300 transition-transform duration-300 group-hover:scale-110 dark:shadow-blue-500/40 dark:shadow-sm" /> <div class="flex items-center mb-2">
<h3 class="text-base font-semibold">{title}</h3> <Icon
name={icon || defaultIcon}
class="w-5 h-5 mr-2 text-primary dark:text-blue-300 transition-transform duration-300 group-hover:scale-110 dark:shadow-blue-500/40 dark:shadow-sm"
/>
<h3 class="text-base font-semibold">{title}</h3>
</div>
<div class="text-muted overflow-hidden">
<p class="text-base max-h-[12rem] hover:max-h-[300px] transition-all duration-500 ease-skills">
{description}
</p>
</div>
</div> </div>
<div class="text-muted overflow-hidden"> ))
<p class="text-base max-h-[12rem] hover:max-h-[300px] transition-all duration-500 ease-skills">{description}</p> }
</div>
</div>
))}
</div> </div>
</WidgetWrapper> </WidgetWrapper>
@@ -49,9 +56,9 @@ const {
.ease-skills { .ease-skills {
transition-timing-function: cubic-bezier(0.25, 0.46, 0.45, 0.94); transition-timing-function: cubic-bezier(0.25, 0.46, 0.45, 0.94);
} }
/* Fade effect for the gradient overlay */ /* Fade effect for the gradient overlay */
.hover\:opacity-0:hover { .hover\:opacity-0:hover {
opacity: 0; opacity: 0;
} }
</style> </style>

View File

@@ -31,4 +31,4 @@ const {
/> />
<CompactTimeline items={items} classes={classes?.items as Record<string, never>} /> <CompactTimeline items={items} classes={classes?.items as Record<string, never>} />
</div> </div>
</WidgetWrapper> </WidgetWrapper>

View File

@@ -44,7 +44,11 @@ const {
<div class="mx-auto max-w-7xl p-4 md:px-8"> <div class="mx-auto max-w-7xl p-4 md:px-8">
<div class={`md:flex ${isReversed ? 'md:flex-row-reverse' : ''} md:gap-16`}> <div class={`md:flex ${isReversed ? 'md:flex-row-reverse' : ''} md:gap-16`}>
<div class="md:basis-1/2 self-center"> <div class="md:basis-1/2 self-center">
{content && <div class="mb-12 text-lg dark:text-slate-400 content-backdrop p-3 rounded-md" set:html={content} />} {
content && (
<div class="mb-12 text-lg dark:text-slate-400 content-backdrop p-3 rounded-md" set:html={content} />
)
}
{ {
callToAction && ( callToAction && (

View File

@@ -27,25 +27,27 @@ export interface Props {
import { supportedLanguages } from '~/i18n/translations'; import { supportedLanguages } from '~/i18n/translations';
// Define the type for supported languages // Define the type for supported languages
type SupportedLanguage = typeof supportedLanguages[number]; type SupportedLanguage = (typeof supportedLanguages)[number];
// Get current language from URL // Get current language from URL
const currentPath = `/${Astro.url.pathname.replace(/^\/+|\/+$/g, '')}`; const currentPath = `/${Astro.url.pathname.replace(/^\/+|\/+$/g, '')}`;
const pathSegments = currentPath.split('/').filter(Boolean); const pathSegments = currentPath.split('/').filter(Boolean);
// Check for language in URL path // Check for language in URL path
let currentLang = pathSegments[0] && supportedLanguages.includes(pathSegments[0] as SupportedLanguage) let currentLang =
? pathSegments[0] as SupportedLanguage pathSegments[0] && supportedLanguages.includes(pathSegments[0] as SupportedLanguage)
: null; ? (pathSegments[0] as SupportedLanguage)
: null;
// If no language in URL, check cookies // If no language in URL, check cookies
if (!currentLang) { if (!currentLang) {
const cookies = Astro.request.headers.get('cookie') || ''; const cookies = Astro.request.headers.get('cookie') || '';
const cookieLanguage = cookies.split(';') const cookieLanguage = cookies
.map(cookie => cookie.trim()) .split(';')
.find(cookie => cookie.startsWith('preferredLanguage=')) .map((cookie) => cookie.trim())
.find((cookie) => cookie.startsWith('preferredLanguage='))
?.split('=')[1]; ?.split('=')[1];
if (cookieLanguage && supportedLanguages.includes(cookieLanguage as SupportedLanguage)) { if (cookieLanguage && supportedLanguages.includes(cookieLanguage as SupportedLanguage)) {
currentLang = cookieLanguage as SupportedLanguage; currentLang = cookieLanguage as SupportedLanguage;
} else { } else {
@@ -60,7 +62,7 @@ const footerData = getFooterData(currentLang);
const { const {
secondaryLinks = footerData.secondaryLinks, secondaryLinks = footerData.secondaryLinks,
socialLinks = footerData.socialLinks, socialLinks = footerData.socialLinks,
theme = 'light' theme = 'light',
} = Astro.props; } = Astro.props;
--- ---
@@ -69,10 +71,8 @@ const {
<div <div
class="relative max-w-7xl mx-auto px-4 sm:px-6 dark:text-slate-300 intersect-once intersect-quarter intercept-no-queue motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade" class="relative max-w-7xl mx-auto px-4 sm:px-6 dark:text-slate-300 intersect-once intersect-quarter intercept-no-queue motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade"
> >
<!-- ✅ Combined Footer Section --> <!-- ✅ Combined Footer Section -->
<div class="flex flex-col md:flex-row md:justify-between py-6 md:py-8 space-y-6 md:space-y-0"> <div class="flex flex-col md:flex-row md:justify-between py-6 md:py-8 space-y-6 md:space-y-0">
<!-- Left Section: Company Name and Business Details --> <!-- Left Section: Company Name and Business Details -->
<div class="flex flex-col items-start space-y-2"> <div class="flex flex-col items-start space-y-2">
<!-- Site Title --> <!-- Site Title -->
@@ -86,39 +86,28 @@ const {
</a> </a>
<!-- Business Information (Dutch Law Requirements) --> <!-- Business Information (Dutch Law Requirements) -->
<div class="text-sm text-white-500 space-y-1"> <div class="text-sm text-white-500 space-y-1">
<p>KVK: 87654321 | BTW: NL123456789B01</p>
<p>info@365devnet.eu</p> <p>info@365devnet.eu</p>
</div> </div>
</div> </div>
<!-- Right Section: Social Icons and Terms/Privacy Links --> <!-- Right Section: Social Icons and Terms/Privacy Links -->
<div class="flex flex-col items-start md:items-end space-y-4"> <div class="flex flex-col items-start md:items-end space-y-4">
<!-- Social Icons --> <div class="text-sm text-white-500 space-y-1">
{ <p>KVK: 87654321 | BTW: NL123456789B01</p>
socialLinks?.length && ( </div>
<ul class="flex space-x-4">
{socialLinks.map(({ ariaLabel, href, icon }) => (
<li>
<a
class="text-muted dark:text-white-400 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-700 rounded-lg p-2 inline-flex items-center"
aria-label={ariaLabel}
href={href}
>
{icon && <Icon name={icon} class="w-5 h-5" />}
</a>
</li>
))}
</ul>
)
}
<!-- Terms & Privacy Policy Links --> <!-- Terms & Privacy Policy Links -->
<div class="flex items-center space-x-4 text-sm text-white-500"> <div class="flex items-center space-x-4 text-sm text-white-500">
{secondaryLinks.map(({ text, href }) => ( {
<a class="hover:text-gray-700 hover:underline dark:hover:text-gray-200 transition duration-150 ease-in-out" href={href}> secondaryLinks.map(({ text, href }) => (
{text} <a
</a> class="hover:text-gray-700 hover:underline dark:hover:text-gray-200 transition duration-150 ease-in-out"
))} href={href}
>
{text}
</a>
))
}
</div> </div>
</div> </div>
</div> </div>
@@ -130,4 +119,4 @@ const {
border-radius: 0 !important; border-radius: 0 !important;
overflow: hidden; overflow: hidden;
} }
</style> </style>

View File

@@ -7,7 +7,7 @@ import ToggleMenu from '~/components/common/ToggleMenu.astro';
import LanguageDropdown from '~/components/LanguageDropdown.astro'; import LanguageDropdown from '~/components/LanguageDropdown.astro';
import { getHomePermalink } from '~/utils/permalinks'; import { getHomePermalink } from '~/utils/permalinks';
import { trimSlash, getAsset } from '~/utils/permalinks'; import { trimSlash } from '~/utils/permalinks';
import { getHeaderData } from '~/navigation'; import { getHeaderData } from '~/navigation';
interface Link { interface Link {
@@ -40,21 +40,23 @@ const currentPath = `/${trimSlash(new URL(Astro.url).pathname)}`;
const pathSegments = currentPath.split('/').filter(Boolean); const pathSegments = currentPath.split('/').filter(Boolean);
// Define the type for supported languages // Define the type for supported languages
type SupportedLanguage = typeof supportedLanguages[number]; type SupportedLanguage = (typeof supportedLanguages)[number];
// Check for language in URL path // Check for language in URL path
let currentLang = pathSegments[0] && supportedLanguages.includes(pathSegments[0] as SupportedLanguage) let currentLang =
? pathSegments[0] as SupportedLanguage pathSegments[0] && supportedLanguages.includes(pathSegments[0] as SupportedLanguage)
: null; ? (pathSegments[0] as SupportedLanguage)
: null;
// If no language in URL, check cookies // If no language in URL, check cookies
if (!currentLang) { if (!currentLang) {
const cookies = Astro.request.headers.get('cookie') || ''; const cookies = Astro.request.headers.get('cookie') || '';
const cookieLanguage = cookies.split(';') const cookieLanguage = cookies
.map(cookie => cookie.trim()) .split(';')
.find(cookie => cookie.startsWith('preferredLanguage=')) .map((cookie) => cookie.trim())
.find((cookie) => cookie.startsWith('preferredLanguage='))
?.split('=')[1]; ?.split('=')[1];
if (cookieLanguage && supportedLanguages.includes(cookieLanguage as SupportedLanguage)) { if (cookieLanguage && supportedLanguages.includes(cookieLanguage as SupportedLanguage)) {
currentLang = cookieLanguage as SupportedLanguage; currentLang = cookieLanguage as SupportedLanguage;
} else { } else {
@@ -65,7 +67,6 @@ if (!currentLang) {
// Get translated header data - ensure we're using the current language // Get translated header data - ensure we're using the current language
const headerData = getHeaderData(currentLang); const headerData = getHeaderData(currentLang);
console.log(`Header initialized with language: ${currentLang}`);
const { const {
id = 'header', id = 'header',
@@ -74,7 +75,7 @@ const {
isDark = false, isDark = false,
isFullWidth = false, isFullWidth = false,
showToggleTheme = false, showToggleTheme = false,
showRssFeed = false,
position = 'center', position = 'center',
} = Astro.props; } = Astro.props;
--- ---
@@ -93,7 +94,7 @@ const {
'relative text-default py-3 px-3 md:px-6 mx-auto w-full', 'relative text-default py-3 px-3 md:px-6 mx-auto w-full',
{ {
'md:flex md:justify-between': position !== 'center', 'md:flex md:justify-between': position !== 'center',
}, },
{ {
'md:grid md:grid-cols-3 md:items-center': position === 'center', 'md:grid md:grid-cols-3 md:items-center': position === 'center',
}, },
@@ -112,7 +113,8 @@ const {
<!-- Improved mobile navigation accessibility --> <!-- Improved mobile navigation accessibility -->
<style> <style>
@media (max-width: 767px) { @media (max-width: 767px) {
nav ul li a, nav ul li button { nav ul li a,
nav ul li button {
padding: 0.75rem 1rem; /* Larger touch targets */ padding: 0.75rem 1rem; /* Larger touch targets */
min-height: 44px; /* Minimum touch target size */ min-height: 44px; /* Minimum touch target size */
display: flex; display: flex;
@@ -189,4 +191,4 @@ const {
</div> </div>
</div> </div>
</div> </div>
</header> </header>

View File

@@ -26,13 +26,13 @@ const {
--- ---
<section class="relative md:-mt-[76px] not-prose" {...id ? { id } : {}}> <section class="relative md:-mt-[76px] not-prose" {...id ? { id } : {}}>
{!disableBackground && ( {
<div class="absolute inset-0 pointer-events-none" aria-hidden="true"> !disableBackground && (
<slot name="bg"> <div class="absolute inset-0 pointer-events-none" aria-hidden="true">
{bg ? <Fragment set:html={bg} /> : <Background isDark={isDark} disableParallax={true} />} <slot name="bg">{bg ? <Fragment set:html={bg} /> : <Background isDark={isDark} disableParallax={true} />}</slot>
</slot> </div>
</div> )
)} }
<div class="relative max-w-7xl mx-auto px-4 sm:px-6"> <div class="relative max-w-7xl mx-auto px-4 sm:px-6">
<div class="pt-0 md:pt-[76px] pointer-events-none"></div> <div class="pt-0 md:pt-[76px] pointer-events-none"></div>
<div class="py-12 md:py-20"> <div class="py-12 md:py-20">

View File

@@ -26,7 +26,12 @@ const {
{ {
testimonials && testimonials &&
testimonials.map(({ title, linkUrl, name, issueDate, description, image }) => ( testimonials.map(({ title, linkUrl, name, issueDate, description, image }) => (
<a href={linkUrl} target="_blank" rel="noopener noreferrer" class="flex flex-col justify-start items-center p-2 md:p-4 rounded-md shadow-md backdrop-blur-sm bg-white/15 dark:bg-transparent border border-gray-200 dark:border-slate-600 hover:bg-white/30 hover:backdrop-blur-md dark:hover:backdrop-blur-md transition-all duration-300 ease-in-out hover:shadow-lg hover:translate-y-[-2px] w-[425px] mx-auto sm:mx-0"> <a
href={linkUrl}
target="_blank"
rel="noopener noreferrer"
class="flex flex-col justify-start items-center p-2 md:p-4 rounded-md shadow-md backdrop-blur-sm bg-white/15 dark:bg-transparent border border-gray-200 dark:border-slate-600 hover:bg-white/30 hover:backdrop-blur-md dark:hover:backdrop-blur-md transition-all duration-300 ease-in-out hover:shadow-lg hover:translate-y-[-2px] w-[425px] mx-auto sm:mx-0"
>
{title && <h2 class="text-lg font-medium leading-6 pb-4 text-center">{title}</h2>} {title && <h2 class="text-lg font-medium leading-6 pb-4 text-center">{title}</h2>}
{image && ( {image && (
<div class="h-[160px] w-[160px] border-slate-200 dark:border-slate-600 mx-auto"> <div class="h-[160px] w-[160px] border-slate-200 dark:border-slate-600 mx-auto">
@@ -42,10 +47,10 @@ const {
/> />
</div> </div>
)} )}
{/* Horizontal line directly under the image */} {/* Horizontal line directly under the image */}
<hr class="border-slate-200 dark:border-slate-600 my-4" /> <hr class="border-slate-200 dark:border-slate-600 my-4" />
{/* Text content takes up the remaining space */} {/* Text content takes up the remaining space */}
<div class="flex flex-col flex-grow items-center"> <div class="flex flex-col flex-grow items-center">
{name && <p class="text-lg font-semibold text-center">{name}</p>} {name && <p class="text-lg font-semibold text-center">{name}</p>}
@@ -56,8 +61,7 @@ const {
)) ))
} }
</div> </div>
{ {
callToAction && ( callToAction && (
<div class="flex justify-center mx-auto w-fit mt-8 md:mt-12 font-medium"> <div class="flex justify-center mx-auto w-fit mt-8 md:mt-12 font-medium">
@@ -68,12 +72,12 @@ const {
</WidgetWrapper> </WidgetWrapper>
<style> <style>
@media (max-width: 640px) { @media (max-width: 640px) {
.grid { .grid {
justify-content: center; justify-content: center;
}
.grid > a {
margin: 0 auto;
}
} }
.grid > a {
margin: 0 auto;
}
}
</style> </style>

View File

@@ -32,7 +32,7 @@ const {
} = Astro.props as Props; } = Astro.props as Props;
// Transform the work experience items to the format expected by ModernTimeline // Transform the work experience items to the format expected by ModernTimeline
const timelineItems = items.map(item => { const timelineItems = items.map((item) => {
// Extract year from date if available // Extract year from date if available
let year: string | undefined = undefined; let year: string | undefined = undefined;
if (item.date) { if (item.date) {
@@ -43,7 +43,7 @@ const timelineItems = items.map(item => {
year = dateMatch[1]; year = dateMatch[1];
} }
} }
return { return {
title: `<span class="${compact ? 'text-lg' : 'text-xl'}">${item.title}</span>${item.company ? `<span class="block ${compact ? 'text-sm' : 'text-sm'} font-normal text-gray-600 dark:text-gray-400">${item.company}</span>` : ''}`, title: `<span class="${compact ? 'text-lg' : 'text-xl'}">${item.title}</span>${item.company ? `<span class="block ${compact ? 'text-sm' : 'text-sm'} font-normal text-gray-600 dark:text-gray-400">${item.company}</span>` : ''}`,
description: `<div class="transform-origin-top transition-transform duration-300">${item.description || ''}${item.description && (item.date || item.location) ? '<div class="mt-2"></div>' : ''}${item.date ? `<span class="block text-sm font-semibold text-gray-500 dark:text-gray-400">${item.date}</span>` : ''}${item.location ? `<span class="block text-sm text-gray-500 dark:text-gray-400">${item.location}</span>` : ''}</div>`, description: `<div class="transform-origin-top transition-transform duration-300">${item.description || ''}${item.description && (item.date || item.location) ? '<div class="mt-2"></div>' : ''}${item.date ? `<span class="block text-sm font-semibold text-gray-500 dark:text-gray-400">${item.date}</span>` : ''}${item.location ? `<span class="block text-sm text-gray-500 dark:text-gray-400">${item.location}</span>` : ''}</div>`,
@@ -56,15 +56,21 @@ const timelineItems = items.map(item => {
<WidgetWrapper id={id} isDark={isDark} bg={bg} classes={classes}> <WidgetWrapper id={id} isDark={isDark} bg={bg} classes={classes}>
<div class={`flex flex-col gap-${compact ? '8' : '12'} md:gap-${compact ? '12' : '16'}`}> <div class={`flex flex-col gap-${compact ? '8' : '12'} md:gap-${compact ? '12' : '16'}`}>
{title && ( {
<div class="flex flex-col gap-4 text-center"> title && (
{tagline && ( <div class="flex flex-col gap-4 text-center">
<p class="text-sm font-semibold uppercase tracking-wide text-primary dark:text-blue-200">{tagline}</p> {tagline && (
)} <p class="text-sm font-semibold uppercase tracking-wide text-primary dark:text-blue-200">{tagline}</p>
{title && <h2 class={`${compact ? 'text-2xl md:text-3xl' : 'text-3xl md:text-4xl'} font-bold font-heading`}>{title}</h2>} )}
{subtitle && <p class={`${compact ? 'text-lg' : 'text-xl'} text-muted dark:text-slate-400`}>{subtitle}</p>} {title && (
</div> <h2 class={`${compact ? 'text-2xl md:text-3xl' : 'text-3xl md:text-4xl'} font-bold font-heading`}>
)} {title}
</h2>
)}
{subtitle && <p class={`${compact ? 'text-lg' : 'text-xl'} text-muted dark:text-slate-400`}>{subtitle}</p>}
</div>
)
}
<ModernTimeline <ModernTimeline
items={timelineItems} items={timelineItems}
compact={compact} compact={compact}
@@ -79,4 +85,4 @@ const timelineItems = items.map(item => {
}} }}
/> />
</div> </div>
</WidgetWrapper> </WidgetWrapper>

View File

@@ -9,7 +9,7 @@ metadata:
title: title:
default: 365DevNet default: 365DevNet
template: '%s — 365DevNet' template: '%s — 365DevNet'
description: "The website 365DevNet serves as the personal portfolio of Richard Bergsma, an IT Systems and Automation Manager with over 15 years of experience. The site provides detailed information about his professional background, including his work experience, skills, and certifications. " description: 'The website 365DevNet serves as the personal portfolio of Richard Bergsma, an IT Systems and Automation Manager with over 15 years of experience. The site provides detailed information about his professional background, including his work experience, skills, and certifications. '
robots: robots:
index: true index: true
follow: true follow: true

View File

@@ -1,11 +1,11 @@
--- ---
title: "Custom Connectors in Power Automate Extending Your Automation Capabilities" title: 'Custom Connectors in Power Automate Extending Your Automation Capabilities'
excerpt: "Learn how to build custom connectors in Power Automate to integrate with any API and extend your automation capabilities. This comprehensive guide covers implementation steps, real-world use cases, and best practices for creating reliable connectors." excerpt: 'Learn how to build custom connectors in Power Automate to integrate with any API and extend your automation capabilities. This comprehensive guide covers implementation steps, real-world use cases, and best practices for creating reliable connectors.'
publishDate: 2025-02-26T02:30:00Z publishDate: 2025-02-26T02:30:00Z
author: "Richard Bergsma" author: 'Richard Bergsma'
category: Automation category: Automation
image: https://res.cloudinary.com/dmgi9movl/image/upload/v1708908828/PowerAutomate_CustomConnectors.png image: https://res.cloudinary.com/dmgi9movl/image/upload/v1708908828/PowerAutomate_CustomConnectors.png
tags: ["Power Automate", "Custom Connectors", "API Integration", "Microsoft Power Platform", "Low-Code Development"] tags: ['Power Automate', 'Custom Connectors', 'API Integration', 'Microsoft Power Platform', 'Low-Code Development']
--- ---
# 🚀 Custom Connectors in Power Automate Extending Your Automation Capabilities # 🚀 Custom Connectors in Power Automate Extending Your Automation Capabilities
@@ -97,6 +97,7 @@ Select and configure the appropriate authentication type:
- **Windows authentication** For internal corporate resources - **Windows authentication** For internal corporate resources
For OAuth 2.0, you'll need to provide: For OAuth 2.0, you'll need to provide:
- Client ID and secret - Client ID and secret
- Authorization and token URLs - Authorization and token URLs
- Scope information - Scope information
@@ -109,6 +110,7 @@ Operations represent the API endpoints your connector will expose:
1. Click **New action** to add an endpoint 1. Click **New action** to add an endpoint
2. Configure: 2. Configure:
- **Summary** Short description of what the operation does - **Summary** Short description of what the operation does
- **Description** Detailed explanation - **Description** Detailed explanation
- **Operation ID** Unique identifier (no spaces) - **Operation ID** Unique identifier (no spaces)
@@ -129,6 +131,7 @@ For each operation, define:
- **Body parameters** Data sent in the request body - **Body parameters** Data sent in the request body
For each parameter, specify: For each parameter, specify:
- Name and description - Name and description
- Whether it's required - Whether it's required
- Data type and format - Data type and format
@@ -205,6 +208,7 @@ A manufacturing company needed to connect their modern Power Apps solution with
![Legacy System Integration](https://via.placeholder.com/800x400?text=Legacy+System+Integration) ![Legacy System Integration](https://via.placeholder.com/800x400?text=Legacy+System+Integration)
**Results:** **Results:**
- Avoided costly system replacement - Avoided costly system replacement
- Enabled mobile inventory management - Enabled mobile inventory management
- Reduced manual data entry by 85% - Reduced manual data entry by 85%
@@ -216,6 +220,7 @@ A healthcare provider needed to integrate with a specialized medical records sys
**Solution:** They developed a custom connector that implemented the required authentication and data transformation, while ensuring HIPAA compliance. **Solution:** They developed a custom connector that implemented the required authentication and data transformation, while ensuring HIPAA compliance.
**Results:** **Results:**
- Automated patient record updates - Automated patient record updates
- Reduced administrative workload - Reduced administrative workload
- Improved data accuracy and compliance - Improved data accuracy and compliance
@@ -229,6 +234,7 @@ A financial services firm with a microservices architecture needed to orchestrat
![Microservices Architecture](https://via.placeholder.com/800x400?text=Microservices+Architecture) ![Microservices Architecture](https://via.placeholder.com/800x400?text=Microservices+Architecture)
**Results:** **Results:**
- Simplified complex cross-service workflows - Simplified complex cross-service workflows
- Enabled business users to create automations - Enabled business users to create automations
- Reduced development time for new integrations - Reduced development time for new integrations
@@ -359,6 +365,7 @@ Even with careful planning, you may encounter issues when implementing custom co
**Problem:** Connector fails to authenticate with the API. **Problem:** Connector fails to authenticate with the API.
**Solutions:** **Solutions:**
- Verify credentials and client IDs/secrets - Verify credentials and client IDs/secrets
- Check for expired tokens or certificates - Check for expired tokens or certificates
- Ensure redirect URLs are correctly configured - Ensure redirect URLs are correctly configured
@@ -369,6 +376,7 @@ Even with careful planning, you may encounter issues when implementing custom co
**Problem:** API rejects requests due to schema validation failures. **Problem:** API rejects requests due to schema validation failures.
**Solutions:** **Solutions:**
- Compare your request schema with the API documentation - Compare your request schema with the API documentation
- Use the API's test endpoints to validate request formats - Use the API's test endpoints to validate request formats
- Check for required fields that might be missing - Check for required fields that might be missing
@@ -381,6 +389,7 @@ Even with careful planning, you may encounter issues when implementing custom co
**Problem:** Connector operations are slow or time out. **Problem:** Connector operations are slow or time out.
**Solutions:** **Solutions:**
- Implement pagination for large data sets - Implement pagination for large data sets
- Add appropriate timeouts in your connector definition - Add appropriate timeouts in your connector definition
- Consider caching strategies for frequently accessed data - Consider caching strategies for frequently accessed data
@@ -391,6 +400,7 @@ Even with careful planning, you may encounter issues when implementing custom co
**Problem:** Connector works in development but fails in production. **Problem:** Connector works in development but fails in production.
**Solutions:** **Solutions:**
- Check environment-specific configurations - Check environment-specific configurations
- Verify network connectivity and firewall rules - Verify network connectivity and firewall rules
- Ensure service accounts have appropriate permissions - Ensure service accounts have appropriate permissions
@@ -425,7 +435,7 @@ Whether you're connecting to legacy applications, specialized industry solutions
✅ **Extend your integration capabilities.** ✅ **Extend your integration capabilities.**
✅ **Simplify complex API interactions.** ✅ **Simplify complex API interactions.**
✅ **Create reusable components across the Power Platform.** ✅ **Create reusable components across the Power Platform.**
✅ **Empower citizen developers with secure, managed API access.** ✅ **Empower citizen developers with secure, managed API access.**
--- ---
@@ -434,8 +444,8 @@ Whether you're connecting to legacy applications, specialized industry solutions
📌 **[Microsoft's Official Documentation on Custom Connectors](https://docs.microsoft.com/en-us/connectors/custom-connectors/)** 📌 **[Microsoft's Official Documentation on Custom Connectors](https://docs.microsoft.com/en-us/connectors/custom-connectors/)**
📌 **[Power Automate Community](https://powerusers.microsoft.com/t5/Power-Automate-Community/ct-p/MPACommunity)** 📌 **[Power Automate Community](https://powerusers.microsoft.com/t5/Power-Automate-Community/ct-p/MPACommunity)**
📌 **[OpenAPI Specification](https://swagger.io/specification/)** 📌 **[OpenAPI Specification](https://swagger.io/specification/)**
📌 **[Power CAT Custom Connector DevKit](https://github.com/microsoft/PowerCAT.PowerShell.CustomConnector)** 📌 **[Power CAT Custom Connector DevKit](https://github.com/microsoft/PowerCAT.PowerShell.CustomConnector)**
--- ---
Have you implemented custom connectors in your organization? I'd love to hear about your experiences and use cases! 🚀 Have you implemented custom connectors in your organization? I'd love to hear about your experiences and use cases! 🚀

View File

@@ -1,21 +1,21 @@
--- ---
title: "Enterprise App Protection Your First Line of Defense Against Phishing" title: 'Enterprise App Protection Your First Line of Defense Against Phishing'
excerpt: "Protect your organization from phishing attacks impersonating Microsoft 365, DocuSign, Salesforce, and more. Enterprise App Protection automatically scans and warns about malicious links." excerpt: 'Protect your organization from phishing attacks impersonating Microsoft 365, DocuSign, Salesforce, and more. Enterprise App Protection automatically scans and warns about malicious links.'
publishDate: 2025-02-16T02:00:00Z publishDate: 2025-02-16T02:00:00Z
author: "Richard Bergsma" author: 'Richard Bergsma'
category: Security category: Security
image: https://raw.githubusercontent.com/rrpbergsma/EnterpriseAppProtection/main/EnterpriseAppProtection.png image: https://raw.githubusercontent.com/rrpbergsma/EnterpriseAppProtection/main/EnterpriseAppProtection.png
tags: ["Security", "Phishing", "Microsoft 365", "Browser Extensions", "Cybersecurity"] tags: ['Security', 'Phishing', 'Microsoft 365', 'Browser Extensions', 'Cybersecurity']
--- ---
# 🚀 Enterprise App Protection Your First Line of Defense Against Phishing # 🚀 Enterprise App Protection Your First Line of Defense Against Phishing
## 🔍 Why This Extension Matters ## 🔍 Why This Extension Matters
**Phishing attacks** are one of the biggest cybersecurity threats in modern enterprises. Attackers frequently **spoof trusted brands** like **Microsoft 365, DocuSign, and Salesforce** to trick employees into entering credentials on fake websites. **Phishing attacks** are one of the biggest cybersecurity threats in modern enterprises. Attackers frequently **spoof trusted brands** like **Microsoft 365, DocuSign, and Salesforce** to trick employees into entering credentials on fake websites.
With **Enterprise App Protection**, you get **real-time phishing detection** to help prevent accidental clicks on **fraudulent links** in: With **Enterprise App Protection**, you get **real-time phishing detection** to help prevent accidental clicks on **fraudulent links** in:
- **Emails** - **Emails**
- **Microsoft Teams chats** - **Microsoft Teams chats**
- **SharePoint documents** - **SharePoint documents**
@@ -66,9 +66,11 @@ Unlike many security extensions, **Enterprise App Protection prioritizes user pr
## 📥 How to Install ## 📥 How to Install
### **🔹 From the Chrome Web Store (Coming Soon)** ### **🔹 From the Chrome Web Store (Coming Soon)**
Once published, the extension will be available directly from the Chrome Web Store. Once published, the extension will be available directly from the Chrome Web Store.
### **🔹 Manually Install (Developer Mode)** ### **🔹 Manually Install (Developer Mode)**
1⃣ **Download the latest version** from the [GitHub repository](https://github.com/rrpbergsma/EnterpriseAppProtection). 1⃣ **Download the latest version** from the [GitHub repository](https://github.com/rrpbergsma/EnterpriseAppProtection).
2⃣ Open **`chrome://extensions/`** in Chrome or Edge. 2⃣ Open **`chrome://extensions/`** in Chrome or Edge.
3⃣ Enable **Developer Mode** (top-right corner). 3⃣ Enable **Developer Mode** (top-right corner).
@@ -81,7 +83,7 @@ Once published, the extension will be available directly from the Chrome Web Sto
🔹 **Multi-browser support** Firefox & Safari compatibility. 🔹 **Multi-browser support** Firefox & Safari compatibility.
🔹 **Advanced AI-based phishing detection** More intelligent scanning for phishing patterns. 🔹 **Advanced AI-based phishing detection** More intelligent scanning for phishing patterns.
🔹 **Expanded enterprise app coverage** Supporting even more cloud-based applications. 🔹 **Expanded enterprise app coverage** Supporting even more cloud-based applications.
If you have feature requests or want to contribute, check out our **GitHub repository**: 👉 **[View the Source Code on GitHub](https://github.com/rrpbergsma/EnterpriseAppProtection)** If you have feature requests or want to contribute, check out our **GitHub repository**: 👉 **[View the Source Code on GitHub](https://github.com/rrpbergsma/EnterpriseAppProtection)**
@@ -93,15 +95,15 @@ Enterprise App Protection is your **first line of defense against phishing attac
✅ **Protect your accounts.** ✅ **Protect your accounts.**
✅ **Stay secure when using enterprise apps.** ✅ **Stay secure when using enterprise apps.**
✅ **Avoid falling victim to sophisticated phishing attacks.** ✅ **Avoid falling victim to sophisticated phishing attacks.**
--- ---
## 🔗 Get Started Now ## 🔗 Get Started Now
📌 **[Download the Extension](#)** (Coming Soon) 📌 **[Download the Extension](#)** (Coming Soon)
📌 **[View on GitHub](https://github.com/rrpbergsma/EnterpriseAppProtection)** 📌 **[View on GitHub](https://github.com/rrpbergsma/EnterpriseAppProtection)**
--- ---
Would love to hear your feedback! Let me know what you think. 🚀 Would love to hear your feedback! Let me know what you think. 🚀

View File

@@ -68,6 +68,7 @@ Organizations face increasing challenges in managing complex IT environments, es
One of Nexthinks standout features is **Remote Actions**—a powerful tool that enables IT teams to automate diagnostics and remediation tasks across devices without user intervention. One of Nexthinks standout features is **Remote Actions**—a powerful tool that enables IT teams to automate diagnostics and remediation tasks across devices without user intervention.
### Key Capabilities of Remote Actions: ### Key Capabilities of Remote Actions:
- **Automated Troubleshooting:** Run scripts to check system performance, clean caches, reset configurations, or diagnose network issues. - **Automated Troubleshooting:** Run scripts to check system performance, clean caches, reset configurations, or diagnose network issues.
- **Bulk Execution:** Apply fixes to hundreds or thousands of devices simultaneously. - **Bulk Execution:** Apply fixes to hundreds or thousands of devices simultaneously.
- **Silent Operations:** Perform actions without disrupting the end users workflow. - **Silent Operations:** Perform actions without disrupting the end users workflow.
@@ -90,13 +91,16 @@ Nexthink Flow allows IT teams to create **multi-step, conditional workflows** th
![Nexthink Flow](https://via.placeholder.com/800x400?text=Nexthink+Flow) ![Nexthink Flow](https://via.placeholder.com/800x400?text=Nexthink+Flow)
### Key Features of Nexthink Flow: ### Key Features of Nexthink Flow:
- **Visual Workflow Builder:** Design automation flows using a drag-and-drop interface, making it easy to create complex logic without coding. - **Visual Workflow Builder:** Design automation flows using a drag-and-drop interface, making it easy to create complex logic without coding.
- **Conditional Logic:** Set conditions based on device status, user behavior, or system performance to trigger different actions automatically. - **Conditional Logic:** Set conditions based on device status, user behavior, or system performance to trigger different actions automatically.
- **Cross-Platform Integration:** Integrate with external ITSM tools, cloud platforms, or internal APIs to orchestrate end-to-end processes. - **Cross-Platform Integration:** Integrate with external ITSM tools, cloud platforms, or internal APIs to orchestrate end-to-end processes.
- **Real-Time Feedback:** Monitor the success or failure of each step in the workflow for quick diagnostics and optimization. - **Real-Time Feedback:** Monitor the success or failure of each step in the workflow for quick diagnostics and optimization.
### Example Use Case: ### Example Use Case:
Imagine an employee reports a problem with their VPN connection: Imagine an employee reports a problem with their VPN connection:
1. **Trigger:** The issue is detected automatically via performance monitoring or reported through a Nexthink campaign. 1. **Trigger:** The issue is detected automatically via performance monitoring or reported through a Nexthink campaign.
2. **Diagnostics:** Flow initiates a series of Remote Actions to diagnose the issue—checking network settings, verifying credentials, and testing connectivity. 2. **Diagnostics:** Flow initiates a series of Remote Actions to diagnose the issue—checking network settings, verifying credentials, and testing connectivity.
3. **Remediation:** If an issue is found, Flow applies the appropriate fix automatically. If not resolved, it escalates the issue by creating a ServiceNow ticket with all diagnostic data attached. 3. **Remediation:** If an issue is found, Flow applies the appropriate fix automatically. If not resolved, it escalates the issue by creating a ServiceNow ticket with all diagnostic data attached.
@@ -114,4 +118,4 @@ For organizations looking to enhance productivity, reduce IT costs, and improve
--- ---
If you're interested in learning more about Nexthink or exploring how it can transform your IT operations, feel free to reach out or request a demo from [Nexthinks official website](https://www.nexthink.com) rel="noopener noreferrer". If you're interested in learning more about Nexthink or exploring how it can transform your IT operations, feel free to reach out or request a demo from [Nexthinks official website](https://www.nexthink.com) rel="noopener noreferrer".

View File

@@ -69,4 +69,4 @@ To test the email system:
1. Configure the `.env` file with your SMTP settings 1. Configure the `.env` file with your SMTP settings
2. Submit the contact form on the website 2. Submit the contact form on the website
3. Check the logs for email sending attempts 3. Check the logs for email sending attempts
4. In production mode, check your inbox for the actual emails 4. In production mode, check your inbox for the actual emails

View File

@@ -13,7 +13,7 @@ export function getAdminNotificationSubject(): string {
export function getAdminNotificationHtml(props: AdminNotificationProps): string { export function getAdminNotificationHtml(props: AdminNotificationProps): string {
const { name, email, message, submittedAt, ipAddress, userAgent } = props; const { name, email, message, submittedAt, ipAddress, userAgent } = props;
return ` return `
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
@@ -92,7 +92,7 @@ export function getAdminNotificationHtml(props: AdminNotificationProps): string
export function getAdminNotificationText(props: AdminNotificationProps): string { export function getAdminNotificationText(props: AdminNotificationProps): string {
const { name, email, message, submittedAt, ipAddress, userAgent } = props; const { name, email, message, submittedAt, ipAddress, userAgent } = props;
return ` return `
New Contact Form Submission New Contact Form Submission
@@ -108,4 +108,4 @@ User Agent: ${userAgent || 'Not available'}
This is an automated email from your website contact form. This is an automated email from your website contact form.
`; `;
} }

View File

@@ -17,9 +17,9 @@ export function getUserConfirmationHtml(props: UserConfirmationProps): string {
message, message,
submittedAt, submittedAt,
websiteName = process.env.WEBSITE_NAME || '365devnet.eu', websiteName = process.env.WEBSITE_NAME || '365devnet.eu',
contactEmail = process.env.ADMIN_EMAIL || 'richard@bergsma.it' contactEmail = process.env.ADMIN_EMAIL || 'richard@bergsma.it',
} = props; } = props;
return ` return `
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
@@ -97,9 +97,9 @@ export function getUserConfirmationText(props: UserConfirmationProps): string {
message, message,
submittedAt, submittedAt,
websiteName = process.env.WEBSITE_NAME || '365devnet.eu', websiteName = process.env.WEBSITE_NAME || '365devnet.eu',
contactEmail = process.env.ADMIN_EMAIL || 'richard@bergsma.it' contactEmail = process.env.ADMIN_EMAIL || 'richard@bergsma.it',
} = props; } = props;
return ` return `
Thank you for contacting ${websiteName} Thank you for contacting ${websiteName}
@@ -117,4 +117,4 @@ The ${websiteName} Team
If you did not submit this contact form, please disregard this email or contact us at ${contactEmail}. If you did not submit this contact form, please disregard this email or contact us at ${contactEmail}.
`; `;
} }

File diff suppressed because it is too large Load Diff

View File

@@ -34,25 +34,31 @@ const { language, textDirection } = I18N;
<html lang={language} dir={textDirection} class="2xl:text-[20px]"> <html lang={language} dir={textDirection} class="2xl:text-[20px]">
<head> <head>
<CommonMeta /> <CommonMeta />
<link rel="alternate" hreflang="en" href="https://www.365devnet.eu/en/" />
<link rel="alternate" hreflang="nl" href="https://www.365devnet.eu/nl/" />
<link rel="alternate" hreflang="de" href="https://www.365devnet.eu/de/" />
<link rel="alternate" hreflang="fr" href="https://www.365devnet.eu/fr/" />
<Favicons /> <Favicons />
<CustomStyles /> <CustomStyles />
<ApplyColorMode /> <ApplyColorMode />
<Metadata {...metadata} /> <Metadata {...metadata} />
<SiteVerification /> <SiteVerification />
<Analytics /> <Analytics />
<!-- Structured Data for SEO --> <!-- Structured Data for SEO -->
<StructuredData data={{ <StructuredData
"@context": "https://schema.org", data={{
"@type": "WebSite", '@context': 'https://schema.org',
"name": "365DevNet", '@type': 'WebSite',
"url": Astro.url.origin, name: '365DevNet',
"potentialAction": { url: Astro.url.origin,
"@type": "SearchAction", potentialAction: {
"target": `${Astro.url.origin}/search?q={search_term_string}`, '@type': 'SearchAction',
"query-input": "required name=search_term_string" target: `${Astro.url.origin}/search?q={search_term_string}`,
} 'query-input': 'required name=search_term_string',
}} /> },
}}
/>
<!-- Comment the line below to disable View Transitions --> <!-- Comment the line below to disable View Transitions -->
<ClientRouter fallback="swap" /> <ClientRouter fallback="swap" />

View File

@@ -3,14 +3,14 @@ import { getTranslation } from './i18n/translations';
export const getHeaderData = (lang = 'en') => { export const getHeaderData = (lang = 'en') => {
const t = getTranslation(lang); const t = getTranslation(lang);
// For hash links on the homepage, we need special handling // For hash links on the homepage, we need special handling
const homeHashLink = (hash) => { const homeHashLink = (hash) => {
// Create an absolute path to the homepage with the language prefix // Create an absolute path to the homepage with the language prefix
// and include the hash in the permalink generation // and include the hash in the permalink generation
return getPermalink('/' + hash, 'page', lang); return getPermalink('/' + hash, 'page', lang);
}; };
return { return {
links: [ links: [
{ {
@@ -27,21 +27,29 @@ export const getHeaderData = (lang = 'en') => {
links: [ links: [
{ text: t.navigation.about, href: getPermalink('/aboutme', 'page', lang), isHashLink: false }, { text: t.navigation.about, href: getPermalink('/aboutme', 'page', lang), isHashLink: false },
{ text: t.navigation.resume, href: getPermalink('/aboutme', 'page', lang) + '#resume', isHashLink: true }, { text: t.navigation.resume, href: getPermalink('/aboutme', 'page', lang) + '#resume', isHashLink: true },
{ text: t.navigation.certifications, href: getPermalink('/aboutme', 'page', lang) + '#certifications', isHashLink: true }, {
text: t.navigation.certifications,
href: getPermalink('/aboutme', 'page', lang) + '#certifications',
isHashLink: true,
},
{ text: t.navigation.skills, href: getPermalink('/aboutme', 'page', lang) + '#skills', isHashLink: true }, { text: t.navigation.skills, href: getPermalink('/aboutme', 'page', lang) + '#skills', isHashLink: true },
{ text: t.navigation.education, href: getPermalink('/aboutme', 'page', lang) + '#education', isHashLink: true }, {
] text: t.navigation.education,
href: getPermalink('/aboutme', 'page', lang) + '#education',
isHashLink: true,
},
],
}, },
] ],
}; };
}; };
// For backward compatibility - but don't use this directly, always use getHeaderData(lang) to ensure translations // For backward compatibility - but don't use this directly, always use getHeaderData(lang) to ensure translations
export const headerData = (lang = 'en') => getHeaderData(lang); export const headerData = (lang = 'en') => getHeaderData(lang);
export const getFooterData = (lang = 'en') => { export const getFooterData = (lang = 'en') => {
const t = getTranslation(lang); const t = getTranslation(lang);
return { return {
secondaryLinks: [ secondaryLinks: [
{ text: t.footer.terms, href: getPermalink('/terms', 'page', lang) }, { text: t.footer.terms, href: getPermalink('/terms', 'page', lang) },

View File

@@ -8,12 +8,12 @@ import CompactSteps from '~/components/widgets/CompactSteps.astro';
import WorkExperience from '~/components/widgets/WorkExperience.astro'; import WorkExperience from '~/components/widgets/WorkExperience.astro';
import CompactCertifications from '~/components/widgets/CompactCertifications.astro'; import CompactCertifications from '~/components/widgets/CompactCertifications.astro';
import CompactSkills from '~/components/widgets/CompactSkills.astro'; import CompactSkills from '~/components/widgets/CompactSkills.astro';
import HomePageImage from '~/assets/images/richardbergsma.png' import HomePageImage from '~/assets/images/richardbergsma.png';
import { getTranslation, supportedLanguages } from '~/i18n/translations'; import { getTranslation, supportedLanguages } from '~/i18n/translations';
export async function getStaticPaths() { export async function getStaticPaths() {
return supportedLanguages.map(lang => ({ return supportedLanguages.map((lang) => ({
params: { lang }, params: { lang },
})); }));
} }
@@ -26,39 +26,37 @@ if (!supportedLanguages.includes(lang)) {
const t = getTranslation(lang); const t = getTranslation(lang);
const metadata = { const metadata = {
title: 'About Me - ' + t.metadata.title, title: `About Me - Richard Bergsma - IT Systems and Automation Manager - ${t.metadata.title}`,
description: t.hero.subtitle + ' - IT professional with experience in systems and automation, working at COFRA Holding in Amsterdam.',
}; };
--- ---
<Layout metadata={metadata}> <Layout metadata={metadata}>
<Fragment slot="announcement"></Fragment> <Fragment slot="announcement"></Fragment>
<!-- Person Structured Data for SEO --> <!-- Person Structured Data for SEO -->
<StructuredData slot="structured-data" data={{ <StructuredData
"@context": "https://schema.org", slot="structured-data"
"@type": "Person", data={{
"name": "Richard Bergsma", '@context': 'https://schema.org',
"jobTitle": "IT Systems and Automation Manager", '@type': 'Person',
"description": t.hero.subtitle, name: 'Richard Bergsma',
"image": Astro.url.origin + "/images/richardbergsma.png", jobTitle: 'IT Systems and Automation Manager',
"url": Astro.url.origin, description: t.hero.subtitle,
"sameAs": [ image: Astro.url.origin + '/images/richardbergsma.png',
"https://www.linkedin.com/in/rrpbergsma", url: Astro.url.origin,
"https://github.com/rrpbergsma" sameAs: ['https://www.linkedin.com/in/rrpbergsma', 'https://github.com/rrpbergsma'],
], knowsAbout: t.skills.items.map((skill) => skill.title),
"knowsAbout": t.skills.items.map(skill => skill.title), worksFor: {
"worksFor": { '@type': 'Organization',
"@type": "Organization", name: 'COFRA Holding C.V.',
"name": "COFRA Holding C.V.", location: 'Amsterdam',
"location": "Amsterdam" },
} }}
}} /> />
<!-- Hero Widget --> <!-- Hero Widget -->
<Hero <Hero id="hero" isDark={false}>
id="hero"
isDark={false}
>
<Fragment slot="subtitle"> <Fragment slot="subtitle">
<strong class="text-3xl md:text-4xl">{t.hero.greeting}</strong><br /><br />{t.hero.subtitle} <strong class="text-3xl md:text-4xl">{t.hero.greeting}</strong><br /><br />{t.hero.subtitle}
</Fragment> </Fragment>
@@ -77,10 +75,14 @@ const metadata = {
> >
<Fragment slot="content"> <Fragment slot="content">
<h2 class="text-3xl font-bold tracking-tight sm:text-4xl mb-2">{t.about.title}</h2> <h2 class="text-3xl font-bold tracking-tight sm:text-4xl mb-2">{t.about.title}</h2>
{t.about.content.map((paragraph) => ( {
<p>{paragraph}</p> t.about.content.map((paragraph) => (
<br /> <>
))} <p>{paragraph}</p>
<br />
</>
))
}
</Fragment> </Fragment>
<Fragment slot="bg"> <Fragment slot="bg">
@@ -93,7 +95,7 @@ const metadata = {
id="resume" id="resume"
title={t.resume.title} title={t.resume.title}
compact={true} compact={true}
items={t.resume.experience.map(exp => ({ items={t.resume.experience.map((exp) => ({
title: exp.title, title: exp.title,
company: exp.company, company: exp.company,
date: exp.period, date: exp.period,
@@ -113,7 +115,7 @@ const metadata = {
issueDate: cert.issueDate, issueDate: cert.issueDate,
description: cert.description, description: cert.description,
linkUrl: cert.linkUrl, linkUrl: cert.linkUrl,
image: cert.image image: cert.image,
}))} }))}
/> />
@@ -123,7 +125,7 @@ const metadata = {
title={t.skills.title} title={t.skills.title}
subtitle={t.skills.subtitle} subtitle={t.skills.subtitle}
defaultIcon="tabler:point-filled" defaultIcon="tabler:point-filled"
items={t.skills.items.map(item => ({ items={t.skills.items.map((item) => ({
title: item.title, title: item.title,
description: item.description, description: item.description,
}))} }))}
@@ -133,9 +135,9 @@ const metadata = {
<CompactSteps <CompactSteps
id="education" id="education"
title={t.education.title} title={t.education.title}
items={t.education.items.map(item => ({ items={t.education.items.map((item) => ({
title: item.title, title: item.title,
icon: 'tabler:school' icon: 'tabler:school',
}))} }))}
/> />
</Layout> </Layout>

View File

@@ -11,7 +11,7 @@ import { getTranslation, supportedLanguages } from '~/i18n/translations';
import OurCommitmentImage from '~/assets/images/OurCommitment.webp'; import OurCommitmentImage from '~/assets/images/OurCommitment.webp';
export async function getStaticPaths() { export async function getStaticPaths() {
return supportedLanguages.map(lang => ({ return supportedLanguages.map((lang) => ({
params: { lang }, params: { lang },
})); }));
} }
@@ -52,72 +52,75 @@ const metadata = {
<!-- Features Widget --> <!-- Features Widget -->
<Features <Features
id="services" id="services"
tagline={t.homepage?.services?.tagline || "Services"} tagline={t.homepage?.services?.tagline || 'Services'}
title={t.homepage?.services?.title || "How I Can Help Your Organization"} title={t.homepage?.services?.title || 'How I Can Help Your Organization'}
subtitle={t.homepage?.services?.subtitle || "I offer a range of specialized IT services to help businesses optimize their operations and digital infrastructure."} subtitle={t.homepage?.services?.subtitle ||
items={(t.homepage?.services?.items || [ 'I offer a range of specialized IT services to help businesses optimize their operations and digital infrastructure.'}
{ items={(
title: 'Workflow Automation', t.homepage?.services?.items || [
description: {
'Streamline your business processes with Power Automate solutions that reduce manual effort and increase operational efficiency.', title: 'Workflow Automation',
icon: 'tabler:settings-automation', description:
}, 'Streamline your business processes with Power Automate solutions that reduce manual effort and increase operational efficiency.',
{ icon: 'tabler:settings-automation',
title: 'Intelligent Chatbots', },
description: {
'Develop smart chatbots in Copilot Studio that enhance user interactions through natural language processing and automated responses.', title: 'Intelligent Chatbots',
icon: 'tabler:message-chatbot', description:
}, 'Develop smart chatbots in Copilot Studio that enhance user interactions through natural language processing and automated responses.',
{ icon: 'tabler:message-chatbot',
title: 'API Integrations', },
description: {
'Create seamless connections between your applications and services with custom API integrations for efficient data exchange.', title: 'API Integrations',
icon: 'tabler:api', description:
}, 'Create seamless connections between your applications and services with custom API integrations for efficient data exchange.',
{ icon: 'tabler:api',
title: 'Microsoft 365 Management', },
description: {
'Optimize your Microsoft 365 environment with expert administration, security configurations, and service optimization.', title: 'Microsoft 365 Management',
icon: 'tabler:brand-office', description:
}, 'Optimize your Microsoft 365 environment with expert administration, security configurations, and service optimization.',
{ icon: 'tabler:brand-office',
title: 'SharePoint Solutions', },
description: {
'Set up, manage, and optimize SharePoint Online and on-premise deployments for effective document management and collaboration.', title: 'SharePoint Solutions',
icon: 'tabler:share', description:
}, 'Set up, manage, and optimize SharePoint Online and on-premise deployments for effective document management and collaboration.',
{ icon: 'tabler:share',
title: 'IT Infrastructure Oversight', },
description: {
'Manage global IT infrastructures, including servers, networks, and end-user devices to ensure reliable operations.', title: 'IT Infrastructure Oversight',
icon: 'tabler:server', description:
}, 'Manage global IT infrastructures, including servers, networks, and end-user devices to ensure reliable operations.',
]).map(item => ({...item, icon: item.icon || 'tabler:check'}))} icon: 'tabler:server',
},
]
).map((item) => ({ ...item, icon: item.icon || 'tabler:check' }))}
/> />
<!-- Content Widget --> <!-- Content Widget -->
<Content <Content
isReversed isReversed
tagline={t.homepage?.approach?.tagline || "About My Approach"} tagline={t.homepage?.approach?.tagline || 'About My Approach'}
title={t.homepage?.approach?.title || "Our Commitment"} title={t.homepage?.approach?.title || 'Our Commitment'}
items={[]} items={[]}
image={{ image={{
src: OurCommitmentImage, src: OurCommitmentImage,
alt: 'IT Excellence and Innovation' alt: 'IT Excellence and Innovation',
}} }}
> >
<Fragment slot="content"> <Fragment slot="content">
<div class="text-lg dark:text-slate-400"> <div class="text-lg dark:text-slate-400">
{(t.homepage?.approach?.missionContent || [ {
'We are committed to driving IT excellence through strategic cloud optimization, process automation, and enterprise-grade technical support. We leverage cutting-edge technology to address complex business challenges and deliver measurable value.', (
'With deep expertise in Microsoft technologies and automation, we empower organizations to transform their digital capabilities and achieve their business objectives. We design solutions that enhance user experience and maximize productivity, ensuring technology empowers your business.', t.homepage?.approach?.missionContent || [
'We stay ahead of the curve by researching and implementing emerging technologies, providing scalable solutions that adapt to your evolving needs. Our approach aligns technical solutions with your core business objectives, delivering measurable ROI and a competitive advantage.', 'We are committed to driving IT excellence through strategic cloud optimization, process automation, and enterprise-grade technical support. We leverage cutting-edge technology to address complex business challenges and deliver measurable value.',
'Our mission is to leverage technology to solve real business challenges and create value through innovation. With over 15 years of IT experience, we bring a wealth of knowledge in Microsoft technologies, automation tools, and system integration to help organizations transform their digital capabilities and achieve their strategic goals.' 'With deep expertise in Microsoft technologies and automation, we empower organizations to transform their digital capabilities and achieve their business objectives. We design solutions that enhance user experience and maximize productivity, ensuring technology empowers your business.',
]).map((paragraph, index, array) => ( 'We stay ahead of the curve by researching and implementing emerging technologies, providing scalable solutions that adapt to your evolving needs. Our approach aligns technical solutions with your core business objectives, delivering measurable ROI and a competitive advantage.',
<p class={index === array.length - 1 ? '' : 'mb-4'}> 'Our mission is to leverage technology to solve real business challenges and create value through innovation. With over 15 years of IT experience, we bring a wealth of knowledge in Microsoft technologies, automation tools, and system integration to help organizations transform their digital capabilities and achieve their strategic goals.',
{paragraph} ]
</p> ).map((paragraph, index, array) => <p class={index === array.length - 1 ? '' : 'mb-4'}>{paragraph}</p>)
))} }
</div> </div>
</Fragment> </Fragment>
</Content> </Content>
@@ -164,15 +167,19 @@ const metadata = {
> >
<Fragment slot="title">{t.homepage?.callToAction?.title || 'Ready to optimize your IT systems?'}</Fragment> <Fragment slot="title">{t.homepage?.callToAction?.title || 'Ready to optimize your IT systems?'}</Fragment>
<Fragment slot="subtitle"> <Fragment slot="subtitle">
{t.homepage?.callToAction?.subtitle || 'Let\'s discuss how I can help your organization streamline processes, enhance collaboration, and drive digital transformation.'} {
t.homepage?.callToAction?.subtitle ||
"Let's discuss how I can help your organization streamline processes, enhance collaboration, and drive digital transformation."
}
</Fragment> </Fragment>
</CallToAction> </CallToAction>
<!-- Contact Widget --> <!-- Contact Widget -->
<Contact <Contact
id="contact" id="contact"
title={t.homepage?.contact?.title || "Get in Touch"} title={t.homepage?.contact?.title || 'Get in Touch'}
subtitle={t.homepage?.contact?.subtitle || "Have a project in mind or questions about my services? Reach out and let's start a conversation."} subtitle={t.homepage?.contact?.subtitle ||
"Have a project in mind or questions about my services? Reach out and let's start a conversation."}
inputs={[ inputs={[
{ {
type: 'text', type: 'text',
@@ -193,11 +200,11 @@ const metadata = {
rows: 8, rows: 8,
}} }}
disclaimer={{ disclaimer={{
label: t.homepage?.contact?.disclaimer || label:
t.homepage?.contact?.disclaimer ||
'By submitting this form, you agree to our privacy policy and allow us to use your information to contact you about our services.', 'By submitting this form, you agree to our privacy policy and allow us to use your information to contact you about our services.',
}} }}
description={t.homepage?.contact?.description || "I'll respond to your message as soon as possible. You can also connect with me on LinkedIn or GitHub."} description={t.homepage?.contact?.description ||
"I'll respond to your message as soon as possible. You can also connect with me on LinkedIn or GitHub."}
/> />
</Layout>
</Layout>

View File

@@ -7,7 +7,7 @@ import Hero from '~/components/widgets/Hero.astro';
import { getTranslation, supportedLanguages } from '~/i18n/translations'; import { getTranslation, supportedLanguages } from '~/i18n/translations';
export async function getStaticPaths() { export async function getStaticPaths() {
return supportedLanguages.map(lang => ({ return supportedLanguages.map((lang) => ({
params: { lang }, params: { lang },
})); }));
} }
@@ -41,31 +41,28 @@ const tocItems = [
<Layout metadata={metadata}> <Layout metadata={metadata}>
<Fragment slot="announcement"></Fragment> <Fragment slot="announcement"></Fragment>
<!-- Legal Document Structured Data for SEO --> <!-- Legal Document Structured Data for SEO -->
<StructuredData slot="structured-data" data={{ <StructuredData
"@context": "https://schema.org", slot="structured-data"
"@type": "WebPage", data={{
"name": "Privacy Policy", '@context': 'https://schema.org',
"description": "Privacy Policy outlining our data collection practices, cookie usage, and your rights under GDPR.", '@type': 'WebPage',
"url": Astro.url.origin + "/" + lang + "/privacy", name: 'Privacy Policy',
"mainEntity": { description: 'Privacy Policy outlining our data collection practices, cookie usage, and your rights under GDPR.',
"@type": "Article", url: Astro.url.origin + '/' + lang + '/privacy',
"headline": "Privacy Policy", mainEntity: {
"datePublished": "2025-03-06", '@type': 'Article',
"dateModified": "2025-03-06" headline: 'Privacy Policy',
} datePublished: '2025-03-06',
}} /> dateModified: '2025-03-06',
},
}}
/>
<!-- Hero Widget --> <!-- Hero Widget -->
<Hero <Hero id="hero" title={t.footer.privacyPolicy} isDark={false}>
id="hero" <Fragment slot="subtitle"> Last updated: March 6, 2025 (Added cookie consent banner) </Fragment>
title={t.footer.privacyPolicy}
isDark={false}
>
<Fragment slot="subtitle">
Last updated: March 6, 2025 (Added cookie consent banner)
</Fragment>
</Hero> </Hero>
<!-- Content Widget --> <!-- Content Widget -->
@@ -74,37 +71,48 @@ const tocItems = [
<div class="bg-gray-50 dark:bg-slate-800 p-5 rounded-lg mb-10"> <div class="bg-gray-50 dark:bg-slate-800 p-5 rounded-lg mb-10">
<h2 class="text-xl font-bold mb-3">Table of Contents</h2> <h2 class="text-xl font-bold mb-3">Table of Contents</h2>
<ul class="space-y-2"> <ul class="space-y-2">
{tocItems.map(item => ( {
<li> tocItems.map((item) => (
<a href={`#${item.id}`} class="text-blue-600 dark:text-blue-400 hover:underline"> <li>
{item.title} <a href={`#${item.id}`} class="text-blue-600 dark:text-blue-400 hover:underline">
</a> {item.title}
</li> </a>
))} </li>
))
}
</ul> </ul>
</div> </div>
<!-- Privacy Policy Content --> <!-- Privacy Policy Content -->
<div class="prose prose-lg max-w-4xl dark:prose-invert dark:prose-headings:text-slate-300 prose-md prose-headings:font-heading prose-headings:leading-tighter prose-headings:tracking-tighter prose-headings:font-bold prose-a:text-blue-600 dark:prose-a:text-blue-400 prose-img:rounded-md prose-img:shadow-lg backdrop-blur-sm bg-white/15 dark:bg-slate-900/30 p-6 rounded-lg border border-gray-200 dark:border-slate-800"> <div
class="prose prose-lg max-w-4xl dark:prose-invert dark:prose-headings:text-slate-300 prose-md prose-headings:font-heading prose-headings:leading-tighter prose-headings:tracking-tighter prose-headings:font-bold prose-a:text-blue-600 dark:prose-a:text-blue-400 prose-img:rounded-md prose-img:shadow-lg backdrop-blur-sm bg-white/15 dark:bg-slate-900/30 p-6 rounded-lg border border-gray-200 dark:border-slate-800"
>
<h2 id="introduction" class="text-2xl font-bold mt-8 mb-4">1. Introduction</h2> <h2 id="introduction" class="text-2xl font-bold mt-8 mb-4">1. Introduction</h2>
<p> <p>
This Privacy Policy explains how we handle information when you visit our website. We are committed to protecting your privacy and complying with applicable data protection laws, including the General Data Protection Regulation (GDPR). This Privacy Policy explains how we handle information when you visit our website. We are committed to
protecting your privacy and complying with applicable data protection laws, including the General Data
Protection Regulation (GDPR).
</p> </p>
<p> <p>
We value transparency and want you to understand what information we collect, why we collect it, and how we use it. This policy applies to all visitors to our website. We value transparency and want you to understand what information we collect, why we collect it, and how we use
it. This policy applies to all visitors to our website.
</p> </p>
<h2 id="data-collection" class="text-2xl font-bold mt-8 mb-4">2. Data Collection Policy</h2> <h2 id="data-collection" class="text-2xl font-bold mt-8 mb-4">2. Data Collection Policy</h2>
<p> <p>
<strong>We do not collect or store any personal user data.</strong> Our website is designed to provide information without requiring you to submit any personal information. <strong>We do not collect or store any personal user data.</strong> Our website is designed to provide information
without requiring you to submit any personal information.
</p> </p>
<p> <p>
The only data stored is your preferences for language and theme settings, which are stored locally on your device using browser technologies (cookies and LocalStorage) and are never transmitted to our servers. More details about this are provided in the sections below. The only data stored is your preferences for language and theme settings, which are stored locally on your
</p> device using browser technologies (cookies and LocalStorage) and are never transmitted to our servers. More
<p> details about this are provided in the sections below.
We do not:
</p> </p>
<p>We do not:</p>
<ul> <ul>
<li>Collect your name, email address, or other contact information unless you voluntarily provide it through our contact form</li> <li>
Collect your name, email address, or other contact information unless you voluntarily provide it through our
contact form
</li>
<li>Track your browsing behavior</li> <li>Track your browsing behavior</li>
<li>Use analytics services that collect personal data</li> <li>Use analytics services that collect personal data</li>
<li>Use advertising or marketing tracking technologies</li> <li>Use advertising or marketing tracking technologies</li>
@@ -112,15 +120,16 @@ const tocItems = [
<li>Store your preferences on our servers</li> <li>Store your preferences on our servers</li>
</ul> </ul>
<p> <p>
If you choose to contact us using our contact form, the information you provide (such as your name and email address) will only be used to respond to your inquiry and will not be stored longer than necessary for that purpose. If you choose to contact us using our contact form, the information you provide (such as your name and email
address) will only be used to respond to your inquiry and will not be stored longer than necessary for that
purpose.
</p> </p>
<h2 id="cookie-usage" class="text-2xl font-bold mt-8 mb-4">3. Cookie & Storage Usage</h2> <h2 id="cookie-usage" class="text-2xl font-bold mt-8 mb-4">3. Cookie & Storage Usage</h2>
<p> <p>
<strong>Our website uses cookies strictly for essential functionality.</strong> These cookies are necessary for the proper functioning of our website and do not collect any personal information. <strong>Our website uses cookies strictly for essential functionality.</strong> These cookies are necessary for the
</p> proper functioning of our website and do not collect any personal information.
<p>
Details about the cookies we use:
</p> </p>
<p>Details about the cookies we use:</p>
<ul> <ul>
<li> <li>
<strong>Name:</strong> preferredLanguage <strong>Name:</strong> preferredLanguage
@@ -142,20 +151,23 @@ const tocItems = [
</li> </li>
</ul> </ul>
<p> <p>
In addition to cookies, we also use LocalStorage to store certain preferences. Details about this are provided in the next section. In addition to cookies, we also use LocalStorage to store certain preferences. Details about this are provided
in the next section.
</p> </p>
<p> <p>
We do not use any tracking, analytics, or third-party cookies. No personal information is collected through our cookies or LocalStorage. We do not use any tracking, analytics, or third-party cookies. No personal information is collected through our
cookies or LocalStorage.
</p> </p>
<h2 id="localstorage" class="text-2xl font-bold mt-8 mb-4">4. LocalStorage Usage</h2> <h2 id="localstorage" class="text-2xl font-bold mt-8 mb-4">4. LocalStorage Usage</h2>
<p> <p>
<strong>Our website uses LocalStorage to enhance your experience by remembering your preferences and consent choices.</strong> <strong
</p> >Our website uses LocalStorage to enhance your experience by remembering your preferences and consent choices.</strong
<p> >
Details about our LocalStorage usage:
</p> </p>
<p>Details about our LocalStorage usage:</p>
<ul> <ul>
<li><strong>Data stored:</strong> <li>
<strong>Data stored:</strong>
<ul> <ul>
<li>Theme preference (light/dark mode)</li> <li>Theme preference (light/dark mode)</li>
<li>Cookie consent acceptance status</li> <li>Cookie consent acceptance status</li>
@@ -166,14 +178,18 @@ const tocItems = [
<li><strong>Duration:</strong> Persists until you clear your browser's LocalStorage</li> <li><strong>Duration:</strong> Persists until you clear your browser's LocalStorage</li>
</ul> </ul>
<p> <p>
LocalStorage is a technology that allows websites to store data directly in your browser. Unlike cookies, LocalStorage data is not sent with every request to the server, which makes it more efficient for storing user preferences that only need to be accessed by your browser. LocalStorage is a technology that allows websites to store data directly in your browser. Unlike cookies,
LocalStorage data is not sent with every request to the server, which makes it more efficient for storing user
preferences that only need to be accessed by your browser.
</p> </p>
<p> <p>
No personal information is collected or stored in LocalStorage. The data is used solely to enhance your browsing experience by maintaining your preferred settings. No personal information is collected or stored in LocalStorage. The data is used solely to enhance your browsing
experience by maintaining your preferred settings.
</p> </p>
<h2 id="clear-preferences" class="text-2xl font-bold mt-8 mb-4">5. How to Clear Your Preferences</h2> <h2 id="clear-preferences" class="text-2xl font-bold mt-8 mb-4">5. How to Clear Your Preferences</h2>
<p> <p>
If you wish to reset your language or theme settings, you can clear your browser's cookies and LocalStorage data. Here's how to do it in common browsers: If you wish to reset your language or theme settings, you can clear your browser's cookies and LocalStorage
data. Here's how to do it in common browsers:
</p> </p>
<p> <p>
<strong>Chrome:</strong> <strong>Chrome:</strong>
@@ -208,44 +224,59 @@ const tocItems = [
<li>Find our website and click "Remove" or "Remove All"</li> <li>Find our website and click "Remove" or "Remove All"</li>
</ol> </ol>
<p> <p>
After clearing your browser data, your language will reset to the default (English) and your theme will reset to the system default. After clearing your browser data, your language will reset to the default (English) and your theme will reset to
the system default.
</p> </p>
<h2 id="user-rights" class="text-2xl font-bold mt-8 mb-4">6. Your Rights (GDPR Compliance)</h2> <h2 id="user-rights" class="text-2xl font-bold mt-8 mb-4">6. Your Rights (GDPR Compliance)</h2>
<p> <p>
Under the General Data Protection Regulation (GDPR), you have various rights regarding your personal data. However, since we do not collect or store personal data (except for the language preference cookie which does not contain personal information), most of these rights are not applicable in practice. Under the General Data Protection Regulation (GDPR), you have various rights regarding your personal data.
</p> However, since we do not collect or store personal data (except for the language preference cookie which does
<p> not contain personal information), most of these rights are not applicable in practice.
Nevertheless, you have the right to:
</p> </p>
<p>Nevertheless, you have the right to:</p>
<ul> <ul>
<li><strong>Delete your cookie and LocalStorage data:</strong> You can delete the language preference cookie and theme preference LocalStorage data at any time through your browser settings (see section 5 for instructions)</li> <li>
<li><strong>Be informed:</strong> This privacy policy provides transparent information about our data practices</li> <strong>Delete your cookie and LocalStorage data:</strong> You can delete the language preference cookie and theme
preference LocalStorage data at any time through your browser settings (see section 5 for instructions)
</li>
<li>
<strong>Be informed:</strong> This privacy policy provides transparent information about our data practices
</li>
<li><strong>Object:</strong> You can choose to disable cookies and LocalStorage in your browser settings</li> <li><strong>Object:</strong> You can choose to disable cookies and LocalStorage in your browser settings</li>
</ul> </ul>
<p> <p>
If you have any questions about your rights or wish to exercise any of them, please contact us using the information provided at the end of this policy. If you have any questions about your rights or wish to exercise any of them, please contact us using the
information provided at the end of this policy.
</p> </p>
<h2 id="data-security" class="text-2xl font-bold mt-8 mb-4">7. Data Security</h2> <h2 id="data-security" class="text-2xl font-bold mt-8 mb-4">7. Data Security</h2>
<p> <p>
We take appropriate technical and organizational measures to ensure the security of any information transmitted to us. However, please be aware that no method of transmission over the internet or method of electronic storage is 100% secure. We take appropriate technical and organizational measures to ensure the security of any information transmitted
to us. However, please be aware that no method of transmission over the internet or method of electronic storage
is 100% secure.
</p> </p>
<p> <p>
Our website uses HTTPS encryption to ensure that any communication between your browser and our website is secure. Our website uses HTTPS encryption to ensure that any communication between your browser and our website is
secure.
</p> </p>
<h2 id="third-party" class="text-2xl font-bold mt-8 mb-4">8. Third-Party Websites</h2> <h2 id="third-party" class="text-2xl font-bold mt-8 mb-4">8. Third-Party Websites</h2>
<p> <p>
Our website may contain links to other websites that are not operated by us. If you click on a third-party link, you will be directed to that third party's site. We strongly advise you to review the Privacy Policy of every site you visit. Our website may contain links to other websites that are not operated by us. If you click on a third-party link,
you will be directed to that third party's site. We strongly advise you to review the Privacy Policy of every
site you visit.
</p> </p>
<p> <p>
We have no control over and assume no responsibility for the content, privacy policies, or practices of any third-party sites or services. We have no control over and assume no responsibility for the content, privacy policies, or practices of any
third-party sites or services.
</p> </p>
<h2 id="changes" class="text-2xl font-bold mt-8 mb-4">9. Changes to Privacy Policy</h2> <h2 id="changes" class="text-2xl font-bold mt-8 mb-4">9. Changes to Privacy Policy</h2>
<p> <p>
We may update our Privacy Policy from time to time. We will notify you of any changes by posting the new Privacy Policy on this page and updating the "Last updated" date at the top of this page. We may update our Privacy Policy from time to time. We will notify you of any changes by posting the new Privacy
Policy on this page and updating the "Last updated" date at the top of this page.
</p> </p>
<p> <p>
You are advised to review this Privacy Policy periodically for any changes. Changes to this Privacy Policy are effective when they are posted on this page. You are advised to review this Privacy Policy periodically for any changes. Changes to this Privacy Policy are
</p> effective when they are posted on this page.
</p>
</div> </div>
</div> </div>
</Layout> </Layout>

View File

@@ -7,7 +7,7 @@ import Hero from '~/components/widgets/Hero.astro';
import { getTranslation, supportedLanguages } from '~/i18n/translations'; import { getTranslation, supportedLanguages } from '~/i18n/translations';
export async function getStaticPaths() { export async function getStaticPaths() {
return supportedLanguages.map(lang => ({ return supportedLanguages.map((lang) => ({
params: { lang }, params: { lang },
})); }));
} }
@@ -39,31 +39,29 @@ const tocItems = [
<Layout metadata={metadata}> <Layout metadata={metadata}>
<Fragment slot="announcement"></Fragment> <Fragment slot="announcement"></Fragment>
<!-- Legal Document Structured Data for SEO --> <!-- Legal Document Structured Data for SEO -->
<StructuredData slot="structured-data" data={{ <StructuredData
"@context": "https://schema.org", slot="structured-data"
"@type": "WebPage", data={{
"name": "Terms and Conditions", '@context': 'https://schema.org',
"description": "Terms and Conditions for our website, outlining user rights, responsibilities, and legal information.", '@type': 'WebPage',
"url": Astro.url.origin + "/" + lang + "/terms", name: 'Terms and Conditions',
"mainEntity": { description:
"@type": "Article", 'Terms and Conditions for our website, outlining user rights, responsibilities, and legal information.',
"headline": "Terms and Conditions", url: Astro.url.origin + '/' + lang + '/terms',
"datePublished": "2025-03-06", mainEntity: {
"dateModified": "2025-03-06" '@type': 'Article',
} headline: 'Terms and Conditions',
}} /> datePublished: '2025-03-06',
dateModified: '2025-03-06',
},
}}
/>
<!-- Hero Widget --> <!-- Hero Widget -->
<Hero <Hero id="hero" title={t.footer.terms} isDark={false}>
id="hero" <Fragment slot="subtitle"> Last updated: March 6, 2025 </Fragment>
title={t.footer.terms}
isDark={false}
>
<Fragment slot="subtitle">
Last updated: March 6, 2025
</Fragment>
</Hero> </Hero>
<!-- Content Widget --> <!-- Content Widget -->
@@ -72,75 +70,103 @@ const tocItems = [
<div class="bg-gray-50 dark:bg-slate-800 p-5 rounded-lg mb-10"> <div class="bg-gray-50 dark:bg-slate-800 p-5 rounded-lg mb-10">
<h2 class="text-xl font-bold mb-3">Table of Contents</h2> <h2 class="text-xl font-bold mb-3">Table of Contents</h2>
<ul class="space-y-2"> <ul class="space-y-2">
{tocItems.map(item => ( {
<li> tocItems.map((item) => (
<a href={`#${item.id}`} class="text-blue-600 dark:text-blue-400 hover:underline"> <li>
{item.title} <a href={`#${item.id}`} class="text-blue-600 dark:text-blue-400 hover:underline">
</a> {item.title}
</li> </a>
))} </li>
))
}
</ul> </ul>
</div> </div>
<!-- Terms Content --> <!-- Terms Content -->
<div class="prose prose-lg max-w-4xl dark:prose-invert dark:prose-headings:text-slate-300 prose-md prose-headings:font-heading prose-headings:leading-tighter prose-headings:tracking-tighter prose-headings:font-bold prose-a:text-blue-600 dark:prose-a:text-blue-400 prose-img:rounded-md prose-img:shadow-lg backdrop-blur-sm bg-white/15 dark:bg-slate-900/30 p-6 rounded-lg border border-gray-200 dark:border-slate-800"> <div
class="prose prose-lg max-w-4xl dark:prose-invert dark:prose-headings:text-slate-300 prose-md prose-headings:font-heading prose-headings:leading-tighter prose-headings:tracking-tighter prose-headings:font-bold prose-a:text-blue-600 dark:prose-a:text-blue-400 prose-img:rounded-md prose-img:shadow-lg backdrop-blur-sm bg-white/15 dark:bg-slate-900/30 p-6 rounded-lg border border-gray-200 dark:border-slate-800"
>
<p> <p>
Please read these terms and conditions carefully before using our website. By accessing or using our website, you agree to be bound by these terms and conditions. Please read these terms and conditions carefully before using our website. By accessing or using our website,
you agree to be bound by these terms and conditions.
</p> </p>
<h2 id="scope" class="text-2xl font-bold mt-8 mb-4">1. Scope of Services</h2> <h2 id="scope" class="text-2xl font-bold mt-8 mb-4">1. Scope of Services</h2>
<p> <p>
Our website provides information about our professional services, expertise, and industry insights. The content on this website is for general informational purposes only and does not constitute professional advice. We may update, modify, or remove content at any time without notice. Our website provides information about our professional services, expertise, and industry insights. The content
on this website is for general informational purposes only and does not constitute professional advice. We may
update, modify, or remove content at any time without notice.
</p> </p>
<h2 id="user-rights" class="text-2xl font-bold mt-8 mb-4">2. User Rights & Responsibilities</h2> <h2 id="user-rights" class="text-2xl font-bold mt-8 mb-4">2. User Rights & Responsibilities</h2>
<p> <p>When using our website, you agree to:</p>
When using our website, you agree to:
</p>
<ul> <ul>
<li>Use the website in accordance with these terms and conditions and all applicable laws and regulations</li> <li>Use the website in accordance with these terms and conditions and all applicable laws and regulations</li>
<li>Not use the website in any way that could damage, disable, overburden, or impair our services</li> <li>Not use the website in any way that could damage, disable, overburden, or impair our services</li>
<li>Not attempt to gain unauthorized access to any part of the website or any system or network connected to the website</li> <li>
Not attempt to gain unauthorized access to any part of the website or any system or network connected to the
website
</li>
<li>Not use any automated means to access or collect data from the website</li> <li>Not use any automated means to access or collect data from the website</li>
<li>Not use the website to transmit any harmful code or material</li> <li>Not use the website to transmit any harmful code or material</li>
</ul> </ul>
<h2 id="intellectual-property" class="text-2xl font-bold mt-8 mb-4">3. Intellectual Property</h2> <h2 id="intellectual-property" class="text-2xl font-bold mt-8 mb-4">3. Intellectual Property</h2>
<p> <p>
All content on this website, including but not limited to text, graphics, logos, images, audio clips, digital downloads, and data compilations, is the property of the website owner or its content suppliers and is protected by Dutch and international copyright laws. All content on this website, including but not limited to text, graphics, logos, images, audio clips, digital
downloads, and data compilations, is the property of the website owner or its content suppliers and is protected
by Dutch and international copyright laws.
</p> </p>
<p> <p>
You may view, download, and print content from this website for your personal, non-commercial use, provided that you do not modify the content and that you retain all copyright and other proprietary notices. You may view, download, and print content from this website for your personal, non-commercial use, provided that
you do not modify the content and that you retain all copyright and other proprietary notices.
</p> </p>
<h2 id="liability" class="text-2xl font-bold mt-8 mb-4">4. Limitation of Liability</h2> <h2 id="liability" class="text-2xl font-bold mt-8 mb-4">4. Limitation of Liability</h2>
<p> <p>
To the fullest extent permitted by applicable law, we exclude all representations, warranties, and conditions relating to our website and the use of this website. We will not be liable for any direct, indirect, or consequential loss or damage arising under these terms and conditions or in connection with our website, whether arising in tort, contract, or otherwise, including, without limitation, any loss of profit, contracts, business, goodwill, data, income, revenue, or anticipated savings. To the fullest extent permitted by applicable law, we exclude all representations, warranties, and conditions
relating to our website and the use of this website. We will not be liable for any direct, indirect, or
consequential loss or damage arising under these terms and conditions or in connection with our website, whether
arising in tort, contract, or otherwise, including, without limitation, any loss of profit, contracts, business,
goodwill, data, income, revenue, or anticipated savings.
</p> </p>
<p> <p>
This does not exclude or limit our liability for death or personal injury resulting from our negligence, nor our liability for fraudulent misrepresentation or misrepresentation as to a fundamental matter, nor any other liability which cannot be excluded or limited under applicable law. This does not exclude or limit our liability for death or personal injury resulting from our negligence, nor our
liability for fraudulent misrepresentation or misrepresentation as to a fundamental matter, nor any other
liability which cannot be excluded or limited under applicable law.
</p> </p>
<h2 id="governing-law" class="text-2xl font-bold mt-8 mb-4">5. Governing Law</h2> <h2 id="governing-law" class="text-2xl font-bold mt-8 mb-4">5. Governing Law</h2>
<p> <p>
These terms and conditions are governed by and construed in accordance with the laws of the Netherlands. Any disputes relating to these terms and conditions shall be subject to the exclusive jurisdiction of the courts of the Netherlands. These terms and conditions are governed by and construed in accordance with the laws of the Netherlands. Any
disputes relating to these terms and conditions shall be subject to the exclusive jurisdiction of the courts of
the Netherlands.
</p> </p>
<p> <p>
If you are a consumer, you will benefit from any mandatory provisions of the law of the country in which you are resident. Nothing in these terms and conditions affects your rights as a consumer to rely on such mandatory provisions of local law. If you are a consumer, you will benefit from any mandatory provisions of the law of the country in which you are
resident. Nothing in these terms and conditions affects your rights as a consumer to rely on such mandatory
provisions of local law.
</p> </p>
<h2 id="cookies" class="text-2xl font-bold mt-8 mb-4">6. Cookie Usage</h2> <h2 id="cookies" class="text-2xl font-bold mt-8 mb-4">6. Cookie Usage</h2>
<p> <p>
Our website uses only one cookie, which is used exclusively for storing your selected language preference. This cookie is essential for the proper functioning of the language selection feature on our website. We do not use any tracking, analytics, or third-party cookies. Our website uses only one cookie, which is used exclusively for storing your selected language preference. This
cookie is essential for the proper functioning of the language selection feature on our website. We do not use
any tracking, analytics, or third-party cookies.
</p> </p>
<p> <p>
The language preference cookie stores only your selected language choice and does not collect any personal information. This cookie is stored on your device for a period of 365 days, after which it will expire unless you visit our website again. The language preference cookie stores only your selected language choice and does not collect any personal
information. This cookie is stored on your device for a period of 365 days, after which it will expire unless
you visit our website again.
</p> </p>
<h2 id="changes" class="text-2xl font-bold mt-8 mb-4">7. Changes to Terms</h2> <h2 id="changes" class="text-2xl font-bold mt-8 mb-4">7. Changes to Terms</h2>
<p> <p>
We may revise these terms and conditions at any time by amending this page. You are expected to check this page from time to time to take notice of any changes we make, as they are legally binding on you. Some of the provisions contained in these terms and conditions may also be superseded by provisions or notices published elsewhere on our website. We may revise these terms and conditions at any time by amending this page. You are expected to check this page
from time to time to take notice of any changes we make, as they are legally binding on you. Some of the
provisions contained in these terms and conditions may also be superseded by provisions or notices published
elsewhere on our website.
</p> </p>
</div> </div>
</div> </div>
</Layout> </Layout>

View File

@@ -4,24 +4,25 @@ import { supportedLanguages } from '~/i18n/translations';
// Check for language preference in cookies (set by client-side JS) // Check for language preference in cookies (set by client-side JS)
const cookies = Astro.request.headers.get('cookie') || ''; const cookies = Astro.request.headers.get('cookie') || '';
const cookieLanguage = cookies.split(';') const cookieLanguage = cookies
.map(cookie => cookie.trim()) .split(';')
.find(cookie => cookie.startsWith('preferredLanguage=')) .map((cookie) => cookie.trim())
.find((cookie) => cookie.startsWith('preferredLanguage='))
?.split('=')[1]; ?.split('=')[1];
// Get the user's preferred language from the browser if no cookie // Get the user's preferred language from the browser if no cookie
const acceptLanguage = Astro.request.headers.get('accept-language') || ''; const acceptLanguage = Astro.request.headers.get('accept-language') || '';
// Define the type for supported languages // Define the type for supported languages
type SupportedLanguage = typeof supportedLanguages[number]; type SupportedLanguage = (typeof supportedLanguages)[number];
// Use cookie language if available, otherwise detect from browser // Use cookie language if available, otherwise detect from browser
const preferredLanguage = const preferredLanguage =
(cookieLanguage && supportedLanguages.includes(cookieLanguage as SupportedLanguage)) cookieLanguage && supportedLanguages.includes(cookieLanguage as SupportedLanguage)
? cookieLanguage ? cookieLanguage
: acceptLanguage : acceptLanguage
.split(',') .split(',')
.map(lang => lang.split(';')[0].trim().substring(0, 2)) .map((lang) => lang.split(';')[0].trim().substring(0, 2))
.find(lang => supportedLanguages.includes(lang as SupportedLanguage)) || 'en'; .find((lang) => supportedLanguages.includes(lang as SupportedLanguage)) || 'en';
// Get the hash fragment if present // Get the hash fragment if present
const url = new URL(Astro.request.url); const url = new URL(Astro.request.url);
@@ -29,4 +30,4 @@ const hash = url.hash;
// Redirect to the language-specific about me page // Redirect to the language-specific about me page
return Astro.redirect(`/${preferredLanguage}/aboutme${hash}`); return Astro.redirect(`/${preferredLanguage}/aboutme${hash}`);
--- ---

View File

@@ -4,7 +4,7 @@ import {
validateCsrfToken, validateCsrfToken,
checkRateLimit, checkRateLimit,
sendAdminNotification, sendAdminNotification,
sendUserConfirmation sendUserConfirmation,
} from '../../utils/email-handler'; } from '../../utils/email-handler';
// Enhanced email validation with more comprehensive regex // Enhanced email validation with more comprehensive regex
@@ -20,52 +20,67 @@ const isSpam = (content: string, name: string, email: string): boolean => {
const lowerContent = content.toLowerCase(); const lowerContent = content.toLowerCase();
const lowerName = name.toLowerCase(); const lowerName = name.toLowerCase();
const lowerEmail = email.toLowerCase(); const lowerEmail = email.toLowerCase();
// Common spam keywords // Common spam keywords
const spamPatterns = [ const spamPatterns = [
'viagra', 'cialis', 'casino', 'lottery', 'prize', 'winner', 'viagra',
'free money', 'buy now', 'click here', 'earn money', 'make money', 'cialis',
'investment opportunity', 'bitcoin', 'cryptocurrency', 'forex', 'casino',
'weight loss', 'diet pill', 'enlargement', 'cheap medication' 'lottery',
'prize',
'winner',
'free money',
'buy now',
'click here',
'earn money',
'make money',
'investment opportunity',
'bitcoin',
'cryptocurrency',
'forex',
'weight loss',
'diet pill',
'enlargement',
'cheap medication',
]; ];
// Check for spam keywords in content // Check for spam keywords in content
if (spamPatterns.some(pattern => lowerContent.includes(pattern))) { if (spamPatterns.some((pattern) => lowerContent.includes(pattern))) {
return true; return true;
} }
// Check for spam keywords in name or email // Check for spam keywords in name or email
if (spamPatterns.some(pattern => lowerName.includes(pattern) || lowerEmail.includes(pattern))) { if (spamPatterns.some((pattern) => lowerName.includes(pattern) || lowerEmail.includes(pattern))) {
return true; return true;
} }
// Check for excessive capitalization (shouting) // Check for excessive capitalization (shouting)
const uppercaseRatio = (content.match(/[A-Z]/g) || []).length / content.length; const uppercaseRatio = (content.match(/[A-Z]/g) || []).length / content.length;
if (uppercaseRatio > 0.5 && content.length > 20) { if (uppercaseRatio > 0.5 && content.length > 20) {
return true; return true;
} }
// Check for excessive special characters // Check for excessive special characters
const specialChars = "!@#$%^&*()_+-=[]{}\\|;:'\",.<>/?"; const specialChars = '!@#$%^&*()_+-=[]{}\\|;:\'",.<>/?';
let specialCharCount = 0; let specialCharCount = 0;
for (let i = 0; i < content.length; i++) { for (let i = 0; i < content.length; i++) {
if (specialChars.includes(content[i])) { if (specialChars.includes(content[i])) {
specialCharCount++; specialCharCount++;
} }
} }
const specialCharRatio = specialCharCount / content.length; const specialCharRatio = specialCharCount / content.length;
if (specialCharRatio > 0.3 && content.length > 20) { if (specialCharRatio > 0.3 && content.length > 20) {
return true; return true;
} }
// Check for excessive URLs - count http:// and https:// occurrences // Check for excessive URLs - count http:// and https:// occurrences
const urlCount = content.split('http').length - 1; const urlCount = content.split('http').length - 1;
if (urlCount > 2) { if (urlCount > 2) {
return true; return true;
} }
return false; return false;
}; };
@@ -73,34 +88,34 @@ const isSpam = (content: string, name: string, email: string): boolean => {
export const GET: APIRoute = async ({ request }) => { export const GET: APIRoute = async ({ request }) => {
const url = new URL(request.url); const url = new URL(request.url);
const csrfRequested = url.searchParams.get('csrf') === 'true'; const csrfRequested = url.searchParams.get('csrf') === 'true';
if (csrfRequested) { if (csrfRequested) {
// Generate and return a CSRF token // Generate and return a CSRF token
const csrfToken = generateCsrfToken(); const csrfToken = generateCsrfToken();
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
csrfToken csrfToken,
}), }),
{ {
status: 200, status: 200,
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json',
} },
} }
); );
} }
// Default response for GET requests // Default response for GET requests
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
message: 'Contact API endpoint is working. Please use POST to submit the form.' message: 'Contact API endpoint is working. Please use POST to submit the form.',
}), }),
{ {
status: 200, status: 200,
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json',
} },
} }
); );
}; };
@@ -108,158 +123,163 @@ export const GET: APIRoute = async ({ request }) => {
export const POST: APIRoute = async ({ request, clientAddress }) => { export const POST: APIRoute = async ({ request, clientAddress }) => {
try { try {
console.log('Contact form submission received'); console.log('Contact form submission received');
// Get client IP address for rate limiting // Get client IP address for rate limiting
const ipAddress = clientAddress || '0.0.0.0'; const ipAddress = clientAddress || '0.0.0.0';
console.log('Client IP:', ipAddress); console.log('Client IP:', ipAddress);
// Check rate limit // Check rate limit
const rateLimitCheck = await checkRateLimit(ipAddress); const rateLimitCheck = await checkRateLimit(ipAddress);
console.log('Rate limit check:', rateLimitCheck); console.log('Rate limit check:', rateLimitCheck);
if (rateLimitCheck.limited) { if (rateLimitCheck.limited) {
console.log('Rate limit exceeded'); console.log('Rate limit exceeded');
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
success: false, success: false,
errors: { errors: {
rateLimit: rateLimitCheck.message rateLimit: rateLimitCheck.message,
} },
}), }),
{ {
status: 429, status: 429,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Retry-After': '3600' 'Retry-After': '3600',
} },
} }
); );
} }
// Get form data // Get form data
const formData = await request.formData(); const formData = await request.formData();
console.log('Form data received'); console.log('Form data received');
// Log all form data keys // Log all form data keys
console.log('Form data keys:', [...formData.keys()]); console.log('Form data keys:', [...formData.keys()]);
const name = formData.get('name')?.toString() || ''; const name = formData.get('name')?.toString() || '';
const email = formData.get('email')?.toString() || ''; const email = formData.get('email')?.toString() || '';
const message = formData.get('message')?.toString() || ''; const message = formData.get('message')?.toString() || '';
const disclaimer = formData.get('disclaimer')?.toString() === 'on'; const disclaimer = formData.get('disclaimer')?.toString() === 'on';
const csrfToken = formData.get('csrf_token')?.toString() || ''; const csrfToken = formData.get('csrf_token')?.toString() || '';
console.log('Form data values:', { name, email, messageLength: message.length, disclaimer, csrfToken: csrfToken ? 'present' : 'missing' }); console.log('Form data values:', {
name,
email,
messageLength: message.length,
disclaimer,
csrfToken: csrfToken ? 'present' : 'missing',
});
// Get user agent for logging and spam detection // Get user agent for logging and spam detection
const userAgent = request.headers.get('user-agent') || 'Unknown'; const userAgent = request.headers.get('user-agent') || 'Unknown';
// Validate form data // Validate form data
const errors: Record<string, string> = {}; const errors: Record<string, string> = {};
// Validate CSRF token // Validate CSRF token
if (!validateCsrfToken(csrfToken)) { if (!validateCsrfToken(csrfToken)) {
errors.csrf = 'Invalid or expired security token. Please refresh the page and try again.'; errors.csrf = 'Invalid or expired security token. Please refresh the page and try again.';
} }
if (!name) { if (!name) {
errors.name = 'Please enter your name'; errors.name = 'Please enter your name';
} else if (name.length < 2) { } else if (name.length < 2) {
errors.name = 'Your name must be at least 2 characters long'; errors.name = 'Your name must be at least 2 characters long';
} }
if (!email) { if (!email) {
errors.email = 'Please enter your email address'; errors.email = 'Please enter your email address';
} else if (!isValidEmail(email)) { } else if (!isValidEmail(email)) {
errors.email = 'Please enter a valid email address (e.g., name@example.com)'; errors.email = 'Please enter a valid email address (e.g., name@example.com)';
} }
if (!message) { if (!message) {
errors.message = 'Please enter your message'; errors.message = 'Please enter your message';
} else if (message.length < 10) { } else if (message.length < 10) {
errors.message = 'Your message must be at least 10 characters long'; errors.message = 'Your message must be at least 10 characters long';
} }
if (!disclaimer) { if (!disclaimer) {
errors.disclaimer = 'Please check the required consent box before submitting'; errors.disclaimer = 'Please check the required consent box before submitting';
} }
// Check for spam // Check for spam
if (isSpam(message, name, email)) { if (isSpam(message, name, email)) {
errors.spam = 'Your message was flagged as potential spam. Please revise your message and try again.'; errors.spam = 'Your message was flagged as potential spam. Please revise your message and try again.';
} }
// If there are validation errors, return them // If there are validation errors, return them
if (Object.keys(errors).length > 0) { if (Object.keys(errors).length > 0) {
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
success: false, success: false,
errors errors,
}), }),
{ {
status: 400, status: 400,
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json',
} },
} }
); );
} }
// Send emails // Send emails
console.log('Attempting to send admin notification email'); console.log('Attempting to send admin notification email');
const adminEmailSent = await sendAdminNotification(name, email, message, ipAddress, userAgent); const adminEmailSent = await sendAdminNotification(name, email, message, ipAddress, userAgent);
console.log('Admin email sent result:', adminEmailSent); console.log('Admin email sent result:', adminEmailSent);
console.log('Attempting to send user confirmation email'); console.log('Attempting to send user confirmation email');
const userEmailSent = await sendUserConfirmation(name, email, message); const userEmailSent = await sendUserConfirmation(name, email, message);
console.log('User email sent result:', userEmailSent); console.log('User email sent result:', userEmailSent);
// Check if emails were sent successfully // Check if emails were sent successfully
if (!adminEmailSent || !userEmailSent) { if (!adminEmailSent || !userEmailSent) {
console.error('Failed to send one or more emails:', { adminEmailSent, userEmailSent }); console.error('Failed to send one or more emails:', { adminEmailSent, userEmailSent });
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
success: false, success: false,
message: 'There was an issue sending your message. Please try again later.' message: 'There was an issue sending your message. Please try again later.',
}), }),
{ {
status: 500, status: 500,
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json',
} },
} }
); );
} }
// Return success response // Return success response
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
success: true, success: true,
message: 'Your message has been sent successfully. We will get back to you soon!' message: 'Your message has been sent successfully. We will get back to you soon!',
}), }),
{ {
status: 200, status: 200,
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json',
} },
} }
); );
} catch (error) { } catch (error) {
console.error('Error processing contact form:', error); console.error('Error processing contact form:', error);
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
success: false, success: false,
message: 'An error occurred while processing your request. Please try again later.' message: 'An error occurred while processing your request. Please try again later.',
}), }),
{ {
status: 500, status: 500,
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json',
} },
} }
); );
} }
}; };

View File

@@ -1,32 +1,14 @@
--- ---
export const prerender = false; export const prerender = false;
import { supportedLanguages } from '~/i18n/translations'; import { detectPreferredLanguage } from '~/utils/language';
// Check for language preference in cookies (set by client-side JS)
const cookies = Astro.request.headers.get('cookie') || '';
const cookieLanguage = cookies.split(';')
.map(cookie => cookie.trim())
.find(cookie => cookie.startsWith('preferredLanguage='))
?.split('=')[1];
// Get the user's preferred language from the browser if no cookie
const acceptLanguage = Astro.request.headers.get('accept-language') || '';
// Define the type for supported languages
type SupportedLanguage = typeof supportedLanguages[number];
// Use cookie language if available, otherwise detect from browser
const preferredLanguage =
(cookieLanguage && supportedLanguages.includes(cookieLanguage as SupportedLanguage))
? cookieLanguage
: acceptLanguage
.split(',')
.map(lang => lang.split(';')[0].trim().substring(0, 2))
.find(lang => supportedLanguages.includes(lang as SupportedLanguage)) || 'en';
// Get the hash fragment if present // Get the hash fragment if present
const url = new URL(Astro.request.url); const url = new URL(Astro.request.url);
const hash = url.hash; const hash = url.hash;
// Detect preferred language
const preferredLanguage = detectPreferredLanguage(Astro.request);
// Redirect to the language-specific homepage // Redirect to the language-specific homepage
return Astro.redirect(`/${preferredLanguage}/${hash}`); return Astro.redirect(`/${preferredLanguage}/${hash}`);
--- ---

View File

@@ -3,11 +3,11 @@ import 'dotenv/config';
async function runEmailTest() { async function runEmailTest() {
console.log('Starting email configuration test...'); console.log('Starting email configuration test...');
// Test the SMTP connection // Test the SMTP connection
const configTest = await testEmailConfiguration(); const configTest = await testEmailConfiguration();
console.log(`Configuration test result: ${configTest ? 'SUCCESS' : 'FAILED'}`); console.log(`Configuration test result: ${configTest ? 'SUCCESS' : 'FAILED'}`);
if (configTest) { if (configTest) {
// Try sending a test email // Try sending a test email
console.log('Attempting to send a test email...'); console.log('Attempting to send a test email...');
@@ -18,14 +18,14 @@ async function runEmailTest() {
'127.0.0.1', '127.0.0.1',
'Email Test Script' 'Email Test Script'
); );
console.log(`Test email result: ${emailResult ? 'SENT' : 'FAILED'}`); console.log(`Test email result: ${emailResult ? 'SENT' : 'FAILED'}`);
} }
console.log('Email test completed'); console.log('Email test completed');
} }
runEmailTest().catch(error => { runEmailTest().catch((error) => {
console.error('Error running email test:', error); console.error('Error running email test:', error);
process.exit(1); process.exit(1);
}); });

View File

@@ -1,8 +1,16 @@
import nodemailer from 'nodemailer'; import nodemailer from 'nodemailer';
import { RateLimiterMemory } from 'rate-limiter-flexible'; import { RateLimiterMemory } from 'rate-limiter-flexible';
import { createHash } from 'crypto'; import { createHash } from 'crypto';
import { getAdminNotificationHtml, getAdminNotificationText, getAdminNotificationSubject } from '../email-templates/admin-notification'; import {
import { getUserConfirmationHtml, getUserConfirmationText, getUserConfirmationSubject } from '../email-templates/user-confirmation'; getAdminNotificationHtml,
getAdminNotificationText,
getAdminNotificationSubject,
} from '../email-templates/admin-notification';
import {
getUserConfirmationHtml,
getUserConfirmationText,
getUserConfirmationSubject,
} from '../email-templates/user-confirmation';
import 'dotenv/config'; import 'dotenv/config';
// Environment variables // Environment variables
@@ -12,7 +20,7 @@ const {
SMTP_USER = '', SMTP_USER = '',
SMTP_PASS = '', SMTP_PASS = '',
ADMIN_EMAIL = '', ADMIN_EMAIL = '',
WEBSITE_NAME = 'bergsma.it' WEBSITE_NAME = 'bergsma.it',
} = process.env; } = process.env;
// Email configuration // Email configuration
@@ -26,11 +34,11 @@ let transporter: nodemailer.Transporter;
function initializeTransporter() { function initializeTransporter() {
if (isProduction && SMTP_HOST && SMTP_USER && SMTP_PASS) { if (isProduction && SMTP_HOST && SMTP_USER && SMTP_PASS) {
// Production: Use SMTP server // Production: Use SMTP server
// ProtonMail specific configuration // ProtonMail specific configuration
// ProtonMail often requires using their Bridge application for SMTP // ProtonMail often requires using their Bridge application for SMTP
const isProtonMail = SMTP_HOST.includes('protonmail'); const isProtonMail = SMTP_HOST.includes('protonmail');
transporter = nodemailer.createTransport({ transporter = nodemailer.createTransport({
host: SMTP_HOST, host: SMTP_HOST,
port: parseInt(SMTP_PORT, 10), port: parseInt(SMTP_PORT, 10),
@@ -45,13 +53,13 @@ function initializeTransporter() {
// Do not fail on invalid certs // Do not fail on invalid certs
rejectUnauthorized: false, rejectUnauthorized: false,
// Specific ciphers for ProtonMail // Specific ciphers for ProtonMail
ciphers: 'SSLv3' ciphers: 'SSLv3',
} },
}) }),
}); });
// Verify SMTP connection configuration // Verify SMTP connection configuration
transporter.verify(function(error, _success) { transporter.verify(function (error, _success) {
if (error) { if (error) {
console.error('SMTP connection error:', error); console.error('SMTP connection error:', error);
} else { } else {
@@ -79,39 +87,37 @@ const csrfTokens = new Map<string, { token: string; expires: Date }>();
// Generate a CSRF token // Generate a CSRF token
export function generateCsrfToken(): string { export function generateCsrfToken(): string {
const token = createHash('sha256') const token = createHash('sha256').update(Math.random().toString()).digest('hex');
.update(Math.random().toString())
.digest('hex');
// Token expires after 1 hour // Token expires after 1 hour
const expires = new Date(); const expires = new Date();
expires.setHours(expires.getHours() + 1); expires.setHours(expires.getHours() + 1);
csrfTokens.set(token, { token, expires }); csrfTokens.set(token, { token, expires });
// Clean up expired tokens // Clean up expired tokens
for (const [key, value] of csrfTokens.entries()) { for (const [key, value] of csrfTokens.entries()) {
if (value.expires < new Date()) { if (value.expires < new Date()) {
csrfTokens.delete(key); csrfTokens.delete(key);
} }
} }
return token; return token;
} }
// Validate a CSRF token // Validate a CSRF token
export function validateCsrfToken(token: string): boolean { export function validateCsrfToken(token: string): boolean {
const storedToken = csrfTokens.get(token); const storedToken = csrfTokens.get(token);
if (!storedToken) { if (!storedToken) {
return false; return false;
} }
if (storedToken.expires < new Date()) { if (storedToken.expires < new Date()) {
csrfTokens.delete(token); csrfTokens.delete(token);
return false; return false;
} }
return true; return true;
} }
@@ -140,18 +146,13 @@ export async function checkRateLimit(ipAddress: string): Promise<{ limited: bool
} }
// Log email sending attempts // Log email sending attempts
export function logEmailAttempt( export function logEmailAttempt(success: boolean, recipient: string, subject: string, error?: Error): void {
success: boolean,
recipient: string,
subject: string,
error?: Error
): void {
const timestamp = new Date().toISOString(); const timestamp = new Date().toISOString();
const status = success ? 'SUCCESS' : 'FAILURE'; const status = success ? 'SUCCESS' : 'FAILURE';
const errorMessage = error ? `: ${error.message}` : ''; const errorMessage = error ? `: ${error.message}` : '';
const logMessage = `[${timestamp}] [EMAIL ${status}] To: ${recipient}, Subject: ${subject}${errorMessage}`; const logMessage = `[${timestamp}] [EMAIL ${status}] To: ${recipient}, Subject: ${subject}${errorMessage}`;
if (isProduction) { if (isProduction) {
// In production, you might want to log to a file or a logging service // In production, you might want to log to a file or a logging service
console.log(logMessage); console.log(logMessage);
@@ -162,23 +163,16 @@ export function logEmailAttempt(
} }
// Send an email // Send an email
export async function sendEmail( export async function sendEmail(to: string, subject: string, html: string, text: string): Promise<boolean> {
to: string,
subject: string,
html: string,
text: string
): Promise<boolean> {
// Initialize transporter if not already done // Initialize transporter if not already done
if (!transporter) { if (!transporter) {
initializeTransporter(); initializeTransporter();
} }
try { try {
// Ensure from address matches SMTP_USER for ProtonMail // Ensure from address matches SMTP_USER for ProtonMail
const fromAddress = isProduction ? const fromAddress = isProduction ? `"${WEBSITE_NAME}" <${SMTP_USER}>` : `"${WEBSITE_NAME}" <${ADMIN_EMAIL}>`;
`"${WEBSITE_NAME}" <${SMTP_USER}>` :
`"${WEBSITE_NAME}" <${ADMIN_EMAIL}>`;
const mailOptions = { const mailOptions = {
from: fromAddress, from: fromAddress,
to, to,
@@ -186,24 +180,23 @@ export async function sendEmail(
html, html,
text, text,
}; };
await transporter.sendMail(mailOptions); await transporter.sendMail(mailOptions);
logEmailAttempt(true, to, subject); logEmailAttempt(true, to, subject);
return true; return true;
} catch (error) { } catch (error) {
logEmailAttempt(false, to, subject, error as Error); logEmailAttempt(false, to, subject, error as Error);
// Enhanced error logging for SMTP issues // Enhanced error logging for SMTP issues
if (isProduction) { if (isProduction) {
console.error('Error sending email:', error); console.error('Error sending email:', error);
// Log more detailed information for SMTP errors // Log more detailed information for SMTP errors
if (error instanceof Error) { if (error instanceof Error) {
console.error('Error name:', error.name); console.error('Error name:', error.name);
console.error('Error message:', error.message); console.error('Error message:', error.message);
// Log additional details for specific error types // Log additional details for specific error types
if (error.name === 'Error' && error.message.includes('ECONNREFUSED')) { if (error.name === 'Error' && error.message.includes('ECONNREFUSED')) {
console.error('SMTP Connection Refused: Check if the SMTP server is reachable and the port is correct'); console.error('SMTP Connection Refused: Check if the SMTP server is reachable and the port is correct');
@@ -214,7 +207,7 @@ export async function sendEmail(
} }
} }
} }
return false; return false;
} }
} }
@@ -232,22 +225,22 @@ export async function sendAdminNotification(
console.error('Cannot send admin notification: name is empty'); console.error('Cannot send admin notification: name is empty');
return false; return false;
} }
if (!email || email.trim() === '') { if (!email || email.trim() === '') {
console.error('Cannot send admin notification: email is empty'); console.error('Cannot send admin notification: email is empty');
return false; return false;
} }
if (!message || message.trim() === '') { if (!message || message.trim() === '') {
console.error('Cannot send admin notification: message is empty'); console.error('Cannot send admin notification: message is empty');
return false; return false;
} }
if (!ADMIN_EMAIL || ADMIN_EMAIL.trim() === '') { if (!ADMIN_EMAIL || ADMIN_EMAIL.trim() === '') {
console.error('Cannot send admin notification: ADMIN_EMAIL is not configured'); console.error('Cannot send admin notification: ADMIN_EMAIL is not configured');
return false; return false;
} }
const submittedAt = new Date().toLocaleString('en-US', { const submittedAt = new Date().toLocaleString('en-US', {
year: 'numeric', year: 'numeric',
month: 'long', month: 'long',
@@ -255,7 +248,7 @@ export async function sendAdminNotification(
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
}); });
const props = { const props = {
name, name,
email, email,
@@ -264,30 +257,26 @@ export async function sendAdminNotification(
ipAddress, ipAddress,
userAgent, userAgent,
}; };
const subject = getAdminNotificationSubject(); const subject = getAdminNotificationSubject();
const html = getAdminNotificationHtml(props); const html = getAdminNotificationHtml(props);
const text = getAdminNotificationText(props); const text = getAdminNotificationText(props);
// Add a backup email address to ensure delivery // Add a backup email address to ensure delivery
const recipients = ADMIN_EMAIL; const recipients = ADMIN_EMAIL;
// Uncomment and modify the line below to add a backup email address // Uncomment and modify the line below to add a backup email address
// const recipients = `${ADMIN_EMAIL}, your-backup-email@example.com`; // const recipients = `${ADMIN_EMAIL}, your-backup-email@example.com`;
return sendEmail(recipients, subject, html, text); return sendEmail(recipients, subject, html, text);
} }
// Send user confirmation email // Send user confirmation email
export async function sendUserConfirmation( export async function sendUserConfirmation(name: string, email: string, message: string): Promise<boolean> {
name: string,
email: string,
message: string
): Promise<boolean> {
if (!email || email.trim() === '') { if (!email || email.trim() === '') {
console.error('Cannot send user confirmation: email is empty'); console.error('Cannot send user confirmation: email is empty');
return false; return false;
} }
const submittedAt = new Date().toLocaleString('en-US', { const submittedAt = new Date().toLocaleString('en-US', {
year: 'numeric', year: 'numeric',
month: 'long', month: 'long',
@@ -295,7 +284,7 @@ export async function sendUserConfirmation(
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
}); });
const props = { const props = {
name, name,
email, email,
@@ -304,11 +293,11 @@ export async function sendUserConfirmation(
websiteName: WEBSITE_NAME, websiteName: WEBSITE_NAME,
contactEmail: ADMIN_EMAIL, contactEmail: ADMIN_EMAIL,
}; };
const subject = getUserConfirmationSubject(WEBSITE_NAME); const subject = getUserConfirmationSubject(WEBSITE_NAME);
const html = getUserConfirmationHtml(props); const html = getUserConfirmationHtml(props);
const text = getUserConfirmationText(props); const text = getUserConfirmationText(props);
return sendEmail(email, subject, html, text); return sendEmail(email, subject, html, text);
} }
@@ -325,16 +314,16 @@ export async function testEmailConfiguration(): Promise<boolean> {
if (!isProduction) { if (!isProduction) {
return true; return true;
} }
try { try {
// Initialize transporter if not already done // Initialize transporter if not already done
if (!transporter) { if (!transporter) {
initializeTransporter(); initializeTransporter();
} }
// Verify connection to SMTP server // Verify connection to SMTP server
const connectionResult = await new Promise<boolean>((resolve) => { const connectionResult = await new Promise<boolean>((resolve) => {
transporter.verify(function(error, _success) { transporter.verify(function (error, _success) {
if (error) { if (error) {
resolve(false); resolve(false);
} else { } else {
@@ -342,11 +331,11 @@ export async function testEmailConfiguration(): Promise<boolean> {
} }
}); });
}); });
if (!connectionResult) { if (!connectionResult) {
return false; return false;
} }
return true; return true;
} catch { } catch {
return false; return false;
@@ -356,4 +345,4 @@ export async function testEmailConfiguration(): Promise<boolean> {
// Run a test of the email configuration // Run a test of the email configuration
if (isProduction) { if (isProduction) {
testEmailConfiguration(); testEmailConfiguration();
} }

View File

@@ -199,7 +199,7 @@ const getBreakpoints = ({
if (layout === 'constrained') { if (layout === 'constrained') {
// Use imageSizes when width is smaller than the smallest deviceSize // Use imageSizes when width is smaller than the smallest deviceSize
const sizesToUse = width < config.deviceSizes[0] ? config.imageSizes : config.deviceSizes; const sizesToUse = width < config.deviceSizes[0] ? config.imageSizes : config.deviceSizes;
return [ return [
// Always include the image at 1x and 2x the specified width // Always include the image at 1x and 2x the specified width
width, width,

28
src/utils/language.ts Normal file
View File

@@ -0,0 +1,28 @@
import { supportedLanguages } from '~/i18n/translations';
// Define the type for supported languages
type SupportedLanguage = (typeof supportedLanguages)[number];
export function detectPreferredLanguage(request: Request): SupportedLanguage {
// Check for language preference in cookies (set by client-side JS)
const cookies = request.headers.get('cookie') || '';
const cookieLanguage = cookies
.split(';')
.map((cookie) => cookie.trim())
.find((cookie) => cookie.startsWith('preferredLanguage='))
?.split('=')[1];
// Get the user's preferred language from the browser if no cookie
const acceptLanguage = request.headers.get('accept-language') || '';
// Use cookie language if available, otherwise detect from browser
const preferredLanguage =
cookieLanguage && supportedLanguages.includes(cookieLanguage as SupportedLanguage)
? (cookieLanguage as SupportedLanguage)
: acceptLanguage
.split(',')
.map((lang) => lang.split(';')[0].trim().substring(0, 2))
.find((lang) => supportedLanguages.includes(lang as SupportedLanguage)) || 'en';
return preferredLanguage as SupportedLanguage;
}

View File

@@ -57,7 +57,7 @@ export const getPermalink = (slug = '', type = 'page', lang = ''): string => {
) { ) {
return slug; return slug;
} }
// Extract hash fragment if present // Extract hash fragment if present
let hashFragment = ''; let hashFragment = '';
if (slug.includes('#')) { if (slug.includes('#')) {
@@ -121,12 +121,12 @@ const definitivePermalink = (permalink: string, lang = ''): string => {
if (permalink.startsWith('#')) { if (permalink.startsWith('#')) {
return permalink; return permalink;
} }
// Don't add language prefix to external links // Don't add language prefix to external links
if (permalink.startsWith('http://') || permalink.startsWith('https://') || permalink.startsWith('//')) { if (permalink.startsWith('http://') || permalink.startsWith('https://') || permalink.startsWith('//')) {
return permalink; return permalink;
} }
if (lang && ['en', 'nl', 'de', 'fr'].includes(lang)) { if (lang && ['en', 'nl', 'de', 'fr'].includes(lang)) {
return createPath(BASE_PATHNAME, lang, permalink); return createPath(BASE_PATHNAME, lang, permalink);
} }