Updated site completely
This commit is contained in:
30
netlify/functions/contact.js
Normal file
30
netlify/functions/contact.js
Normal 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
9
package-lock.json
generated
@@ -4016,9 +4016,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001667",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001667.tgz",
|
||||
"integrity": "sha512-7LTwJjcRkzKFmtqGsibMeuXmvFDfZq/nzIjnmgCGzKKRVzjD72selLDK1oPF/Oxzmt4fNcPvTDvGqSDG4tCALw==",
|
||||
"version": "1.0.30001707",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001707.tgz",
|
||||
"integrity": "sha512-3qtRjw/HQSMlDWf+X79N206fepf4SOOU6SQLMaq/0KkZLmSjPxAkBOQQ+FxbHKfHmYLZFfdWsO3KA90ceHPSnw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -4032,7 +4032,8 @@
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
]
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/ccount": {
|
||||
"version": "2.0.1",
|
||||
|
@@ -1,14 +1,16 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
p, li, div {
|
||||
@layer base {
|
||||
p,
|
||||
li,
|
||||
div {
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
@layer utilities {
|
||||
.bg-page {
|
||||
background-color: var(--aw-color-bg-page);
|
||||
}
|
||||
@@ -24,83 +26,75 @@
|
||||
.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 {
|
||||
@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 {
|
||||
@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 {
|
||||
@apply btn;
|
||||
@apply btn;
|
||||
}
|
||||
|
||||
.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;
|
||||
box-shadow: 0 0.375rem 1.5rem 0 rgb(140 152 164 / 13%);
|
||||
}
|
||||
.dark #header.scroll > div:first-child,
|
||||
#header.scroll.dark > div:first-child {
|
||||
}
|
||||
.dark #header.scroll > div:first-child,
|
||||
#header.scroll.dark > div:first-child {
|
||||
@apply bg-page md:bg-[#030621e6] border-b border-gray-500/20;
|
||||
box-shadow: none;
|
||||
}
|
||||
/* #header.scroll > div:last-child {
|
||||
}
|
||||
/* #header.scroll > div:last-child {
|
||||
@apply py-3;
|
||||
} */
|
||||
} */
|
||||
|
||||
#header.expanded nav {
|
||||
#header.expanded nav {
|
||||
position: fixed;
|
||||
top: 70px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 70px !important;
|
||||
padding: 0 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown:focus .dropdown-menu,
|
||||
.dropdown:focus-within .dropdown-menu,
|
||||
.dropdown:hover .dropdown-menu {
|
||||
.dropdown:focus .dropdown-menu,
|
||||
.dropdown:focus-within .dropdown-menu,
|
||||
.dropdown:hover .dropdown-menu {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
[astro-icon].icon-light > * {
|
||||
[astro-icon].icon-light > * {
|
||||
stroke-width: 1.2;
|
||||
}
|
||||
}
|
||||
|
||||
[astro-icon].icon-bold > * {
|
||||
[astro-icon].icon-bold > * {
|
||||
stroke-width: 2.4;
|
||||
}
|
||||
}
|
||||
|
||||
[data-aw-toggle-menu] path {
|
||||
[data-aw-toggle-menu] path {
|
||||
@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];
|
||||
}
|
||||
}
|
||||
|
||||
[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];
|
||||
}
|
||||
}
|
||||
|
||||
/* To deprecated */
|
||||
/* To deprecated */
|
||||
|
||||
.dd *:first-child {
|
||||
.dd *:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
@@ -5,12 +5,18 @@ const { lang = 'en' } = Astro.params;
|
||||
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="text-sm text-gray-800 dark:text-gray-200 font-medium">
|
||||
<p>
|
||||
{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>
|
||||
</div>
|
||||
<div class="flex-shrink-0">
|
||||
@@ -41,7 +47,7 @@ const t = getTranslation(lang);
|
||||
// Helper function to set cookie with expiration
|
||||
function setCookie(name, value, days) {
|
||||
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()}`;
|
||||
document.cookie = `${name}=${value}; ${expires}; path=/; SameSite=Lax`;
|
||||
}
|
||||
|
@@ -66,5 +66,4 @@ import '@fontsource-variable/inter';
|
||||
color: snow;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
@@ -8,16 +8,16 @@ interface Props {
|
||||
|
||||
const { currentLang } = Astro.props;
|
||||
|
||||
type SupportedLanguage = typeof supportedLanguages[number];
|
||||
type SupportedLanguage = (typeof supportedLanguages)[number];
|
||||
|
||||
const languages = [
|
||||
{ code: 'en' as SupportedLanguage, name: 'English', flag: 'gb' },
|
||||
{ code: 'nl' as SupportedLanguage, name: 'Dutch', flag: 'nl' },
|
||||
{ code: 'de' as SupportedLanguage, name: 'German', flag: 'de' },
|
||||
{ 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">
|
||||
@@ -51,28 +51,35 @@ const currentLanguage = languages.find(lang => lang.code === currentLang) || lan
|
||||
style="max-height: min(300px, 70vh);"
|
||||
>
|
||||
<div class="py-1" role="none">
|
||||
{languages.map(lang => (
|
||||
<button
|
||||
type="button"
|
||||
data-lang-code={lang.code}
|
||||
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"
|
||||
role="menuitem"
|
||||
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}
|
||||
</button>
|
||||
))}
|
||||
{
|
||||
languages.map((lang) => (
|
||||
<button
|
||||
type="button"
|
||||
data-lang-code={lang.code}
|
||||
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"
|
||||
role="menuitem"
|
||||
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}
|
||||
</button>
|
||||
))
|
||||
}
|
||||
</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">
|
||||
{languages.map(lang => (
|
||||
<option value={lang.code} selected={lang.code === currentLang}>
|
||||
{lang.name}
|
||||
</option>
|
||||
))}
|
||||
<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"
|
||||
>
|
||||
{
|
||||
languages.map((lang) => (
|
||||
<option value={lang.code} selected={lang.code === currentLang}>
|
||||
{lang.name}
|
||||
</option>
|
||||
))
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -181,10 +188,7 @@ const currentLanguage = languages.find(lang => lang.code === currentLang) || lan
|
||||
const shouldOpenUpward = spaceBelow < menuHeight && spaceAbove > spaceBelow;
|
||||
|
||||
// Position menu
|
||||
menu.style.maxHeight = `${Math.min(
|
||||
shouldOpenUpward ? spaceAbove - 8 : spaceBelow - 8,
|
||||
300
|
||||
)}px`;
|
||||
menu.style.maxHeight = `${Math.min(shouldOpenUpward ? spaceAbove - 8 : spaceBelow - 8, 300)}px`;
|
||||
|
||||
if (shouldOpenUpward) {
|
||||
menu.classList.add('open-upward');
|
||||
@@ -214,7 +218,7 @@ const currentLanguage = languages.find(lang => lang.code === currentLang) || lan
|
||||
});
|
||||
|
||||
// Handle language selection
|
||||
languageButtons.forEach(langButton => {
|
||||
languageButtons.forEach((langButton) => {
|
||||
langButton.addEventListener('click', () => {
|
||||
const langCode = langButton.dataset.langCode;
|
||||
if (!langCode) return;
|
||||
@@ -288,8 +292,8 @@ const currentLanguage = languages.find(lang => lang.code === currentLang) || lan
|
||||
langCode,
|
||||
previousLangCode,
|
||||
path: newUrl,
|
||||
willReload: true
|
||||
}
|
||||
willReload: true,
|
||||
},
|
||||
});
|
||||
document.dispatchEvent(reloadEvent);
|
||||
|
||||
@@ -362,8 +366,8 @@ const currentLanguage = languages.find(lang => lang.code === currentLang) || lan
|
||||
langCode,
|
||||
previousLangCode,
|
||||
path: newUrl,
|
||||
willReload: true
|
||||
}
|
||||
willReload: true,
|
||||
},
|
||||
});
|
||||
document.dispatchEvent(reloadEvent);
|
||||
|
||||
|
@@ -96,7 +96,7 @@ declare global {
|
||||
storeLanguagePreference: (langCode) => {
|
||||
localStorage.setItem('preferredLanguage', langCode);
|
||||
setLanguageCookie(langCode);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
@@ -41,18 +41,11 @@ export default function LanguageSelectorComponent({ defaultLang }: LanguageSelec
|
||||
className={`
|
||||
inline-flex items-center px-3 py-2 text-sm font-medium rounded-md
|
||||
transition-colors duration-200 hover:bg-gray-100
|
||||
${language.code === currentLang
|
||||
? 'text-blue-600 bg-blue-50'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
}
|
||||
${language.code === currentLang ? 'text-blue-600 bg-blue-50' : 'text-gray-600 hover:text-gray-900'}
|
||||
`}
|
||||
aria-current={language.code === currentLang ? 'page' : undefined}
|
||||
>
|
||||
<Icon
|
||||
name={`circle-flags:${language.flag}`}
|
||||
className="w-5 h-5 mr-2"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Icon name={`circle-flags:${language.flag}`} className="w-5 h-5 mr-2" aria-hidden="true" />
|
||||
<span>{language.name}</span>
|
||||
</button>
|
||||
))}
|
||||
|
@@ -4,6 +4,7 @@ import { SITE } from 'astrowind:config';
|
||||
|
||||
<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"
|
||||
aria-label={SITE?.name}
|
||||
>
|
||||
{SITE?.name}
|
||||
{SITE?.name}
|
||||
</span>
|
||||
|
@@ -163,7 +163,7 @@ import { UI } from 'astrowind:config';
|
||||
// Handle smooth scrolling for anchor links across all pages
|
||||
function setupSmoothScrolling() {
|
||||
// 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) {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -173,14 +173,14 @@ import { UI } from 'astrowind:config';
|
||||
if (targetElement) {
|
||||
window.scrollTo({
|
||||
top: targetElement.offsetTop - 50, // Offset for header
|
||||
behavior: 'smooth'
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 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) {
|
||||
const href = this.getAttribute('href');
|
||||
const isHashLink = this.getAttribute('data-hash-link') === 'true';
|
||||
@@ -214,7 +214,7 @@ import { UI } from 'astrowind:config';
|
||||
|
||||
window.scrollTo({
|
||||
top: targetElement.offsetTop - 50, // Offset for header
|
||||
behavior: 'smooth'
|
||||
behavior: 'smooth',
|
||||
});
|
||||
} else {
|
||||
// If the target element doesn't exist on the current page, navigate to the page
|
||||
@@ -247,7 +247,7 @@ import { UI } from 'astrowind:config';
|
||||
const supportedLanguages = ['en', 'nl', 'de', 'fr'];
|
||||
|
||||
// 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');
|
||||
if (!href) return;
|
||||
|
||||
@@ -285,9 +285,7 @@ import { UI } from 'astrowind:config';
|
||||
// If it doesn't have a language prefix, add the current language
|
||||
if (!hasLanguagePrefix) {
|
||||
// Create the new path with the language prefix
|
||||
const newPath = pathWithoutHash === '/' ?
|
||||
`/${currentLang}` :
|
||||
`/${currentLang}${pathWithoutHash}`;
|
||||
const newPath = pathWithoutHash === '/' ? `/${currentLang}` : `/${currentLang}${pathWithoutHash}`;
|
||||
|
||||
// Set the new href with the hash fragment (if any)
|
||||
link.setAttribute('href', newPath + hashFragment);
|
||||
@@ -333,7 +331,7 @@ import { UI } from 'astrowind:config';
|
||||
setTimeout(() => {
|
||||
window.scrollTo({
|
||||
top: targetElement.offsetTop - 50, // Offset for header
|
||||
behavior: 'smooth'
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
@@ -345,7 +343,7 @@ import { UI } from 'astrowind:config';
|
||||
// Make language functions available globally
|
||||
window.languageUtils = {
|
||||
getStoredLanguage,
|
||||
storeLanguagePreference
|
||||
storeLanguagePreference,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -471,7 +469,7 @@ import { UI } from 'astrowind:config';
|
||||
if (!contactForm) return;
|
||||
|
||||
// Form validation and submission
|
||||
contactForm.addEventListener('submit', async function(e) {
|
||||
contactForm.addEventListener('submit', async function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
// Reset previous error messages
|
||||
@@ -498,7 +496,7 @@ import { UI } from 'astrowind:config';
|
||||
const response = await fetch('/', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams(formData).toString()
|
||||
body: new URLSearchParams(formData).toString(),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
@@ -518,12 +516,12 @@ import { UI } from 'astrowind:config';
|
||||
});
|
||||
|
||||
// Add input validation on blur
|
||||
contactForm.querySelectorAll('input, textarea').forEach(input => {
|
||||
input.addEventListener('blur', function() {
|
||||
contactForm.querySelectorAll('input, textarea').forEach((input) => {
|
||||
input.addEventListener('blur', function () {
|
||||
validateInput(this);
|
||||
});
|
||||
|
||||
input.addEventListener('input', function() {
|
||||
input.addEventListener('input', function () {
|
||||
// Remove error styling when user starts typing
|
||||
this.classList.remove('border-red-500');
|
||||
const feedbackElement = this.closest('div').querySelector('.invalid-feedback');
|
||||
@@ -538,7 +536,7 @@ import { UI } from 'astrowind:config';
|
||||
let isValid = true;
|
||||
|
||||
// Validate all inputs
|
||||
form.querySelectorAll('input, textarea').forEach(input => {
|
||||
form.querySelectorAll('input, textarea').forEach((input) => {
|
||||
if (!validateInput(input)) {
|
||||
isValid = false;
|
||||
}
|
||||
@@ -621,17 +619,17 @@ import { UI } from 'astrowind:config';
|
||||
|
||||
function resetFormErrors() {
|
||||
// Hide all error messages
|
||||
document.querySelectorAll('.invalid-feedback').forEach(el => {
|
||||
document.querySelectorAll('.invalid-feedback').forEach((el) => {
|
||||
el.classList.add('hidden');
|
||||
});
|
||||
|
||||
// Remove error styling
|
||||
document.querySelectorAll('input, textarea').forEach(input => {
|
||||
document.querySelectorAll('input, textarea').forEach((input) => {
|
||||
input.classList.remove('border-red-500');
|
||||
});
|
||||
|
||||
// Remove checkbox container error styling
|
||||
document.querySelectorAll('.flex.items-start').forEach(container => {
|
||||
document.querySelectorAll('.flex.items-start').forEach((container) => {
|
||||
container.classList.remove('checkbox-error');
|
||||
});
|
||||
|
||||
|
@@ -10,25 +10,33 @@ const { items } = Astro.props;
|
||||
---
|
||||
|
||||
<div class="accordion">
|
||||
{items.map((item, index) => (
|
||||
<div class="accordion-item">
|
||||
<button class="accordion-header" data-accordion-target={`#accordion-body-${index}`}>
|
||||
{item.title}
|
||||
<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>
|
||||
</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>
|
||||
{
|
||||
items.map((item, index) => (
|
||||
<div class="accordion-item">
|
||||
<button class="accordion-header" data-accordion-target={`#accordion-body-${index}`}>
|
||||
{item.title}
|
||||
<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"
|
||||
/>
|
||||
</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>
|
||||
|
||||
<script>
|
||||
const accordionHeaders = document.querySelectorAll('.accordion-header');
|
||||
|
||||
accordionHeaders.forEach(header => {
|
||||
accordionHeaders.forEach((header) => {
|
||||
header.addEventListener('click', () => {
|
||||
const target = header.getAttribute('data-accordion-target');
|
||||
const body = document.querySelector(target);
|
||||
@@ -37,7 +45,7 @@ const { items } = Astro.props;
|
||||
const expanded = (body as HTMLElement).classList.contains('expanded');
|
||||
|
||||
// Close all accordion items
|
||||
document.querySelectorAll('.accordion-body.expanded').forEach(item => {
|
||||
document.querySelectorAll('.accordion-body.expanded').forEach((item) => {
|
||||
if (item && 'style' in item) {
|
||||
(item as any).style.display = 'none';
|
||||
item.classList.remove('expanded');
|
||||
@@ -57,7 +65,7 @@ const { items } = Astro.props;
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.accordion-header {
|
||||
.accordion-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
@@ -69,17 +77,17 @@ const { items } = Astro.props;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.accordion-header:hover {
|
||||
.accordion-header:hover {
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
}
|
||||
|
||||
.accordion-body {
|
||||
.accordion-body {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.accordion-body.expanded {
|
||||
.accordion-body.expanded {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
</style>
|
@@ -1,12 +1,8 @@
|
||||
---
|
||||
---
|
||||
|
||||
<button
|
||||
id="back-to-top"
|
||||
class="back-to-top"
|
||||
aria-label="Back to top"
|
||||
title="Return to Top"
|
||||
>
|
||||
---
|
||||
|
||||
<button id="back-to-top" class="back-to-top" aria-label="Back to top" title="Return to Top">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
@@ -98,7 +94,7 @@
|
||||
e.preventDefault();
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: 'smooth'
|
||||
behavior: 'smooth',
|
||||
});
|
||||
};
|
||||
|
||||
|
@@ -101,17 +101,13 @@ const getRandomRotation = (): string => {
|
||||
// Function to get a random size
|
||||
const getRandomSize = (isDarkMode: boolean = false): string => {
|
||||
// Slightly larger size range for dark mode for better visibility
|
||||
return isDarkMode
|
||||
? `${getRandomInRange(160, 200)}px`
|
||||
: `${getRandomInRange(140, 180)}px`;
|
||||
return isDarkMode ? `${getRandomInRange(160, 200)}px` : `${getRandomInRange(140, 180)}px`;
|
||||
};
|
||||
|
||||
// Function to get a random opacity
|
||||
const getRandomOpacity = (isDarkMode: boolean = false): string => {
|
||||
// Higher opacity range for dark mode for better visibility
|
||||
return isDarkMode
|
||||
? getRandomInRange(0.45, 0.55).toFixed(2)
|
||||
: getRandomInRange(0.32, 0.38).toFixed(2);
|
||||
return isDarkMode ? getRandomInRange(0.45, 0.55).toFixed(2) : getRandomInRange(0.32, 0.38).toFixed(2);
|
||||
};
|
||||
|
||||
// Create a spacious layout with well-separated icons
|
||||
@@ -136,8 +132,8 @@ const createSpacedIcons = (): BaseIconObject[] => {
|
||||
}
|
||||
|
||||
// Base position in the grid with margins applied
|
||||
const baseX = marginX + ((col / cols) * (100 - 2 * marginX));
|
||||
const baseY = marginY + ((row / rows) * (100 - 2 * marginY));
|
||||
const baseX = marginX + (col / cols) * (100 - 2 * marginX);
|
||||
const baseY = marginY + (row / rows) * (100 - 2 * marginY);
|
||||
|
||||
// Add limited randomness to the position (±5%) to maintain spacing
|
||||
const randomOffsetX = getRandomInRange(-5, 5);
|
||||
@@ -155,10 +151,7 @@ const createSpacedIcons = (): BaseIconObject[] => {
|
||||
const existingY = parseFloat(existingIcon.y);
|
||||
|
||||
// Calculate distance between points
|
||||
const distance = Math.sqrt(
|
||||
Math.pow(x - existingX, 2) +
|
||||
Math.pow(y - existingY, 2)
|
||||
);
|
||||
const distance = Math.sqrt(Math.pow(x - existingX, 2) + Math.pow(y - existingY, 2));
|
||||
|
||||
// If too close to an existing icon, skip this position
|
||||
if (distance < minDistance) {
|
||||
@@ -191,7 +184,7 @@ const createSpacedIcons = (): BaseIconObject[] => {
|
||||
const icons: BaseIconObject[] = createSpacedIcons();
|
||||
|
||||
// Assign random colors to each icon
|
||||
const iconsWithColors: IconWithColors[] = icons.map(icon => ({
|
||||
const iconsWithColors: IconWithColors[] = icons.map((icon) => ({
|
||||
icon: icon.icon,
|
||||
x: icon.x,
|
||||
y: icon.y,
|
||||
@@ -202,80 +195,84 @@ const iconsWithColors: IconWithColors[] = icons.map(icon => ({
|
||||
darkColor: getRandomColor(darkModeColors),
|
||||
}));
|
||||
---
|
||||
|
||||
<div class:list={['absolute inset-0', { 'backdrop-blur-sm bg-white/5 dark:bg-gray-900/10': isDark }]}>
|
||||
<slot />
|
||||
|
||||
{showIcons && (
|
||||
/* Decorative background icons with random placement */
|
||||
<div id="background-icons" class="absolute inset-0 overflow-hidden pointer-events-none z-[-5]">
|
||||
{iconsWithColors.map(({ icon, x, y, size, opacity, rotate, lightColor, darkColor }) => (
|
||||
<div
|
||||
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>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{
|
||||
showIcons && (
|
||||
/* Decorative background icons with random placement */
|
||||
<div id="background-icons" class="absolute inset-0 overflow-hidden pointer-events-none z-[-5]">
|
||||
{iconsWithColors.map(({ icon, x, y, size, opacity, rotate, lightColor, darkColor }) => (
|
||||
<div
|
||||
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>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
{showIcons && (
|
||||
<script define:vars={{ disableParallax }}>
|
||||
// Parallax scrolling effect for background icons
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Get all parallax icons
|
||||
const parallaxIcons = document.querySelectorAll('.parallax-icon');
|
||||
<script define:vars={{ disableParallax }}>
|
||||
// Parallax scrolling effect for background icons
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Get all parallax icons
|
||||
const parallaxIcons = document.querySelectorAll('.parallax-icon');
|
||||
|
||||
// Skip parallax on mobile devices for better performance or if parallax is disabled
|
||||
const isMobile = window.matchMedia('(max-width: 768px)').matches;
|
||||
if (isMobile || disableParallax) return;
|
||||
// Skip parallax on mobile devices for better performance or if parallax is disabled
|
||||
const isMobile = window.matchMedia('(max-width: 768px)').matches;
|
||||
if (isMobile || disableParallax) return;
|
||||
|
||||
// Variables to track scroll position
|
||||
let lastScrollY = window.scrollY;
|
||||
let ticking = false;
|
||||
// Variables to track scroll position
|
||||
let lastScrollY = window.scrollY;
|
||||
let ticking = false;
|
||||
|
||||
// Function to update icon positions based on scroll
|
||||
const updateParallax = () => {
|
||||
parallaxIcons.forEach((icon) => {
|
||||
const depth = parseFloat(icon.getAttribute('data-depth') || '0.5');
|
||||
// Function to update icon positions based on scroll
|
||||
const updateParallax = () => {
|
||||
parallaxIcons.forEach((icon) => {
|
||||
const depth = parseFloat(icon.getAttribute('data-depth') || '0.5');
|
||||
|
||||
// Calculate parallax offset based on scroll position and depth
|
||||
// Lower depth value means the icon moves slower (appears further away)
|
||||
const yOffset = (lastScrollY * depth * 0.15);
|
||||
// Calculate parallax offset based on scroll position and depth
|
||||
// Lower depth value means the icon moves slower (appears further away)
|
||||
const yOffset = lastScrollY * depth * 0.15;
|
||||
|
||||
// Get the original rotation
|
||||
const transformValue = icon.style.transform;
|
||||
const rotateMatch = transformValue.match(/rotate\([^)]+\)/);
|
||||
const rotateValue = rotateMatch ? rotateMatch[0] : 'rotate(0deg)';
|
||||
// Get the original rotation
|
||||
const transformValue = icon.style.transform;
|
||||
const rotateMatch = transformValue.match(/rotate\([^)]+\)/);
|
||||
const rotateValue = rotateMatch ? rotateMatch[0] : 'rotate(0deg)';
|
||||
|
||||
// Apply transform with the original rotation plus the parallax offset
|
||||
icon.style.transform = `${rotateValue} translate3d(0, ${yOffset}px, 0)`;
|
||||
// Apply transform with the original rotation plus the parallax offset
|
||||
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 = () => {
|
||||
lastScrollY = window.scrollY;
|
||||
// Add scroll event listener
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
|
||||
if (!ticking) {
|
||||
window.requestAnimationFrame(() => {
|
||||
updateParallax();
|
||||
ticking = false;
|
||||
});
|
||||
|
||||
ticking = true;
|
||||
}
|
||||
};
|
||||
|
||||
// Add scroll event listener
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
|
||||
// Update on resize (debounced)
|
||||
let resizeTimer;
|
||||
window.addEventListener('resize', () => {
|
||||
// Update on resize (debounced)
|
||||
let resizeTimer;
|
||||
window.addEventListener(
|
||||
'resize',
|
||||
() => {
|
||||
clearTimeout(resizeTimer);
|
||||
resizeTimer = setTimeout(() => {
|
||||
// Check if device is now mobile and disable parallax if needed
|
||||
@@ -294,10 +291,11 @@ const iconsWithColors: IconWithColors[] = icons.map(icon => ({
|
||||
updateParallax();
|
||||
}
|
||||
}, 200);
|
||||
}, { passive: true });
|
||||
},
|
||||
{ passive: true }
|
||||
);
|
||||
|
||||
// Initial update
|
||||
updateParallax();
|
||||
});
|
||||
</script>
|
||||
)}
|
||||
// Initial update
|
||||
updateParallax();
|
||||
});
|
||||
</script>
|
||||
|
@@ -22,7 +22,7 @@ const {
|
||||
|
||||
{
|
||||
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 = {} }) => (
|
||||
<div
|
||||
class={twMerge(
|
||||
@@ -44,7 +44,11 @@ const {
|
||||
{description && (
|
||||
<div class="text-muted mt-2 overflow-hidden">
|
||||
<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}
|
||||
/>
|
||||
</div>
|
||||
|
@@ -15,8 +15,15 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '' }
|
||||
}
|
||||
</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 -->
|
||||
<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!
|
||||
@@ -41,7 +48,8 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '' }
|
||||
<div class="mb-6">
|
||||
{label && (
|
||||
<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>
|
||||
)}
|
||||
<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"
|
||||
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>
|
||||
)
|
||||
)
|
||||
@@ -63,7 +71,8 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '' }
|
||||
textarea && (
|
||||
<div class="mb-6">
|
||||
<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>
|
||||
<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"
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -92,9 +101,10 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '' }
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<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>
|
||||
<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>
|
||||
)
|
||||
@@ -118,3 +128,57 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '' }
|
||||
)
|
||||
}
|
||||
</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>
|
||||
|
@@ -101,17 +101,13 @@ const getRandomRotation = (): string => {
|
||||
// Function to get a random size
|
||||
const getRandomSize = (isDarkMode: boolean = false): string => {
|
||||
// Slightly larger size range for dark mode for better visibility
|
||||
return isDarkMode
|
||||
? `${getRandomInRange(160, 200)}px`
|
||||
: `${getRandomInRange(140, 180)}px`;
|
||||
return isDarkMode ? `${getRandomInRange(160, 200)}px` : `${getRandomInRange(140, 180)}px`;
|
||||
};
|
||||
|
||||
// Function to get a random opacity
|
||||
const getRandomOpacity = (isDarkMode: boolean = false): string => {
|
||||
// Higher opacity range for dark mode for better visibility
|
||||
return isDarkMode
|
||||
? getRandomInRange(0.45, 0.55).toFixed(2)
|
||||
: getRandomInRange(0.32, 0.38).toFixed(2);
|
||||
return isDarkMode ? getRandomInRange(0.45, 0.55).toFixed(2) : getRandomInRange(0.32, 0.38).toFixed(2);
|
||||
};
|
||||
|
||||
// Create a spacious layout with well-separated icons
|
||||
@@ -152,8 +148,8 @@ const createSpacedIcons = (): BaseIconObject[] => {
|
||||
}
|
||||
|
||||
// Base position in the grid with margins applied
|
||||
const baseX = marginX + ((col / cols) * (100 - 2 * marginX));
|
||||
const baseY = marginY + ((row / rows) * (100 - 2 * marginY));
|
||||
const baseX = marginX + (col / cols) * (100 - 2 * marginX);
|
||||
const baseY = marginY + (row / rows) * (100 - 2 * marginY);
|
||||
|
||||
// Add limited randomness to the position (±5%) to maintain spacing
|
||||
const randomOffsetX = getRandomInRange(-5, 5);
|
||||
@@ -171,10 +167,7 @@ const createSpacedIcons = (): BaseIconObject[] => {
|
||||
const existingY = parseFloat(existingIcon.y);
|
||||
|
||||
// Calculate distance between points
|
||||
const distance = Math.sqrt(
|
||||
Math.pow(x - existingX, 2) +
|
||||
Math.pow(y - existingY, 2)
|
||||
);
|
||||
const distance = Math.sqrt(Math.pow(x - existingX, 2) + Math.pow(y - existingY, 2));
|
||||
|
||||
// If too close to an existing icon, skip this position
|
||||
if (distance < minDistance) {
|
||||
@@ -205,11 +198,10 @@ const createSpacedIcons = (): BaseIconObject[] => {
|
||||
return icons;
|
||||
};
|
||||
|
||||
|
||||
const icons: BaseIconObject[] = createSpacedIcons();
|
||||
|
||||
// Assign random colors to each icon
|
||||
const iconsWithColors: IconWithColors[] = icons.map(icon => ({
|
||||
const iconsWithColors: IconWithColors[] = icons.map((icon) => ({
|
||||
icon: icon.icon,
|
||||
x: icon.x,
|
||||
y: icon.y,
|
||||
@@ -227,14 +219,16 @@ const iconsWithColors: IconWithColors[] = icons.map(icon => ({
|
||||
|
||||
{/* Decorative background icons with random placement */}
|
||||
<div id="background-icons" class="absolute inset-0 overflow-hidden">
|
||||
{iconsWithColors.map(({ icon, x, y, size, opacity, rotate, lightColor, darkColor, visibilityClass }) => (
|
||||
<div
|
||||
class={`absolute ${lightColor} ${darkColor} ${visibilityClass}`}
|
||||
style={`left: ${x}; top: ${y}; opacity: ${opacity}; transform: rotate(${rotate});`}
|
||||
>
|
||||
<Icon name={icon} style={`width: ${size}; height: ${size};`} />
|
||||
</div>
|
||||
))}
|
||||
{
|
||||
iconsWithColors.map(({ icon, x, y, size, opacity, rotate, lightColor, darkColor, visibilityClass }) => (
|
||||
<div
|
||||
class={`absolute ${lightColor} ${darkColor} ${visibilityClass}`}
|
||||
style={`left: ${x}; top: ${y}; opacity: ${opacity}; transform: rotate(${rotate});`}
|
||||
>
|
||||
<Icon name={icon} style={`width: ${size}; height: ${size};`} />
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@@ -20,16 +20,24 @@ const {
|
||||
(title || subtitle || tagline) && (
|
||||
<div class={twMerge('mb-8 md:mx-auto md:mb-12 text-center', containerClass)}>
|
||||
{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 && (
|
||||
<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}
|
||||
/>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
@@ -2,25 +2,44 @@
|
||||
// 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 -->
|
||||
<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 -->
|
||||
<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 -->
|
||||
<button
|
||||
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"
|
||||
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">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
<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"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- 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">
|
||||
<img
|
||||
id="modal-image"
|
||||
@@ -60,7 +79,7 @@
|
||||
|
||||
// Create a temporary image to get the natural dimensions
|
||||
const tempImg = new Image();
|
||||
tempImg.onload = function() {
|
||||
tempImg.onload = function () {
|
||||
// Calculate the certification section height (75% of viewport height as defined in the container)
|
||||
const certSectionHeight = window.innerHeight * 0.75;
|
||||
const maxHeight = certSectionHeight * 0.75; // 75% of the certification section height
|
||||
|
@@ -19,40 +19,56 @@ const {
|
||||
{
|
||||
items && (
|
||||
<div
|
||||
class={twMerge(
|
||||
`grid mx-auto gap-8 md:gap-y-12 ${
|
||||
columns === 4
|
||||
? 'lg:grid-cols-4 md:grid-cols-3 sm:grid-cols-2'
|
||||
: columns === 3
|
||||
? 'lg:grid-cols-3 sm:grid-cols-2'
|
||||
: columns === 2
|
||||
? 'sm:grid-cols-2 '
|
||||
: ''
|
||||
} grid-flow-row auto-rows-fr text-center sm:text-left`,
|
||||
containerClass
|
||||
)}
|
||||
>
|
||||
class={twMerge(
|
||||
`grid mx-auto gap-8 md:gap-y-12 ${
|
||||
columns === 4
|
||||
? 'lg:grid-cols-4 md:grid-cols-3 sm:grid-cols-2'
|
||||
: columns === 3
|
||||
? 'lg:grid-cols-3 sm:grid-cols-2'
|
||||
: columns === 2
|
||||
? 'sm:grid-cols-2 '
|
||||
: ''
|
||||
} grid-flow-row auto-rows-fr text-center sm:text-left`,
|
||||
containerClass
|
||||
)}
|
||||
>
|
||||
{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={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 class="flex justify-center sm:justify-start flex-shrink-0 mb-2 sm:mb-0">
|
||||
{(icon || defaultIcon) && (
|
||||
<Icon
|
||||
name={icon || defaultIcon}
|
||||
class={twMerge('w-7 h-7 sm:mr-2 rtl:sm:mr-0 rtl:sm:ml-2', defaultIconClass, itemClasses?.icon)}
|
||||
/>
|
||||
)}
|
||||
</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}
|
||||
/>
|
||||
)}
|
||||
<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 class="flex justify-center sm:justify-start flex-shrink-0 mb-2 sm:mb-0">
|
||||
{(icon || defaultIcon) && (
|
||||
<Icon
|
||||
name={icon || defaultIcon}
|
||||
class={twMerge('w-7 h-7 sm:mr-2 rtl:sm:mr-0 rtl:sm:ml-2', defaultIconClass, itemClasses?.icon)}
|
||||
/>
|
||||
)}
|
||||
</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 && (
|
||||
<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} />
|
||||
</div>
|
||||
)}
|
||||
|
@@ -45,7 +45,10 @@ const {
|
||||
)}
|
||||
<div class={twMerge('text-xl font-bold', titleClass, itemClasses?.title)}>{title}</div>
|
||||
{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 && (
|
||||
<div class="mt-2">
|
||||
|
@@ -26,9 +26,12 @@ const {
|
||||
|
||||
{
|
||||
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 */}
|
||||
<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">
|
||||
{items.map((item, index) => {
|
||||
@@ -51,16 +54,24 @@ const {
|
||||
<div class={`relative ${compact ? 'mb-6' : 'mb-12'}`}>
|
||||
{/* Year marker (if available) */}
|
||||
{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="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}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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 */}
|
||||
<div
|
||||
@@ -81,29 +92,51 @@ const {
|
||||
{(icon || defaultIcon) && (
|
||||
<Icon
|
||||
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>
|
||||
{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="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
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Connector line to timeline (visible only on desktop) */}
|
||||
<div class={twMerge(
|
||||
'absolute top-5 hidden md:block h-0.5 w-10 z-0',
|
||||
isEven ? 'right-0 bg-gradient-to-r' : 'left-0 bg-gradient-to-l',
|
||||
'from-transparent to-blue-700 dark:to-blue-700'
|
||||
)}></div>
|
||||
<div
|
||||
class={twMerge(
|
||||
'absolute top-5 hidden md:block h-0.5 w-10 z-0',
|
||||
isEven ? 'right-0 bg-gradient-to-r' : 'left-0 bg-gradient-to-l',
|
||||
'from-transparent to-blue-700 dark:to-blue-700'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -130,15 +163,18 @@ const {
|
||||
.card-container {
|
||||
min-height: 0;
|
||||
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;
|
||||
}
|
||||
|
||||
/* Hover effect for details */
|
||||
[data-details] {
|
||||
transform-origin: top;
|
||||
transition: 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);
|
||||
transition:
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -153,7 +189,9 @@ const {
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
to { opacity: 1; }
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
|
@@ -100,28 +100,22 @@ const {
|
||||
|
||||
{/* Responsive styles for small screens */}
|
||||
<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) {
|
||||
#timeline-item-0,
|
||||
#timeline-item-1,
|
||||
#timeline-item-2,
|
||||
#timeline-item-3,
|
||||
#timeline-item-4 {
|
||||
[id^="timeline-item-"] {
|
||||
margin-top: 0 !important;
|
||||
margin-left: 2rem !important;
|
||||
margin-right: 0 !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>
|
||||
</div>
|
||||
)
|
||||
|
@@ -16,13 +16,13 @@ const WrapperTag = as;
|
||||
---
|
||||
|
||||
<WrapperTag class="relative not-prose scroll-mt-[72px]" {...id ? { id } : {}}>
|
||||
{!disableBackground && (
|
||||
<div class="absolute inset-0 pointer-events-none -z-[1]" aria-hidden="true">
|
||||
<slot name="bg">
|
||||
{bg ? <Fragment set:html={bg} /> : <Background isDark={isDark} />}
|
||||
</slot>
|
||||
</div>
|
||||
)}
|
||||
{
|
||||
!disableBackground && (
|
||||
<div class="absolute inset-0 pointer-events-none -z-[1]" aria-hidden="true">
|
||||
<slot name="bg">{bg ? <Fragment set:html={bg} /> : <Background isDark={isDark} />}</slot>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<div
|
||||
class:list={[
|
||||
twMerge(
|
||||
|
@@ -52,7 +52,9 @@ const posts = APP_BLOG.isEnabled ? await findLatestPosts({ count }) : [];
|
||||
</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>
|
||||
|
||||
<Grid posts={posts} />
|
||||
|
@@ -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">
|
||||
{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]">
|
||||
<div class="flex items-center mb-2">
|
||||
<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>
|
||||
{
|
||||
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]">
|
||||
<div class="flex items-center mb-2">
|
||||
<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 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>
|
||||
</WidgetWrapper>
|
||||
|
||||
|
@@ -44,7 +44,11 @@ const {
|
||||
<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: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 && (
|
||||
|
@@ -27,23 +27,25 @@ export interface Props {
|
||||
import { supportedLanguages } from '~/i18n/translations';
|
||||
|
||||
// Define the type for supported languages
|
||||
type SupportedLanguage = typeof supportedLanguages[number];
|
||||
type SupportedLanguage = (typeof supportedLanguages)[number];
|
||||
|
||||
// Get current language from URL
|
||||
const currentPath = `/${Astro.url.pathname.replace(/^\/+|\/+$/g, '')}`;
|
||||
const pathSegments = currentPath.split('/').filter(Boolean);
|
||||
|
||||
// Check for language in URL path
|
||||
let currentLang = pathSegments[0] && supportedLanguages.includes(pathSegments[0] as SupportedLanguage)
|
||||
? pathSegments[0] as SupportedLanguage
|
||||
: null;
|
||||
let currentLang =
|
||||
pathSegments[0] && supportedLanguages.includes(pathSegments[0] as SupportedLanguage)
|
||||
? (pathSegments[0] as SupportedLanguage)
|
||||
: null;
|
||||
|
||||
// If no language in URL, check cookies
|
||||
if (!currentLang) {
|
||||
const cookies = Astro.request.headers.get('cookie') || '';
|
||||
const cookieLanguage = cookies.split(';')
|
||||
.map(cookie => cookie.trim())
|
||||
.find(cookie => cookie.startsWith('preferredLanguage='))
|
||||
const cookieLanguage = cookies
|
||||
.split(';')
|
||||
.map((cookie) => cookie.trim())
|
||||
.find((cookie) => cookie.startsWith('preferredLanguage='))
|
||||
?.split('=')[1];
|
||||
|
||||
if (cookieLanguage && supportedLanguages.includes(cookieLanguage as SupportedLanguage)) {
|
||||
@@ -60,7 +62,7 @@ const footerData = getFooterData(currentLang);
|
||||
const {
|
||||
secondaryLinks = footerData.secondaryLinks,
|
||||
socialLinks = footerData.socialLinks,
|
||||
theme = 'light'
|
||||
theme = 'light',
|
||||
} = Astro.props;
|
||||
---
|
||||
|
||||
@@ -69,10 +71,8 @@ const {
|
||||
<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"
|
||||
>
|
||||
|
||||
<!-- ✅ 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">
|
||||
|
||||
<!-- Left Section: Company Name and Business Details -->
|
||||
<div class="flex flex-col items-start space-y-2">
|
||||
<!-- Site Title -->
|
||||
@@ -86,39 +86,28 @@ const {
|
||||
</a>
|
||||
<!-- Business Information (Dutch Law Requirements) -->
|
||||
<div class="text-sm text-white-500 space-y-1">
|
||||
<p>KVK: 87654321 | BTW: NL123456789B01</p>
|
||||
<p>info@365devnet.eu</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Section: Social Icons and Terms/Privacy Links -->
|
||||
<div class="flex flex-col items-start md:items-end space-y-4">
|
||||
<!-- Social Icons -->
|
||||
{
|
||||
socialLinks?.length && (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
<div class="text-sm text-white-500 space-y-1">
|
||||
<p>KVK: 87654321 | BTW: NL123456789B01</p>
|
||||
</div>
|
||||
|
||||
<!-- Terms & Privacy Policy Links -->
|
||||
<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}>
|
||||
{text}
|
||||
</a>
|
||||
))}
|
||||
{
|
||||
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}
|
||||
>
|
||||
{text}
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -7,7 +7,7 @@ import ToggleMenu from '~/components/common/ToggleMenu.astro';
|
||||
import LanguageDropdown from '~/components/LanguageDropdown.astro';
|
||||
|
||||
import { getHomePermalink } from '~/utils/permalinks';
|
||||
import { trimSlash, getAsset } from '~/utils/permalinks';
|
||||
import { trimSlash } from '~/utils/permalinks';
|
||||
import { getHeaderData } from '~/navigation';
|
||||
|
||||
interface Link {
|
||||
@@ -40,19 +40,21 @@ const currentPath = `/${trimSlash(new URL(Astro.url).pathname)}`;
|
||||
const pathSegments = currentPath.split('/').filter(Boolean);
|
||||
|
||||
// Define the type for supported languages
|
||||
type SupportedLanguage = typeof supportedLanguages[number];
|
||||
type SupportedLanguage = (typeof supportedLanguages)[number];
|
||||
|
||||
// Check for language in URL path
|
||||
let currentLang = pathSegments[0] && supportedLanguages.includes(pathSegments[0] as SupportedLanguage)
|
||||
? pathSegments[0] as SupportedLanguage
|
||||
: null;
|
||||
let currentLang =
|
||||
pathSegments[0] && supportedLanguages.includes(pathSegments[0] as SupportedLanguage)
|
||||
? (pathSegments[0] as SupportedLanguage)
|
||||
: null;
|
||||
|
||||
// If no language in URL, check cookies
|
||||
if (!currentLang) {
|
||||
const cookies = Astro.request.headers.get('cookie') || '';
|
||||
const cookieLanguage = cookies.split(';')
|
||||
.map(cookie => cookie.trim())
|
||||
.find(cookie => cookie.startsWith('preferredLanguage='))
|
||||
const cookieLanguage = cookies
|
||||
.split(';')
|
||||
.map((cookie) => cookie.trim())
|
||||
.find((cookie) => cookie.startsWith('preferredLanguage='))
|
||||
?.split('=')[1];
|
||||
|
||||
if (cookieLanguage && supportedLanguages.includes(cookieLanguage as SupportedLanguage)) {
|
||||
@@ -65,7 +67,6 @@ if (!currentLang) {
|
||||
|
||||
// Get translated header data - ensure we're using the current language
|
||||
const headerData = getHeaderData(currentLang);
|
||||
console.log(`Header initialized with language: ${currentLang}`);
|
||||
|
||||
const {
|
||||
id = 'header',
|
||||
@@ -74,7 +75,7 @@ const {
|
||||
isDark = false,
|
||||
isFullWidth = false,
|
||||
showToggleTheme = false,
|
||||
showRssFeed = false,
|
||||
|
||||
position = 'center',
|
||||
} = Astro.props;
|
||||
---
|
||||
@@ -93,7 +94,7 @@ const {
|
||||
'relative text-default py-3 px-3 md:px-6 mx-auto w-full',
|
||||
{
|
||||
'md:flex md:justify-between': position !== 'center',
|
||||
},
|
||||
},
|
||||
{
|
||||
'md:grid md:grid-cols-3 md:items-center': position === 'center',
|
||||
},
|
||||
@@ -112,7 +113,8 @@ const {
|
||||
<!-- Improved mobile navigation accessibility -->
|
||||
<style>
|
||||
@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 */
|
||||
min-height: 44px; /* Minimum touch target size */
|
||||
display: flex;
|
||||
|
@@ -26,13 +26,13 @@ const {
|
||||
---
|
||||
|
||||
<section class="relative md:-mt-[76px] not-prose" {...id ? { id } : {}}>
|
||||
{!disableBackground && (
|
||||
<div class="absolute inset-0 pointer-events-none" aria-hidden="true">
|
||||
<slot name="bg">
|
||||
{bg ? <Fragment set:html={bg} /> : <Background isDark={isDark} disableParallax={true} />}
|
||||
</slot>
|
||||
</div>
|
||||
)}
|
||||
{
|
||||
!disableBackground && (
|
||||
<div class="absolute inset-0 pointer-events-none" aria-hidden="true">
|
||||
<slot name="bg">{bg ? <Fragment set:html={bg} /> : <Background isDark={isDark} disableParallax={true} />}</slot>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<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="py-12 md:py-20">
|
||||
|
@@ -26,7 +26,12 @@ const {
|
||||
{
|
||||
testimonials &&
|
||||
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>}
|
||||
{image && (
|
||||
<div class="h-[160px] w-[160px] border-slate-200 dark:border-slate-600 mx-auto">
|
||||
@@ -57,7 +62,6 @@ const {
|
||||
}
|
||||
</div>
|
||||
|
||||
|
||||
{
|
||||
callToAction && (
|
||||
<div class="flex justify-center mx-auto w-fit mt-8 md:mt-12 font-medium">
|
||||
@@ -68,12 +72,12 @@ const {
|
||||
</WidgetWrapper>
|
||||
|
||||
<style>
|
||||
@media (max-width: 640px) {
|
||||
.grid {
|
||||
justify-content: center;
|
||||
@media (max-width: 640px) {
|
||||
.grid {
|
||||
justify-content: center;
|
||||
}
|
||||
.grid > a {
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
.grid > a {
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
@@ -32,7 +32,7 @@ const {
|
||||
} = Astro.props as Props;
|
||||
|
||||
// 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
|
||||
let year: string | undefined = undefined;
|
||||
if (item.date) {
|
||||
@@ -56,15 +56,21 @@ const timelineItems = items.map(item => {
|
||||
|
||||
<WidgetWrapper id={id} isDark={isDark} bg={bg} classes={classes}>
|
||||
<div class={`flex flex-col gap-${compact ? '8' : '12'} md:gap-${compact ? '12' : '16'}`}>
|
||||
{title && (
|
||||
<div class="flex flex-col gap-4 text-center">
|
||||
{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>}
|
||||
</div>
|
||||
)}
|
||||
{
|
||||
title && (
|
||||
<div class="flex flex-col gap-4 text-center">
|
||||
{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>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<ModernTimeline
|
||||
items={timelineItems}
|
||||
compact={compact}
|
||||
|
@@ -9,7 +9,7 @@ metadata:
|
||||
title:
|
||||
default: 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:
|
||||
index: true
|
||||
follow: true
|
||||
|
@@ -1,11 +1,11 @@
|
||||
---
|
||||
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."
|
||||
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.'
|
||||
publishDate: 2025-02-26T02:30:00Z
|
||||
author: "Richard Bergsma"
|
||||
author: 'Richard Bergsma'
|
||||
category: Automation
|
||||
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
|
||||
@@ -97,6 +97,7 @@ Select and configure the appropriate authentication type:
|
||||
- **Windows authentication** – For internal corporate resources
|
||||
|
||||
For OAuth 2.0, you'll need to provide:
|
||||
|
||||
- Client ID and secret
|
||||
- Authorization and token URLs
|
||||
- Scope information
|
||||
@@ -109,6 +110,7 @@ Operations represent the API endpoints your connector will expose:
|
||||
|
||||
1. Click **New action** to add an endpoint
|
||||
2. Configure:
|
||||
|
||||
- **Summary** – Short description of what the operation does
|
||||
- **Description** – Detailed explanation
|
||||
- **Operation ID** – Unique identifier (no spaces)
|
||||
@@ -129,6 +131,7 @@ For each operation, define:
|
||||
- **Body parameters** – Data sent in the request body
|
||||
|
||||
For each parameter, specify:
|
||||
|
||||
- Name and description
|
||||
- Whether it's required
|
||||
- Data type and format
|
||||
@@ -205,6 +208,7 @@ A manufacturing company needed to connect their modern Power Apps solution with
|
||||

|
||||
|
||||
**Results:**
|
||||
|
||||
- Avoided costly system replacement
|
||||
- Enabled mobile inventory management
|
||||
- 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.
|
||||
|
||||
**Results:**
|
||||
|
||||
- Automated patient record updates
|
||||
- Reduced administrative workload
|
||||
- Improved data accuracy and compliance
|
||||
@@ -229,6 +234,7 @@ A financial services firm with a microservices architecture needed to orchestrat
|
||||

|
||||
|
||||
**Results:**
|
||||
|
||||
- Simplified complex cross-service workflows
|
||||
- Enabled business users to create automations
|
||||
- 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.
|
||||
|
||||
**Solutions:**
|
||||
|
||||
- Verify credentials and client IDs/secrets
|
||||
- Check for expired tokens or certificates
|
||||
- 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.
|
||||
|
||||
**Solutions:**
|
||||
|
||||
- Compare your request schema with the API documentation
|
||||
- Use the API's test endpoints to validate request formats
|
||||
- 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.
|
||||
|
||||
**Solutions:**
|
||||
|
||||
- Implement pagination for large data sets
|
||||
- Add appropriate timeouts in your connector definition
|
||||
- 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.
|
||||
|
||||
**Solutions:**
|
||||
|
||||
- Check environment-specific configurations
|
||||
- Verify network connectivity and firewall rules
|
||||
- Ensure service accounts have appropriate permissions
|
||||
|
@@ -1,21 +1,21 @@
|
||||
---
|
||||
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."
|
||||
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.'
|
||||
publishDate: 2025-02-16T02:00:00Z
|
||||
author: "Richard Bergsma"
|
||||
author: 'Richard Bergsma'
|
||||
category: Security
|
||||
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
|
||||
|
||||
|
||||
## 🔍 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.
|
||||
|
||||
With **Enterprise App Protection**, you get **real-time phishing detection** to help prevent accidental clicks on **fraudulent links** in:
|
||||
|
||||
- **Emails**
|
||||
- **Microsoft Teams chats**
|
||||
- **SharePoint documents**
|
||||
@@ -66,9 +66,11 @@ Unlike many security extensions, **Enterprise App Protection prioritizes user pr
|
||||
## 📥 How to Install
|
||||
|
||||
### **🔹 From the Chrome Web Store (Coming Soon)**
|
||||
|
||||
Once published, the extension will be available directly from the Chrome Web Store.
|
||||
|
||||
### **🔹 Manually Install (Developer Mode)**
|
||||
|
||||
1️⃣ **Download the latest version** from the [GitHub repository](https://github.com/rrpbergsma/EnterpriseAppProtection).
|
||||
2️⃣ Open **`chrome://extensions/`** in Chrome or Edge.
|
||||
3️⃣ Enable **Developer Mode** (top-right corner).
|
||||
|
@@ -68,6 +68,7 @@ Organizations face increasing challenges in managing complex IT environments, es
|
||||
One of Nexthink’s 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:
|
||||
|
||||
- **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.
|
||||
- **Silent Operations:** Perform actions without disrupting the end user’s workflow.
|
||||
@@ -90,13 +91,16 @@ Nexthink Flow allows IT teams to create **multi-step, conditional workflows** th
|
||||

|
||||
|
||||
### 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.
|
||||
- **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.
|
||||
- **Real-Time Feedback:** Monitor the success or failure of each step in the workflow for quick diagnostics and optimization.
|
||||
|
||||
### Example Use Case:
|
||||
|
||||
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.
|
||||
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.
|
||||
|
@@ -17,7 +17,7 @@ export function getUserConfirmationHtml(props: UserConfirmationProps): string {
|
||||
message,
|
||||
submittedAt,
|
||||
websiteName = process.env.WEBSITE_NAME || '365devnet.eu',
|
||||
contactEmail = process.env.ADMIN_EMAIL || 'richard@bergsma.it'
|
||||
contactEmail = process.env.ADMIN_EMAIL || 'richard@bergsma.it',
|
||||
} = props;
|
||||
|
||||
return `
|
||||
@@ -97,7 +97,7 @@ export function getUserConfirmationText(props: UserConfirmationProps): string {
|
||||
message,
|
||||
submittedAt,
|
||||
websiteName = process.env.WEBSITE_NAME || '365devnet.eu',
|
||||
contactEmail = process.env.ADMIN_EMAIL || 'richard@bergsma.it'
|
||||
contactEmail = process.env.ADMIN_EMAIL || 'richard@bergsma.it',
|
||||
} = props;
|
||||
|
||||
return `
|
||||
|
File diff suppressed because it is too large
Load Diff
@@ -34,6 +34,10 @@ const { language, textDirection } = I18N;
|
||||
<html lang={language} dir={textDirection} class="2xl:text-[20px]">
|
||||
<head>
|
||||
<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 />
|
||||
<CustomStyles />
|
||||
<ApplyColorMode />
|
||||
@@ -42,17 +46,19 @@ const { language, textDirection } = I18N;
|
||||
<Analytics />
|
||||
|
||||
<!-- Structured Data for SEO -->
|
||||
<StructuredData data={{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebSite",
|
||||
"name": "365DevNet",
|
||||
"url": Astro.url.origin,
|
||||
"potentialAction": {
|
||||
"@type": "SearchAction",
|
||||
"target": `${Astro.url.origin}/search?q={search_term_string}`,
|
||||
"query-input": "required name=search_term_string"
|
||||
}
|
||||
}} />
|
||||
<StructuredData
|
||||
data={{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebSite',
|
||||
name: '365DevNet',
|
||||
url: Astro.url.origin,
|
||||
potentialAction: {
|
||||
'@type': 'SearchAction',
|
||||
target: `${Astro.url.origin}/search?q={search_term_string}`,
|
||||
'query-input': 'required name=search_term_string',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<!-- Comment the line below to disable View Transitions -->
|
||||
<ClientRouter fallback="swap" />
|
||||
|
@@ -27,17 +27,25 @@ export const getHeaderData = (lang = 'en') => {
|
||||
links: [
|
||||
{ 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.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.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
|
||||
export const headerData = (lang = 'en') => getHeaderData(lang);
|
||||
// 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 getFooterData = (lang = 'en') => {
|
||||
const t = getTranslation(lang);
|
||||
|
@@ -8,12 +8,12 @@ import CompactSteps from '~/components/widgets/CompactSteps.astro';
|
||||
import WorkExperience from '~/components/widgets/WorkExperience.astro';
|
||||
import CompactCertifications from '~/components/widgets/CompactCertifications.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';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
return supportedLanguages.map(lang => ({
|
||||
return supportedLanguages.map((lang) => ({
|
||||
params: { lang },
|
||||
}));
|
||||
}
|
||||
@@ -26,7 +26,8 @@ if (!supportedLanguages.includes(lang)) {
|
||||
const t = getTranslation(lang);
|
||||
|
||||
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.',
|
||||
};
|
||||
---
|
||||
|
||||
@@ -34,31 +35,28 @@ const metadata = {
|
||||
<Fragment slot="announcement"></Fragment>
|
||||
|
||||
<!-- Person Structured Data for SEO -->
|
||||
<StructuredData slot="structured-data" data={{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Person",
|
||||
"name": "Richard Bergsma",
|
||||
"jobTitle": "IT Systems and Automation Manager",
|
||||
"description": t.hero.subtitle,
|
||||
"image": Astro.url.origin + "/images/richardbergsma.png",
|
||||
"url": Astro.url.origin,
|
||||
"sameAs": [
|
||||
"https://www.linkedin.com/in/rrpbergsma",
|
||||
"https://github.com/rrpbergsma"
|
||||
],
|
||||
"knowsAbout": t.skills.items.map(skill => skill.title),
|
||||
"worksFor": {
|
||||
"@type": "Organization",
|
||||
"name": "COFRA Holding C.V.",
|
||||
"location": "Amsterdam"
|
||||
}
|
||||
}} />
|
||||
<StructuredData
|
||||
slot="structured-data"
|
||||
data={{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Person',
|
||||
name: 'Richard Bergsma',
|
||||
jobTitle: 'IT Systems and Automation Manager',
|
||||
description: t.hero.subtitle,
|
||||
image: Astro.url.origin + '/images/richardbergsma.png',
|
||||
url: Astro.url.origin,
|
||||
sameAs: ['https://www.linkedin.com/in/rrpbergsma', 'https://github.com/rrpbergsma'],
|
||||
knowsAbout: t.skills.items.map((skill) => skill.title),
|
||||
worksFor: {
|
||||
'@type': 'Organization',
|
||||
name: 'COFRA Holding C.V.',
|
||||
location: 'Amsterdam',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<!-- Hero Widget -->
|
||||
<Hero
|
||||
id="hero"
|
||||
isDark={false}
|
||||
>
|
||||
<Hero id="hero" isDark={false}>
|
||||
<Fragment slot="subtitle">
|
||||
<strong class="text-3xl md:text-4xl">{t.hero.greeting}</strong><br /><br />{t.hero.subtitle}
|
||||
</Fragment>
|
||||
@@ -77,10 +75,14 @@ const metadata = {
|
||||
>
|
||||
<Fragment slot="content">
|
||||
<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>
|
||||
<br />
|
||||
))}
|
||||
{
|
||||
t.about.content.map((paragraph) => (
|
||||
<>
|
||||
<p>{paragraph}</p>
|
||||
<br />
|
||||
</>
|
||||
))
|
||||
}
|
||||
</Fragment>
|
||||
|
||||
<Fragment slot="bg">
|
||||
@@ -93,7 +95,7 @@ const metadata = {
|
||||
id="resume"
|
||||
title={t.resume.title}
|
||||
compact={true}
|
||||
items={t.resume.experience.map(exp => ({
|
||||
items={t.resume.experience.map((exp) => ({
|
||||
title: exp.title,
|
||||
company: exp.company,
|
||||
date: exp.period,
|
||||
@@ -113,7 +115,7 @@ const metadata = {
|
||||
issueDate: cert.issueDate,
|
||||
description: cert.description,
|
||||
linkUrl: cert.linkUrl,
|
||||
image: cert.image
|
||||
image: cert.image,
|
||||
}))}
|
||||
/>
|
||||
|
||||
@@ -123,7 +125,7 @@ const metadata = {
|
||||
title={t.skills.title}
|
||||
subtitle={t.skills.subtitle}
|
||||
defaultIcon="tabler:point-filled"
|
||||
items={t.skills.items.map(item => ({
|
||||
items={t.skills.items.map((item) => ({
|
||||
title: item.title,
|
||||
description: item.description,
|
||||
}))}
|
||||
@@ -133,9 +135,9 @@ const metadata = {
|
||||
<CompactSteps
|
||||
id="education"
|
||||
title={t.education.title}
|
||||
items={t.education.items.map(item => ({
|
||||
items={t.education.items.map((item) => ({
|
||||
title: item.title,
|
||||
icon: 'tabler:school'
|
||||
icon: 'tabler:school',
|
||||
}))}
|
||||
/>
|
||||
</Layout>
|
@@ -11,7 +11,7 @@ import { getTranslation, supportedLanguages } from '~/i18n/translations';
|
||||
import OurCommitmentImage from '~/assets/images/OurCommitment.webp';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
return supportedLanguages.map(lang => ({
|
||||
return supportedLanguages.map((lang) => ({
|
||||
params: { lang },
|
||||
}));
|
||||
}
|
||||
@@ -52,72 +52,75 @@ const metadata = {
|
||||
<!-- Features Widget -->
|
||||
<Features
|
||||
id="services"
|
||||
tagline={t.homepage?.services?.tagline || "Services"}
|
||||
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."}
|
||||
items={(t.homepage?.services?.items || [
|
||||
{
|
||||
title: 'Workflow 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.',
|
||||
icon: 'tabler:message-chatbot',
|
||||
},
|
||||
{
|
||||
title: 'API Integrations',
|
||||
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.',
|
||||
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.',
|
||||
icon: 'tabler:share',
|
||||
},
|
||||
{
|
||||
title: 'IT Infrastructure Oversight',
|
||||
description:
|
||||
'Manage global IT infrastructures, including servers, networks, and end-user devices to ensure reliable operations.',
|
||||
icon: 'tabler:server',
|
||||
},
|
||||
]).map(item => ({...item, icon: item.icon || 'tabler:check'}))}
|
||||
tagline={t.homepage?.services?.tagline || 'Services'}
|
||||
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.'}
|
||||
items={(
|
||||
t.homepage?.services?.items || [
|
||||
{
|
||||
title: 'Workflow 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.',
|
||||
icon: 'tabler:message-chatbot',
|
||||
},
|
||||
{
|
||||
title: 'API Integrations',
|
||||
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.',
|
||||
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.',
|
||||
icon: 'tabler:share',
|
||||
},
|
||||
{
|
||||
title: 'IT Infrastructure Oversight',
|
||||
description:
|
||||
'Manage global IT infrastructures, including servers, networks, and end-user devices to ensure reliable operations.',
|
||||
icon: 'tabler:server',
|
||||
},
|
||||
]
|
||||
).map((item) => ({ ...item, icon: item.icon || 'tabler:check' }))}
|
||||
/>
|
||||
|
||||
<!-- Content Widget -->
|
||||
<Content
|
||||
isReversed
|
||||
tagline={t.homepage?.approach?.tagline || "About My Approach"}
|
||||
title={t.homepage?.approach?.title || "Our Commitment"}
|
||||
tagline={t.homepage?.approach?.tagline || 'About My Approach'}
|
||||
title={t.homepage?.approach?.title || 'Our Commitment'}
|
||||
items={[]}
|
||||
image={{
|
||||
src: OurCommitmentImage,
|
||||
alt: 'IT Excellence and Innovation'
|
||||
alt: 'IT Excellence and Innovation',
|
||||
}}
|
||||
>
|
||||
<Fragment slot="content">
|
||||
<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.',
|
||||
'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.',
|
||||
'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.'
|
||||
]).map((paragraph, index, array) => (
|
||||
<p class={index === array.length - 1 ? '' : 'mb-4'}>
|
||||
{paragraph}
|
||||
</p>
|
||||
))}
|
||||
{
|
||||
(
|
||||
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.',
|
||||
'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.',
|
||||
'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.',
|
||||
]
|
||||
).map((paragraph, index, array) => <p class={index === array.length - 1 ? '' : 'mb-4'}>{paragraph}</p>)
|
||||
}
|
||||
</div>
|
||||
</Fragment>
|
||||
</Content>
|
||||
@@ -164,15 +167,19 @@ const metadata = {
|
||||
>
|
||||
<Fragment slot="title">{t.homepage?.callToAction?.title || 'Ready to optimize your IT systems?'}</Fragment>
|
||||
<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>
|
||||
</CallToAction>
|
||||
|
||||
<!-- Contact Widget -->
|
||||
<Contact
|
||||
id="contact"
|
||||
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."}
|
||||
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."}
|
||||
inputs={[
|
||||
{
|
||||
type: 'text',
|
||||
@@ -193,11 +200,11 @@ const metadata = {
|
||||
rows: 8,
|
||||
}}
|
||||
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.',
|
||||
}}
|
||||
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>
|
@@ -7,7 +7,7 @@ import Hero from '~/components/widgets/Hero.astro';
|
||||
import { getTranslation, supportedLanguages } from '~/i18n/translations';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
return supportedLanguages.map(lang => ({
|
||||
return supportedLanguages.map((lang) => ({
|
||||
params: { lang },
|
||||
}));
|
||||
}
|
||||
@@ -43,29 +43,26 @@ const tocItems = [
|
||||
<Fragment slot="announcement"></Fragment>
|
||||
|
||||
<!-- Legal Document Structured Data for SEO -->
|
||||
<StructuredData slot="structured-data" data={{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebPage",
|
||||
"name": "Privacy Policy",
|
||||
"description": "Privacy Policy outlining our data collection practices, cookie usage, and your rights under GDPR.",
|
||||
"url": Astro.url.origin + "/" + lang + "/privacy",
|
||||
"mainEntity": {
|
||||
"@type": "Article",
|
||||
"headline": "Privacy Policy",
|
||||
"datePublished": "2025-03-06",
|
||||
"dateModified": "2025-03-06"
|
||||
}
|
||||
}} />
|
||||
<StructuredData
|
||||
slot="structured-data"
|
||||
data={{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebPage',
|
||||
name: 'Privacy Policy',
|
||||
description: 'Privacy Policy outlining our data collection practices, cookie usage, and your rights under GDPR.',
|
||||
url: Astro.url.origin + '/' + lang + '/privacy',
|
||||
mainEntity: {
|
||||
'@type': 'Article',
|
||||
headline: 'Privacy Policy',
|
||||
datePublished: '2025-03-06',
|
||||
dateModified: '2025-03-06',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<!-- Hero Widget -->
|
||||
<Hero
|
||||
id="hero"
|
||||
title={t.footer.privacyPolicy}
|
||||
isDark={false}
|
||||
>
|
||||
<Fragment slot="subtitle">
|
||||
Last updated: March 6, 2025 (Added cookie consent banner)
|
||||
</Fragment>
|
||||
<Hero id="hero" title={t.footer.privacyPolicy} isDark={false}>
|
||||
<Fragment slot="subtitle"> Last updated: March 6, 2025 (Added cookie consent banner) </Fragment>
|
||||
</Hero>
|
||||
|
||||
<!-- Content Widget -->
|
||||
@@ -74,37 +71,48 @@ const tocItems = [
|
||||
<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>
|
||||
<ul class="space-y-2">
|
||||
{tocItems.map(item => (
|
||||
<li>
|
||||
<a href={`#${item.id}`} class="text-blue-600 dark:text-blue-400 hover:underline">
|
||||
{item.title}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
{
|
||||
tocItems.map((item) => (
|
||||
<li>
|
||||
<a href={`#${item.id}`} class="text-blue-600 dark:text-blue-400 hover:underline">
|
||||
{item.title}
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
<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>
|
||||
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>
|
||||
<h2 id="data-collection" class="text-2xl font-bold mt-8 mb-4">2. Data Collection Policy</h2>
|
||||
<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>
|
||||
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.
|
||||
</p>
|
||||
<p>
|
||||
We do not:
|
||||
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.
|
||||
</p>
|
||||
<p>We do not:</p>
|
||||
<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>Use analytics services that collect personal data</li>
|
||||
<li>Use advertising or marketing tracking technologies</li>
|
||||
@@ -112,15 +120,16 @@ const tocItems = [
|
||||
<li>Store your preferences on our servers</li>
|
||||
</ul>
|
||||
<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>
|
||||
<h2 id="cookie-usage" class="text-2xl font-bold mt-8 mb-4">3. Cookie & Storage Usage</h2>
|
||||
<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.
|
||||
</p>
|
||||
<p>
|
||||
Details about the cookies we use:
|
||||
<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.
|
||||
</p>
|
||||
<p>Details about the cookies we use:</p>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Name:</strong> preferredLanguage
|
||||
@@ -142,20 +151,23 @@ const tocItems = [
|
||||
</li>
|
||||
</ul>
|
||||
<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>
|
||||
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>
|
||||
<h2 id="localstorage" class="text-2xl font-bold mt-8 mb-4">4. LocalStorage Usage</h2>
|
||||
<p>
|
||||
<strong>Our website uses LocalStorage to enhance your experience by remembering your preferences and consent choices.</strong>
|
||||
</p>
|
||||
<p>
|
||||
Details about our LocalStorage usage:
|
||||
<strong
|
||||
>Our website uses LocalStorage to enhance your experience by remembering your preferences and consent choices.</strong
|
||||
>
|
||||
</p>
|
||||
<p>Details about our LocalStorage usage:</p>
|
||||
<ul>
|
||||
<li><strong>Data stored:</strong>
|
||||
<li>
|
||||
<strong>Data stored:</strong>
|
||||
<ul>
|
||||
<li>Theme preference (light/dark mode)</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>
|
||||
</ul>
|
||||
<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>
|
||||
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>
|
||||
<h2 id="clear-preferences" class="text-2xl font-bold mt-8 mb-4">5. How to Clear Your Preferences</h2>
|
||||
<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>
|
||||
<strong>Chrome:</strong>
|
||||
@@ -208,43 +224,58 @@ const tocItems = [
|
||||
<li>Find our website and click "Remove" or "Remove All"</li>
|
||||
</ol>
|
||||
<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>
|
||||
<h2 id="user-rights" class="text-2xl font-bold mt-8 mb-4">6. Your Rights (GDPR Compliance)</h2>
|
||||
<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.
|
||||
</p>
|
||||
<p>
|
||||
Nevertheless, you have the right to:
|
||||
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.
|
||||
</p>
|
||||
<p>Nevertheless, you have the right to:</p>
|
||||
<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><strong>Be informed:</strong> This privacy policy provides transparent information about our data practices</li>
|
||||
<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>
|
||||
</ul>
|
||||
<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>
|
||||
<h2 id="data-security" class="text-2xl font-bold mt-8 mb-4">7. Data Security</h2>
|
||||
<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>
|
||||
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>
|
||||
<h2 id="third-party" class="text-2xl font-bold mt-8 mb-4">8. Third-Party Websites</h2>
|
||||
<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>
|
||||
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>
|
||||
<h2 id="changes" class="text-2xl font-bold mt-8 mb-4">9. Changes to Privacy Policy</h2>
|
||||
<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>
|
||||
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
|
||||
effective when they are posted on this page.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -7,7 +7,7 @@ import Hero from '~/components/widgets/Hero.astro';
|
||||
import { getTranslation, supportedLanguages } from '~/i18n/translations';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
return supportedLanguages.map(lang => ({
|
||||
return supportedLanguages.map((lang) => ({
|
||||
params: { lang },
|
||||
}));
|
||||
}
|
||||
@@ -41,29 +41,27 @@ const tocItems = [
|
||||
<Fragment slot="announcement"></Fragment>
|
||||
|
||||
<!-- Legal Document Structured Data for SEO -->
|
||||
<StructuredData slot="structured-data" data={{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebPage",
|
||||
"name": "Terms and Conditions",
|
||||
"description": "Terms and Conditions for our website, outlining user rights, responsibilities, and legal information.",
|
||||
"url": Astro.url.origin + "/" + lang + "/terms",
|
||||
"mainEntity": {
|
||||
"@type": "Article",
|
||||
"headline": "Terms and Conditions",
|
||||
"datePublished": "2025-03-06",
|
||||
"dateModified": "2025-03-06"
|
||||
}
|
||||
}} />
|
||||
<StructuredData
|
||||
slot="structured-data"
|
||||
data={{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebPage',
|
||||
name: 'Terms and Conditions',
|
||||
description:
|
||||
'Terms and Conditions for our website, outlining user rights, responsibilities, and legal information.',
|
||||
url: Astro.url.origin + '/' + lang + '/terms',
|
||||
mainEntity: {
|
||||
'@type': 'Article',
|
||||
headline: 'Terms and Conditions',
|
||||
datePublished: '2025-03-06',
|
||||
dateModified: '2025-03-06',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<!-- Hero Widget -->
|
||||
<Hero
|
||||
id="hero"
|
||||
title={t.footer.terms}
|
||||
isDark={false}
|
||||
>
|
||||
<Fragment slot="subtitle">
|
||||
Last updated: March 6, 2025
|
||||
</Fragment>
|
||||
<Hero id="hero" title={t.footer.terms} isDark={false}>
|
||||
<Fragment slot="subtitle"> Last updated: March 6, 2025 </Fragment>
|
||||
</Hero>
|
||||
|
||||
<!-- Content Widget -->
|
||||
@@ -72,74 +70,102 @@ const tocItems = [
|
||||
<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>
|
||||
<ul class="space-y-2">
|
||||
{tocItems.map(item => (
|
||||
<li>
|
||||
<a href={`#${item.id}`} class="text-blue-600 dark:text-blue-400 hover:underline">
|
||||
{item.title}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
{
|
||||
tocItems.map((item) => (
|
||||
<li>
|
||||
<a href={`#${item.id}`} class="text-blue-600 dark:text-blue-400 hover:underline">
|
||||
{item.title}
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
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>
|
||||
|
||||
<h2 id="scope" class="text-2xl font-bold mt-8 mb-4">1. Scope of Services</h2>
|
||||
<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>
|
||||
|
||||
<h2 id="user-rights" class="text-2xl font-bold mt-8 mb-4">2. User Rights & Responsibilities</h2>
|
||||
<p>
|
||||
When using our website, you agree to:
|
||||
</p>
|
||||
<p>When using our website, you agree to:</p>
|
||||
<ul>
|
||||
<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 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 the website to transmit any harmful code or material</li>
|
||||
</ul>
|
||||
|
||||
<h2 id="intellectual-property" class="text-2xl font-bold mt-8 mb-4">3. Intellectual Property</h2>
|
||||
<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>
|
||||
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>
|
||||
|
||||
<h2 id="liability" class="text-2xl font-bold mt-8 mb-4">4. Limitation of Liability</h2>
|
||||
<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>
|
||||
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>
|
||||
|
||||
<h2 id="governing-law" class="text-2xl font-bold mt-8 mb-4">5. Governing Law</h2>
|
||||
<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>
|
||||
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>
|
||||
|
||||
<h2 id="cookies" class="text-2xl font-bold mt-8 mb-4">6. Cookie Usage</h2>
|
||||
<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>
|
||||
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>
|
||||
|
||||
<h2 id="changes" class="text-2xl font-bold mt-8 mb-4">7. Changes to Terms</h2>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -4,24 +4,25 @@ import { supportedLanguages } from '~/i18n/translations';
|
||||
|
||||
// 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='))
|
||||
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];
|
||||
type SupportedLanguage = (typeof supportedLanguages)[number];
|
||||
|
||||
// Use cookie language if available, otherwise detect from browser
|
||||
const preferredLanguage =
|
||||
(cookieLanguage && supportedLanguages.includes(cookieLanguage as SupportedLanguage))
|
||||
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';
|
||||
.map((lang) => lang.split(';')[0].trim().substring(0, 2))
|
||||
.find((lang) => supportedLanguages.includes(lang as SupportedLanguage)) || 'en';
|
||||
|
||||
// Get the hash fragment if present
|
||||
const url = new URL(Astro.request.url);
|
||||
|
@@ -4,7 +4,7 @@ import {
|
||||
validateCsrfToken,
|
||||
checkRateLimit,
|
||||
sendAdminNotification,
|
||||
sendUserConfirmation
|
||||
sendUserConfirmation,
|
||||
} from '../../utils/email-handler';
|
||||
|
||||
// Enhanced email validation with more comprehensive regex
|
||||
@@ -23,19 +23,34 @@ const isSpam = (content: string, name: string, email: string): boolean => {
|
||||
|
||||
// Common spam keywords
|
||||
const spamPatterns = [
|
||||
'viagra', 'cialis', 'casino', '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'
|
||||
'viagra',
|
||||
'cialis',
|
||||
'casino',
|
||||
'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
|
||||
if (spamPatterns.some(pattern => lowerContent.includes(pattern))) {
|
||||
if (spamPatterns.some((pattern) => lowerContent.includes(pattern))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -46,7 +61,7 @@ const isSpam = (content: string, name: string, email: string): boolean => {
|
||||
}
|
||||
|
||||
// Check for excessive special characters
|
||||
const specialChars = "!@#$%^&*()_+-=[]{}\\|;:'\",.<>/?";
|
||||
const specialChars = '!@#$%^&*()_+-=[]{}\\|;:\'",.<>/?';
|
||||
let specialCharCount = 0;
|
||||
|
||||
for (let i = 0; i < content.length; i++) {
|
||||
@@ -80,13 +95,13 @@ export const GET: APIRoute = async ({ request }) => {
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
csrfToken
|
||||
csrfToken,
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -94,13 +109,13 @@ export const GET: APIRoute = async ({ request }) => {
|
||||
// Default response for GET requests
|
||||
return new Response(
|
||||
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,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -123,15 +138,15 @@ export const POST: APIRoute = async ({ request, clientAddress }) => {
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
errors: {
|
||||
rateLimit: rateLimitCheck.message
|
||||
}
|
||||
rateLimit: rateLimitCheck.message,
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Retry-After': '3600'
|
||||
}
|
||||
'Retry-After': '3600',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -149,7 +164,13 @@ export const POST: APIRoute = async ({ request, clientAddress }) => {
|
||||
const disclaimer = formData.get('disclaimer')?.toString() === 'on';
|
||||
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
|
||||
const userAgent = request.headers.get('user-agent') || 'Unknown';
|
||||
@@ -194,13 +215,13 @@ export const POST: APIRoute = async ({ request, clientAddress }) => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
errors
|
||||
errors,
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -221,13 +242,13 @@ export const POST: APIRoute = async ({ request, clientAddress }) => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
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,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -236,29 +257,28 @@ export const POST: APIRoute = async ({ request, clientAddress }) => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
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,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error processing contact form:', error);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
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,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
@@ -1,32 +1,14 @@
|
||||
---
|
||||
export const prerender = false;
|
||||
import { supportedLanguages } from '~/i18n/translations';
|
||||
|
||||
// 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';
|
||||
import { detectPreferredLanguage } from '~/utils/language';
|
||||
|
||||
// Get the hash fragment if present
|
||||
const url = new URL(Astro.request.url);
|
||||
const hash = url.hash;
|
||||
|
||||
// Detect preferred language
|
||||
const preferredLanguage = detectPreferredLanguage(Astro.request);
|
||||
|
||||
// Redirect to the language-specific homepage
|
||||
return Astro.redirect(`/${preferredLanguage}/${hash}`);
|
||||
---
|
@@ -25,7 +25,7 @@ async function runEmailTest() {
|
||||
console.log('Email test completed');
|
||||
}
|
||||
|
||||
runEmailTest().catch(error => {
|
||||
runEmailTest().catch((error) => {
|
||||
console.error('Error running email test:', error);
|
||||
process.exit(1);
|
||||
});
|
@@ -1,8 +1,16 @@
|
||||
import nodemailer from 'nodemailer';
|
||||
import { RateLimiterMemory } from 'rate-limiter-flexible';
|
||||
import { createHash } from 'crypto';
|
||||
import { getAdminNotificationHtml, getAdminNotificationText, getAdminNotificationSubject } from '../email-templates/admin-notification';
|
||||
import { getUserConfirmationHtml, getUserConfirmationText, getUserConfirmationSubject } from '../email-templates/user-confirmation';
|
||||
import {
|
||||
getAdminNotificationHtml,
|
||||
getAdminNotificationText,
|
||||
getAdminNotificationSubject,
|
||||
} from '../email-templates/admin-notification';
|
||||
import {
|
||||
getUserConfirmationHtml,
|
||||
getUserConfirmationText,
|
||||
getUserConfirmationSubject,
|
||||
} from '../email-templates/user-confirmation';
|
||||
import 'dotenv/config';
|
||||
|
||||
// Environment variables
|
||||
@@ -12,7 +20,7 @@ const {
|
||||
SMTP_USER = '',
|
||||
SMTP_PASS = '',
|
||||
ADMIN_EMAIL = '',
|
||||
WEBSITE_NAME = 'bergsma.it'
|
||||
WEBSITE_NAME = 'bergsma.it',
|
||||
} = process.env;
|
||||
|
||||
// Email configuration
|
||||
@@ -45,13 +53,13 @@ function initializeTransporter() {
|
||||
// Do not fail on invalid certs
|
||||
rejectUnauthorized: false,
|
||||
// Specific ciphers for ProtonMail
|
||||
ciphers: 'SSLv3'
|
||||
}
|
||||
})
|
||||
ciphers: 'SSLv3',
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
// Verify SMTP connection configuration
|
||||
transporter.verify(function(error, _success) {
|
||||
transporter.verify(function (error, _success) {
|
||||
if (error) {
|
||||
console.error('SMTP connection error:', error);
|
||||
} else {
|
||||
@@ -79,9 +87,7 @@ const csrfTokens = new Map<string, { token: string; expires: Date }>();
|
||||
|
||||
// Generate a CSRF token
|
||||
export function generateCsrfToken(): string {
|
||||
const token = createHash('sha256')
|
||||
.update(Math.random().toString())
|
||||
.digest('hex');
|
||||
const token = createHash('sha256').update(Math.random().toString()).digest('hex');
|
||||
|
||||
// Token expires after 1 hour
|
||||
const expires = new Date();
|
||||
@@ -140,12 +146,7 @@ export async function checkRateLimit(ipAddress: string): Promise<{ limited: bool
|
||||
}
|
||||
|
||||
// Log email sending attempts
|
||||
export function logEmailAttempt(
|
||||
success: boolean,
|
||||
recipient: string,
|
||||
subject: string,
|
||||
error?: Error
|
||||
): void {
|
||||
export function logEmailAttempt(success: boolean, recipient: string, subject: string, error?: Error): void {
|
||||
const timestamp = new Date().toISOString();
|
||||
const status = success ? 'SUCCESS' : 'FAILURE';
|
||||
const errorMessage = error ? `: ${error.message}` : '';
|
||||
@@ -162,12 +163,7 @@ export function logEmailAttempt(
|
||||
}
|
||||
|
||||
// Send an email
|
||||
export async function sendEmail(
|
||||
to: string,
|
||||
subject: string,
|
||||
html: string,
|
||||
text: string
|
||||
): Promise<boolean> {
|
||||
export async function sendEmail(to: string, subject: string, html: string, text: string): Promise<boolean> {
|
||||
// Initialize transporter if not already done
|
||||
if (!transporter) {
|
||||
initializeTransporter();
|
||||
@@ -175,9 +171,7 @@ export async function sendEmail(
|
||||
|
||||
try {
|
||||
// Ensure from address matches SMTP_USER for ProtonMail
|
||||
const fromAddress = isProduction ?
|
||||
`"${WEBSITE_NAME}" <${SMTP_USER}>` :
|
||||
`"${WEBSITE_NAME}" <${ADMIN_EMAIL}>`;
|
||||
const fromAddress = isProduction ? `"${WEBSITE_NAME}" <${SMTP_USER}>` : `"${WEBSITE_NAME}" <${ADMIN_EMAIL}>`;
|
||||
|
||||
const mailOptions = {
|
||||
from: fromAddress,
|
||||
@@ -189,7 +183,6 @@ export async function sendEmail(
|
||||
|
||||
await transporter.sendMail(mailOptions);
|
||||
|
||||
|
||||
logEmailAttempt(true, to, subject);
|
||||
return true;
|
||||
} catch (error) {
|
||||
@@ -278,11 +271,7 @@ export async function sendAdminNotification(
|
||||
}
|
||||
|
||||
// Send user confirmation email
|
||||
export async function sendUserConfirmation(
|
||||
name: string,
|
||||
email: string,
|
||||
message: string
|
||||
): Promise<boolean> {
|
||||
export async function sendUserConfirmation(name: string, email: string, message: string): Promise<boolean> {
|
||||
if (!email || email.trim() === '') {
|
||||
console.error('Cannot send user confirmation: email is empty');
|
||||
return false;
|
||||
@@ -334,7 +323,7 @@ export async function testEmailConfiguration(): Promise<boolean> {
|
||||
|
||||
// Verify connection to SMTP server
|
||||
const connectionResult = await new Promise<boolean>((resolve) => {
|
||||
transporter.verify(function(error, _success) {
|
||||
transporter.verify(function (error, _success) {
|
||||
if (error) {
|
||||
resolve(false);
|
||||
} else {
|
||||
|
28
src/utils/language.ts
Normal file
28
src/utils/language.ts
Normal 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;
|
||||
}
|
Reference in New Issue
Block a user