Compare commits

...

46 Commits

Author SHA1 Message Date
6257a223b2 Update translation links for Microsoft Applied Skills in multiple languages
- Revised URLs for the Microsoft Applied Skills translations in English, Dutch, German, and French to reflect the latest resources.
- Ensured consistency across all language translations for improved user access to relevant content.
2025-07-24 15:05:00 +02:00
b2fbe18214 Enhance RocketChat livechat initialization with detailed logging
- Added console logs to track the initialization process of the RocketChat livechat widget, including the setting of the URL and script loading status.
- Improved error handling by logging warnings when the script tag is not found or when the script fails to load.
- Ensured that the script loads correctly based on the DOM readiness state, enhancing reliability and user experience.
2025-07-24 14:57:01 +02:00
b75422716b Update German translation for applied skills in i18n module
- Changed the translation for 'appliedSkills' from 'Fähigkeiten' to 'Angewandte Fähigkeiten' to enhance clarity and accuracy in the German language context.
2025-07-24 14:51:17 +02:00
83a11a45fb Update LanguageDropdown and Header components for improved localization and layout
- Enhanced the LanguageDropdown component by updating language names to their native forms and adding non-breaking spaces for better readability.
- Refactored the Header component to improve layout, including a gap between icons and adjusted class attributes for better spacing.
- Ensured consistent filtering of navigation links and improved accessibility for the contact link.
2025-07-24 14:49:12 +02:00
7d0e9ee8f6 Enhance header component with translation support and improve navigation links
- Imported the getTranslation function to provide localized text for the contact link in the header.
- Updated the navigation link filtering to use href instead of text, ensuring language independence.
- Adjusted the translations for the contact link to enhance accessibility and user experience.
- Refined the translations for applied skills in the i18n module for consistency.
2025-07-24 14:36:02 +02:00
526d296995 Add applied skills section to navigation and update translations
- Introduced a new navigation item for applied skills, linking to the relevant section on the about me page.
- Updated the translations interface to include the appliedSkills key, with corresponding translations in English, Dutch, German, and French for improved multilingual support.
2025-07-24 14:26:13 +02:00
6e6bfb67b1 Refactor Logo component and enhance translations for applied skills
- Updated the Logo component to improve accessibility and visual design, replacing SVG elements with structured HTML and CSS for better responsiveness and styling.
- Introduced a new translations interface for applied skills, adding detailed descriptions and links for various skills across multiple languages.
- Enhanced the about me page to include a new section for applied skills, integrating the updated translations and improving the overall user experience.
2025-07-24 14:18:35 +02:00
becarta
5bc2197182 Enhance RocketChat livechat integration with improved error handling
- Refactored the script for initializing the RocketChat livechat widget to include error handling for script loading failures.
- Ensured the RocketChat object is properly initialized and handles cases where it may not exist.
- Added a check for DOM readiness before loading the livechat script, improving reliability and user experience.
2025-07-18 08:42:12 +02:00
becarta
a9a1404386 Add response time smoothing to uptime API
- Introduced a new configuration for response time smoothing to enhance the accuracy of ping data.
- Cleaned up the code for clarity and maintainability while preserving existing functionality.
2025-07-18 08:36:54 +02:00
becarta
0b59b3b977 Update server configuration and translations for improved deployment and user experience
- Enhanced server startup message to include dynamic protocol and domain based on the environment (production or development).
- Updated translation references from GitHub to Gitea across multiple languages for consistency.
- Refactored layout and metadata in Astro components to utilize SITE configuration for URLs, ensuring accurate site links.
- Cleaned up unused code in the layout file and removed commented-out sections for better readability.
2025-07-18 08:16:10 +02:00
becarta
4fbfcc5855 Enhance light mode styles for buttons and CTAs
- Added text-shadow to primary buttons for improved visibility in light mode.
- Updated secondary button styles with specific light mode enhancements, including color, border, and background adjustments.
- Introduced light mode fixes for secondary CTAs, ensuring better visibility and user experience.
- Refined styles for CTA buttons to enhance their appearance and interaction in light mode.
2025-07-12 00:57:10 +02:00
becarta
2f65102da4 Update Hero2 component layout for improved spacing and responsiveness
- Added padding to the Hero2 component's layout to enhance visual spacing and alignment across different screen sizes.
- Adjusted the class attributes to ensure better adaptability and user experience in the component's design.
2025-07-12 00:46:18 +02:00
becarta
b5b5b80af1 Update Hero components for improved layout and responsiveness
- Changed the maximum width of the Hero component in Hero.astro from 'max-w-7xl' to 'max-w-screen-2xl' for better adaptability across larger screens.
- Adjusted the Hero2 component's layout by setting the width to 'w-full' and removing the maximum width constraint, enhancing flexibility and responsiveness.
2025-07-12 00:39:06 +02:00
becarta
e8833ce52b Add spam review functionality to contact form and update translations
- Introduced a new spamReview property in the Form interface to handle manual review requests for messages flagged as spam.
- Updated the Form component to display a spam warning and a manual review form with dynamic labels and placeholders based on translations.
- Enhanced the Tailwind CSS styles for responsive subtitles across various components.
- Added corresponding translations for the spam review feature in English, Dutch, German, and French, ensuring consistency and clarity in messaging.
- Updated various components to integrate the new spamReview functionality, improving user experience and interaction.
2025-07-12 00:28:44 +02:00
becarta
bbf85be7c8 Refactor homepage translations for improved consistency and clarity
- Updated the English, Dutch, and German translations for the ExplosiveHomepage, enhancing the hero, actions, services, approach, cta, and contact sections.
- Improved language for better engagement and user experience, ensuring consistency in terminology across all supported languages.
- Added new fields and refined existing text to accurately reflect the services offered and enhance user interaction.
2025-07-11 23:55:25 +02:00
becarta
4bbaa000bb Revise English homepage translations for improved clarity and engagement
- Updated the English translations for the ExplosiveHomepage, enhancing the hero, actions, services, approach, cta, and contact sections.
- Refined language to better reflect the services offered and to create a more compelling user experience.
- Ensured consistency in terminology and messaging throughout the homepage content.
2025-07-11 23:50:01 +02:00
becarta
619dfd17ad Enhance homepage translations for multiple languages
- Updated the structure and content of the ExplosiveHomepageTranslation interface to include detailed translations for hero, actions, services, approach, cta, and contact sections.
- Improved clarity and engagement in English, Dutch, German, and French translations, ensuring consistency and better user experience across supported languages.
- Added new fields and refined existing text to better reflect the services offered and enhance user interaction.
2025-07-11 23:48:39 +02:00
becarta
0c3651ccbc Update German translation for homepage actions
- Changed the 'learnMore' action text from "Zünden Sie Ihr Business" to "Jetzt durchstarten" for improved clarity and engagement.
2025-07-11 23:37:51 +02:00
babba0adc0 Refactor formatLocalTime function in UptimeStatusIsland to remove Luxon fallback
- Eliminated the fallback handling for Luxon in the formatLocalTime function to streamline date formatting.
- Improved code clarity by removing unnecessary error handling and debug logging related to Luxon availability.
- Maintained existing functionality while simplifying the implementation.
2025-07-07 20:38:44 +02:00
6403efa64c Add fallback handling for date formatting in formatLocalTime function
- Implemented a check for Luxon availability and added a fallback to native JavaScript Date for better robustness.
- Enhanced error handling to log failures during date parsing attempts.
- Maintained existing functionality while improving resilience against missing dependencies.
2025-07-07 20:28:54 +02:00
b19fdbbc0c Remove temporary debug logging from formatLocalTime and HeartbeatPopup in UptimeStatusIsland
- Cleaned up console logs used for production debugging to streamline the code.
- Added a log statement to indicate when UptimeStatusIsland is loaded in production.
- Maintained existing functionality while improving code clarity.
2025-07-07 20:25:11 +02:00
1d609b303f Add debug logging to HeartbeatPopup in UptimeStatusIsland for production insights
- Introduced console logs to track incoming data and formatted time outputs.
- Aimed at enhancing visibility into the component's behavior during runtime.
2025-07-07 20:16:29 +02:00
d046a6f4fd Add temporary debug logging to formatLocalTime function in UptimeStatusIsland
- Introduced console logging for various stages of date parsing to assist in production debugging.
- Enhanced error handling to log failures during ISO and format parsing attempts.
- Maintained existing functionality while providing insights into the parsing process.
2025-07-07 20:11:22 +02:00
658881124f Enhance date formatting in UptimeStatusIsland and ensure UTC compliance in API
- Improved the formatLocalTime function to handle various date formats and ensure accurate timezone conversion.
- Updated the ensureUTC function to check for ISO format and append 'Z' for UTC interpretation when necessary.
2025-07-07 20:07:07 +02:00
becarta
63f5c24590 Update translations in YouTube script to replace GitHub links with greasyfork.org for improved clarity and user guidance 2025-07-03 00:26:24 +02:00
becarta
63143d0270 Remove unused GitHub link from Focused YouTube component for cleaner code 2025-07-03 00:20:45 +02:00
becarta
18f4f018a2 Update Focused YouTube link in translations and remove unused GitHub link
- Changed the link to the Focused YouTube script on Greasy Fork to the correct URL.
- Removed the unused GitHub link from the component for cleaner code.
2025-07-03 00:17:45 +02:00
f099b80fc3 Update installation instructions in translations for YouTube script
- Revised step 2 in multiple language translations to include the direct link to the Focused YouTube script page on Greasy Fork, replacing the previous placeholder text.
2025-07-02 23:45:22 +02:00
6ec4b2c604 Refactor navigation header data to improve organization and add new link
- Removed the duplicate development link from the header.
- Added a new link for 'Focused YouTube' to the navigation.
- Sorted the links alphabetically for better readability.
2025-07-02 23:38:56 +02:00
cf24268751 Comment out Rocket.Chat Livechat script in Layout.astro for future reference without execution. 2025-07-02 23:15:29 +02:00
966d946c77 Update Astro configuration to use Node adapter and adjust server settings
- Replaced Fastify adapter with Node for standalone mode.
- Maintained the inclusion of the @astrojs/prefetch integration for resource loading optimization.
2025-07-02 23:01:32 +02:00
efdd0c28d4 Update Astro configuration to use Fastify adapter and include prefetch integration
- Replaced the Node adapter with Fastify for improved performance.
- Added the @astrojs/prefetch integration to enhance resource loading.
- Updated server settings to adjust logging level and minification options.
- Modified package.json to include new dependencies for Fastify and prefetch.
2025-07-02 22:52:53 +02:00
2cb5f4bf24 Refactor About Me page for language detection and redirection
- Changed prerendering setting to true for improved performance.
- Implemented client-side language detection using cookies and browser settings.
- Added a redirect mechanism to the language-specific About Me page based on detected language.
- Included a fallback for users without JavaScript support.
2025-07-02 22:38:54 +02:00
28de900b95 Update refresh intervals in UpdateTimer and UptimeStatusIsland components from 30 minutes to 5 minutes for more frequent status checks and updates. 2025-06-28 13:45:23 +02:00
246edb3952 Update TypeScript version and enhance ContactForm and email handling
- Upgraded TypeScript dependency from 5.7.3 to 5.8.3 for improved type checking and features.
- Modified ContactForm component to include a hidden input for the domain, capturing the current hostname.
- Updated API contact handling to log and utilize the domain information in email notifications.
- Refactored email sending functions to conditionally include the domain in the sender's address for better context.
2025-06-26 23:40:44 +02:00
559bd3e983 Revamp email templates for improved design and user experience
- Enhanced the admin notification, manual review, and user confirmation email templates with modern styling and responsive design.
- Implemented glassmorphism effects, gradient backgrounds, and animated elements for a visually appealing layout.
- Improved content structure with clear sections for messages, actions, and technical details, ensuring better readability and engagement.
- Added dynamic elements and call-to-action buttons to enhance user interaction and response rates.
2025-06-26 23:16:21 +02:00
e19dc8eb3e Update ContactForm component to use dynamic origin for API requests and ensure CSRF token is included in form submissions
- Modified API fetch calls in the ContactForm to use window.location.origin for better compatibility across environments.
- Ensured CSRF token is appended to form data before submission for enhanced security.
2025-06-26 23:03:27 +02:00
49fabddc96 Refactor CookieBanner and Contact API for improved functionality and security
- Removed localStorage fallback from CookieBanner, simplifying consent management.
- Refactored manual review email handling in the Contact API to utilize HTML templates for better structure and security.
- Enhanced email content generation by escaping HTML special characters and using template files for dynamic data insertion.
2025-06-26 22:54:02 +02:00
cb64f7f76c Enhance ContactForm and CookieBanner components for improved accessibility and user feedback
- Added CSRF token handling in the ContactForm for enhanced security.
- Introduced a feedback div for displaying form submission results instead of alerts.
- Updated the CookieBanner to include ARIA roles and improved focus management for better accessibility.
- Refactored manual review email handling to escape HTML special characters, enhancing security.
2025-06-26 22:38:06 +02:00
dde3fb1923 Fix calculation of start date in ContributionCalendar to correctly reflect the 52-week period 2025-06-24 21:11:05 +02:00
3847f415d6 Enhance accessibility and structure across multiple components
- Updated the ContactForm component to include labels and aria-describedby attributes for better accessibility.
- Improved the Button component by conditionally adding aria-labels based on text input.
- Enhanced the Form component with aria-describedby attributes for validation feedback.
- Refactored the Layout component to include semantic HTML5 elements for better structure and accessibility.
- Updated the 404 page metadata for improved SEO and user experience.
2025-06-24 21:05:49 +02:00
0b16ad5a28 Update homepage translations to enhance highlight formatting for improved readability 2025-06-24 20:55:12 +02:00
851759d16b Update workflow automation description for clarity and consistency 2025-06-24 20:49:18 +02:00
fd2e4e8104 Merge branch 'main' of https://git.365devnet.eu/365DevNet/365devnet 2025-06-24 20:47:53 +02:00
934da2be73 Merge branch 'main' of https://git.365devnet.eu/365DevNet/365devnet 2025-06-24 20:45:26 +02:00
fe93eb716f Revamp homepage with explosive design and enhanced interactivity
- Introduced a new explosive hero section featuring vibrant animations and floating orbs for a dynamic visual experience.
- Updated the layout to utilize a new translation method for improved content management.
- Enhanced call-to-action buttons with modern styling and animations for better user engagement.
- Implemented responsive design adjustments to ensure optimal viewing on various devices.
- Replaced static content with dynamic elements, improving the overall interactivity and appeal of the homepage.
2025-06-24 20:42:03 +02:00
50 changed files with 6150 additions and 617 deletions

View File

@@ -8,11 +8,12 @@ import tailwind from '@astrojs/tailwind';
import mdx from '@astrojs/mdx';
import react from '@astrojs/react';
import partytown from '@astrojs/partytown';
import node from '@astrojs/node';
import icon from 'astro-icon';
import compress from 'astro-compress';
import type { AstroIntegration } from 'astro';
import astrowind from './vendor/integration';
import node from '@astrojs/node';
import prefetch from '@astrojs/prefetch';
import { readingTimeRemarkPlugin, responsiveTablesRehypePlugin, lazyImagesRehypePlugin } from './src/utils/frontmatter';
@@ -79,12 +80,14 @@ export default defineConfig({
Image: true,
JavaScript: true,
SVG: false,
Logger: 1,
Logger: 0,
}),
astrowind({
config: './src/config.yaml',
}),
prefetch(),
],
image: {
@@ -97,6 +100,9 @@ export default defineConfig({
},
vite: {
build: {
minify: 'esbuild',
},
resolve: {
alias: {
'~': path.resolve(__dirname, './src'),

723
package-lock.json generated
View File

@@ -9,6 +9,7 @@
"version": "1.0.0-beta.50",
"dependencies": {
"@astrojs/node": "^9.2.2",
"@astrojs/prefetch": "^0.4.1",
"@astrojs/react": "^4.2.0",
"@astrojs/rss": "^4.0.11",
"@astrojs/sitemap": "^3.2.1",
@@ -22,8 +23,10 @@
"astro": "^5.2.3",
"astro-embed": "^0.9.0",
"astro-icon": "^1.1.5",
"compression": "^1.7.4",
"csrf": "^3.1.0",
"dotenv": "^16.4.7",
"express": "^4.19.2",
"form-data": "^4.0.2",
"jsonwebtoken": "^9.0.2",
"limax": "4.1.0",
@@ -67,7 +70,7 @@
"sharp": "0.33.5",
"tailwind-merge": "^2.6.0",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.3",
"typescript": "^5.8.3",
"typescript-eslint": "^8.21.0",
"unist-util-visit": "^5.0.0"
},
@@ -388,6 +391,16 @@
"mrmime": "^2.0.1"
}
},
"node_modules/@astrojs/prefetch": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@astrojs/prefetch/-/prefetch-0.4.1.tgz",
"integrity": "sha512-bpC875BqeIuWVgqhi4X814ftzzbKocaLkiZczaj8k5J2SRpueIGkww3XmD+yY/Ekkm9j30aS3neVO6wSm4IJNA==",
"deprecated": "@astrojs/prefetch is deprecated in favor of the builtin prefetch option. Please see the migration guide for more information: https://docs.astro.build/en/guides/prefetch/#migrating-from-astrojsprefetch",
"license": "MIT",
"dependencies": {
"throttles": "^1.0.1"
}
},
"node_modules/@astrojs/prism": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@astrojs/prism/-/prism-3.3.0.tgz",
@@ -3398,6 +3411,28 @@
"dev": true,
"license": "MIT"
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"license": "MIT",
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/accepts/node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
@@ -3564,6 +3599,12 @@
"node": ">= 0.4"
}
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/array-iterate": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/array-iterate/-/array-iterate-2.0.1.tgz",
@@ -3956,6 +3997,57 @@
],
"license": "MIT"
},
"node_modules/body-parser": {
"version": "1.20.3",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
"integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==",
"license": "MIT",
"dependencies": {
"bytes": "3.1.2",
"content-type": "~1.0.5",
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"http-errors": "2.0.0",
"iconv-lite": "0.4.24",
"on-finished": "2.4.1",
"qs": "6.13.0",
"raw-body": "2.5.2",
"type-is": "~1.6.18",
"unpipe": "1.0.0"
},
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/body-parser/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/body-parser/node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/body-parser/node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/boolbase": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
@@ -4076,6 +4168,15 @@
"integrity": "sha512-IxLEnfsCYLjlpf6mG7SWpWgA4A8IAT5dAX3FxXHFn+6FTLf3ums771elQ74sj1BCOVanBf6esu0rEC6zgwfmIg==",
"license": "MIT"
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
@@ -4089,6 +4190,22 @@
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/callsites": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@@ -4537,6 +4654,51 @@
"integrity": "sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w==",
"license": "ISC"
},
"node_modules/compressible": {
"version": "2.0.18",
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
"integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==",
"license": "MIT",
"dependencies": {
"mime-db": ">= 1.43.0 < 2"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/compression": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/compression/-/compression-1.8.0.tgz",
"integrity": "sha512-k6WLKfunuqCYD3t6AsuPGvQWaKwuLLh2/xHNcX4qE+vIfDNXpSqnrhwA7O53R7WVQUnt8dVAIW+YHr7xTgOgGA==",
"license": "MIT",
"dependencies": {
"bytes": "3.1.2",
"compressible": "~2.0.18",
"debug": "2.6.9",
"negotiator": "~0.6.4",
"on-headers": "~1.0.2",
"safe-buffer": "5.2.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/compression/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/compression/node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -4550,6 +4712,27 @@
"integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==",
"license": "MIT"
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
"license": "MIT",
"dependencies": {
"safe-buffer": "5.2.1"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -4571,6 +4754,12 @@
"integrity": "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==",
"license": "MIT"
},
"node_modules/cookie-signature": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT"
},
"node_modules/cross-fetch": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz",
@@ -4818,6 +5007,16 @@
"integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==",
"license": "MIT"
},
"node_modules/destroy": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
"license": "MIT",
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/detect-libc": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
@@ -5666,6 +5865,127 @@
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
"license": "MIT"
},
"node_modules/express": {
"version": "4.21.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "1.20.3",
"content-disposition": "0.5.4",
"content-type": "~1.0.4",
"cookie": "0.7.1",
"cookie-signature": "1.0.6",
"debug": "2.6.9",
"depd": "2.0.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"finalhandler": "1.3.1",
"fresh": "0.5.2",
"http-errors": "2.0.0",
"merge-descriptors": "1.0.3",
"methods": "~1.1.2",
"on-finished": "2.4.1",
"parseurl": "~1.3.3",
"path-to-regexp": "0.1.12",
"proxy-addr": "~2.0.7",
"qs": "6.13.0",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "0.19.0",
"serve-static": "1.16.2",
"setprototypeof": "1.2.0",
"statuses": "2.0.1",
"type-is": "~1.6.18",
"utils-merge": "1.0.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/express/node_modules/cookie": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/express/node_modules/debug/node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/express/node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express/node_modules/send": {
"version": "0.19.0",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz",
"integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"encodeurl": "~1.0.2",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"fresh": "0.5.2",
"http-errors": "2.0.0",
"mime": "1.6.0",
"ms": "2.1.3",
"on-finished": "2.4.1",
"range-parser": "~1.2.1",
"statuses": "2.0.1"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/express/node_modules/send/node_modules/encodeurl": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
"integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/express/node_modules/statuses": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/exsolve": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.5.tgz",
@@ -5865,6 +6185,48 @@
"node": ">=8"
}
},
"node_modules/finalhandler": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz",
"integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"on-finished": "2.4.1",
"parseurl": "~1.3.3",
"statuses": "2.0.1",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/finalhandler/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/finalhandler/node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/finalhandler/node_modules/statuses": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/find-up": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
@@ -6004,6 +6366,15 @@
"node": ">=12.20.0"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fraction.js": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
@@ -6760,6 +7131,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/iron-webcrypto": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz",
@@ -7984,6 +8364,24 @@
"integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==",
"license": "CC0-1.0"
},
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/merge-descriptors": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -7994,6 +8392,15 @@
"node": ">= 8"
}
},
"node_modules/methods": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/micromark": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz",
@@ -8745,6 +9152,18 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/mime": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
@@ -8917,6 +9336,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/negotiator": {
"version": "0.6.4",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz",
"integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/neotraverse": {
"version": "0.6.18",
"resolved": "https://registry.npmjs.org/neotraverse/-/neotraverse-0.6.18.tgz",
@@ -9066,6 +9494,18 @@
"node": ">= 6"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/ofetch": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/ofetch/-/ofetch-1.4.1.tgz",
@@ -9095,6 +9535,15 @@
"node": ">= 0.8"
}
},
"node_modules/on-headers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz",
"integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@@ -9352,6 +9801,15 @@
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/pascal-case": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz",
@@ -9431,6 +9889,12 @@
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/path-to-regexp": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT"
},
"node_modules/pathe": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz",
@@ -9749,6 +10213,19 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
@@ -9775,6 +10252,21 @@
"node": ">=6"
}
},
"node_modules/qs": {
"version": "6.13.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
"integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.0.6"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/quansync": {
"version": "0.2.10",
"resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.10.tgz",
@@ -9842,6 +10334,33 @@
"integrity": "sha512-+/dSQfo+3FYwYygUs/V2BBdwGa9nFtakDwKt4l0bnvNB53TNT++QSFewwHX9qXrZJuMe9j+TUaU21lm5ARgqdQ==",
"license": "ISC"
},
"node_modules/raw-body": {
"version": "2.5.2",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz",
"integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==",
"license": "MIT",
"dependencies": {
"bytes": "3.1.2",
"http-errors": "2.0.0",
"iconv-lite": "0.4.24",
"unpipe": "1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/raw-body/node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react": {
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
@@ -10507,6 +11026,87 @@
"node": ">= 0.6"
}
},
"node_modules/serve-static": {
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz",
"integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==",
"license": "MIT",
"dependencies": {
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"parseurl": "~1.3.3",
"send": "0.19.0"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/serve-static/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/serve-static/node_modules/debug/node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/serve-static/node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/serve-static/node_modules/send": {
"version": "0.19.0",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz",
"integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"encodeurl": "~1.0.2",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"fresh": "0.5.2",
"http-errors": "2.0.0",
"mime": "1.6.0",
"ms": "2.1.3",
"on-finished": "2.4.1",
"range-parser": "~1.2.1",
"statuses": "2.0.1"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/serve-static/node_modules/send/node_modules/encodeurl": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
"integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/serve-static/node_modules/statuses": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/server-destroy": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/server-destroy/-/server-destroy-1.0.1.tgz",
@@ -10608,6 +11208,78 @@
"@types/hast": "^3.0.4"
}
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
@@ -11255,6 +11927,15 @@
"node": ">=0.8"
}
},
"node_modules/throttles": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/throttles/-/throttles-1.0.1.tgz",
"integrity": "sha512-fab7Xg+zELr9KOv4fkaBoe/b3L0GMGLd0IBSCn16GoE/Qx6/OfCr1eGNyEcDU2pUA79qQfZ8kPQWlRuok4YwTw==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/tiny-inflate": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
@@ -11435,6 +12116,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"license": "MIT",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/typesafe-path": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/typesafe-path/-/typesafe-path-0.2.2.tgz",
@@ -11781,6 +12475,15 @@
"integrity": "sha512-NFhB8HgHHWkNzTxwWg6KHx8+3RZnhWFm4Axdqp9iI176iY3wskzfP16NRSJ2SSTfXzyK4W6GsBqs8iOOdvOB3g==",
"license": "MIT"
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/unstorage": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/unstorage/-/unstorage-1.16.0.tgz",
@@ -11926,6 +12629,24 @@
"dev": true,
"license": "MIT"
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
"license": "MIT",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/vfile": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",

View File

@@ -9,20 +9,21 @@
},
"scripts": {
"dev": "astro dev",
"start": "astro dev",
"build": "astro build",
"preview": "astro preview",
"start": "node server.js",
"astro": "astro",
"check": "npm run check:astro && npm run check:eslint && npm run check:prettier",
"check:astro": "astro check",
"check:eslint": "eslint .",
"check:eslint": "npx eslint .",
"check:prettier": "prettier --check .",
"fix": "npm run fix:eslint && npm run fix:prettier",
"fix:eslint": "eslint --fix .",
"fix:eslint": "npx eslint --fix .",
"fix:prettier": "prettier -w ."
},
"dependencies": {
"@astrojs/node": "^9.2.2",
"@astrojs/prefetch": "^0.4.1",
"@astrojs/react": "^4.2.0",
"@astrojs/rss": "^4.0.11",
"@astrojs/sitemap": "^3.2.1",
@@ -36,8 +37,10 @@
"astro": "^5.2.3",
"astro-embed": "^0.9.0",
"astro-icon": "^1.1.5",
"compression": "^1.7.4",
"csrf": "^3.1.0",
"dotenv": "^16.4.7",
"express": "^4.19.2",
"form-data": "^4.0.2",
"jsonwebtoken": "^9.0.2",
"limax": "4.1.0",
@@ -81,7 +84,7 @@
"sharp": "0.33.5",
"tailwind-merge": "^2.6.0",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.3",
"typescript": "^5.8.3",
"typescript-eslint": "^8.21.0",
"unist-util-visit": "^5.0.0"
}

29
server.js Normal file
View File

@@ -0,0 +1,29 @@
import express from 'express';
import compression from 'compression';
import { handler } from './dist/server/entry.mjs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const app = express();
const PORT = process.env.PORT || 3000;
// Enable gzip/brotli compression
app.use(compression());
// Serve static assets from the dist/client directory
app.use(express.static(path.join(__dirname, 'dist/client')));
// Handle all SSR requests with Astro's handler
app.all('*', handler);
app.listen(PORT, () => {
const isProduction = process.env.NODE_ENV === 'production';
const domain = isProduction ? '365devnet.eu' : `localhost:${PORT}`;
const protocol = isProduction ? 'https' : 'http';
console.log(`Server running with compression on ${protocol}://${domain}`);
if (!isProduction) {
console.log(`Development server accessible at http://localhost:${PORT}`);
}
});

View File

@@ -26,11 +26,18 @@
.text-muted {
color: var(--aw-color-text-muted);
}
/* Responsive subtitle utility */
.subtitle-responsive {
@apply text-base md:text-lg lg:text-xl;
}
}
@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;
/* Enhanced visibility for light mode */
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.btn-primary {
@@ -39,6 +46,24 @@
.btn-secondary {
@apply btn;
/* Enhanced visibility for light mode secondary buttons */
color: rgb(16 16 16);
border-color: rgb(156 163 175);
}
/* Light mode specific enhancements for secondary buttons */
html:not(.dark) .btn-secondary {
color: rgb(16 16 16) !important;
border-color: rgb(156 163 175) !important;
background-color: rgba(255, 255, 255, 0.9) !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1) !important;
}
html:not(.dark) .btn-secondary:hover {
background-color: rgb(243 244 246) !important;
border-color: rgb(107 114 128) !important;
color: rgb(16 16 16) !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15) !important;
}
.btn-tertiary {

View File

@@ -1,17 +1,29 @@
<form id="contact-form">
<input type="email" name="email" id="email" required />
<textarea name="message" id="message" required></textarea>
<input type="hidden" name="csrf_token" id="csrf_token" />
<input type="hidden" name="domain" id="domain" />
<label for="email">Email</label>
<input type="email" name="email" id="email" required aria-describedby="email-help" />
<div id="email-help" class="sr-only">Enter your email address</div>
<label for="message">Message</label>
<textarea name="message" id="message" required aria-describedby="message-help"></textarea>
<div id="message-help" class="sr-only">Enter your message</div>
<button type="submit">Send</button>
</form>
<div id="spam-warning" style="display:none;">
<div id="form-feedback" role="alert" aria-live="assertive"></div>
<div id="spam-warning" style="display:none;" tabindex="-1">
<p>
Your message was detected as spam and was not sent.<br>
If you believe this is a mistake, you can request a manual review.
</p>
<form id="manual-review-form">
<input type="email" id="manual-email" readonly />
<textarea id="manual-justification" placeholder="Why is this not spam? (optional)"></textarea>
<label for="manual-email">Email</label>
<input type="email" id="manual-email" readonly aria-describedby="manual-email-help" />
<div id="manual-email-help" class="sr-only">Your email address.</div>
<label for="manual-justification">Why is this not spam? (optional)</label>
<textarea id="manual-justification" placeholder="Why is this not spam? (optional)" aria-describedby="manual-justification-help"></textarea>
<div id="manual-justification-help" class="sr-only">Explain why your message is legitimate</div>
<input type="hidden" id="manual-token" />
<button type="submit">Request Manual Review</button>
</form>
@@ -19,18 +31,49 @@
</div>
<script>
async function fetchCsrfToken() {
try {
const response = await fetch(window.location.origin + '/api/contact?csrf=true');
const data = await response.json();
const csrfTokenInput = document.getElementById('csrf_token');
if (csrfTokenInput) {
(csrfTokenInput as HTMLInputElement).value = data.csrfToken;
}
} catch (error) {
console.error('Error fetching CSRF token:', error);
}
}
function setDomainHiddenField() {
const domainInput = document.getElementById('domain');
if (domainInput) {
(domainInput as HTMLInputElement).value = window.location.hostname;
}
}
document.addEventListener('DOMContentLoaded', () => {
fetchCsrfToken();
setDomainHiddenField();
});
const contactForm = document.getElementById('contact-form');
if (contactForm) {
const feedbackDiv = document.getElementById('form-feedback');
if (contactForm && feedbackDiv) {
contactForm.onsubmit = async function (e) {
e.preventDefault();
const formData = new FormData(this as HTMLFormElement);
const csrfTokenInput = document.getElementById('csrf_token') as HTMLInputElement | null;
if (csrfTokenInput) {
formData.append('csrf_token', csrfTokenInput.value);
}
let res, data;
try {
res = await fetch('/api/contact', { method: 'POST', body: formData });
res = await fetch(window.location.origin + '/api/contact', { method: 'POST', body: formData });
data = await res.json();
console.log('Contact API response:', data);
} catch (_err) {
alert('Unexpected server response.');
feedbackDiv.textContent = 'Unexpected server response.';
return;
}
@@ -43,14 +86,15 @@ if (contactForm) {
spamWarning.style.display = 'block';
manualEmail.value = String(formData.get('email'));
manualToken.value = data.token;
spamWarning.focus();
}
return;
}
if (!res.ok) {
alert(data.error || 'There was an error sending your message.');
feedbackDiv.textContent = data.error || 'There was an error sending your message.';
} else {
alert('Your message was sent successfully!');
feedbackDiv.textContent = 'Your message was sent successfully!';
}
};
}
@@ -68,7 +112,7 @@ if (manualReviewForm) {
const justification = manualJustification.value;
const token = manualToken.value;
const res = await fetch('/api/contact/manual-review', {
const res = await fetch(window.location.origin + '/api/contact/manual-review', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, justification, token }),

View File

@@ -10,7 +10,7 @@ function getCalendarDays() {
// Find the Monday that starts our 52-week period
const startDate = new Date(todayUTC);
startDate.setUTCDate(todayUTC.getUTCDate() - (52 * 7 - 1));
startDate.setUTCDate(todayUTC.getUTCDate() - (51 * 7 - 1));
// Adjust startDate to the Monday of that week (using UTC day)
const startDayOfWeek = startDate.getUTCDay(); // 0 = Sunday, 1 = Monday, etc.

View File

@@ -9,9 +9,14 @@ const t = getTranslation(lang);
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;"
role="dialog"
aria-modal="true"
aria-labelledby="cookie-banner-title"
tabindex="-1"
>
<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">
<h2 id="cookie-banner-title" class="sr-only">Cookie Consent</h2>
<p>
{t.cookies.message}
<a href={`/${lang}/privacy#cookie-usage`} class="text-blue-600 dark:text-blue-400 hover:underline"
@@ -64,40 +69,20 @@ const t = getTranslation(lang);
return;
}
// Also check localStorage as a fallback
try {
if (localStorage && localStorage.getItem('cookieConsentAccepted') === 'true') {
cookieBanner.style.display = 'none';
// Also set the cookie for future visits
setCookie('cookieConsentAccepted', 'true', 365);
return;
}
} catch (e) {
console.error('Error accessing localStorage:', e);
// Continue checking cookies
}
// Show the banner
cookieBanner.style.display = 'block';
// Show the banner with a slight delay for better UX
setTimeout(() => {
cookieBanner.classList.remove('translate-y-full');
cookieBanner.focus();
}, 500);
// Handle accept button click
acceptButton.addEventListener('click', () => {
// Store consent in cookie (primary storage)
// Store consent in cookie
setCookie('cookieConsentAccepted', 'true', 365);
// Also store in localStorage as backup
try {
localStorage.setItem('cookieConsentAccepted', 'true');
} catch (e) {
console.error('Error setting localStorage:', e);
// Continue with cookie storage
}
// Hide the banner with animation
cookieBanner.classList.add('translate-y-full');

View File

@@ -38,6 +38,7 @@ import '@fontsource-variable/inter';
--aw-color-text-heading: rgb(0 0 0);
--aw-color-text-default: rgb(16 16 16);
--aw-color-text-muted: rgb(0, 0, 0);
--aw-color-text-page: rgb(16 16 16);
--aw-color-bg-page: rgb(255 255 255);
--aw-color-bg-page-dark: rgb(3 6 32);
@@ -59,6 +60,7 @@ import '@fontsource-variable/inter';
--aw-color-text-heading: rgb(247, 248, 248);
--aw-color-text-default: rgb(229 236 246);
--aw-color-text-muted: rgb(229 236 246 / 85%);
--aw-color-text-page: rgb(229 236 246);
--aw-color-bg-page: rgb(3 6 32);
::selection {

View File

@@ -11,10 +11,10 @@ const { currentLang } = Astro.props;
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' },
{ code: 'en' as SupportedLanguage, name: 'English\u00A0\u00A0', flag: 'gb' },
{ code: 'nl' as SupportedLanguage, name: 'Nederlands', flag: 'nl' },
{ code: 'de' as SupportedLanguage, name: 'Deutsch', flag: 'de' },
{ code: 'fr' as SupportedLanguage, name: 'Français', flag: 'fr' },
].filter((lang) => supportedLanguages.includes(lang.code));
const currentLanguage = languages.find((lang) => lang.code === currentLang) || languages[0];

View File

@@ -1,13 +1,39 @@
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 900 120"
fill="none"
role="img"
---
// Logo.astro
---
<div
role="img"
aria-label="365DevNet logo"
class="h-14 w-auto block"
class="flex flex-col h-14 justify-center"
>
<!-- Main Title -->
<text x="0" y="65" font-size="80" font-weight="bold" fill="currentColor" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif">365DevNet</text>
<!-- Tagline (single line, readable) -->
<text x="0" y="110" font-size="25" fill="currentColor" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif">Helping freelancers & small businesses with Microsoft 365</text>
</svg>
<h1 class="text-2xl font-bold bg-gradient-to-r from-blue-600 via-blue-700 to-indigo-700 bg-clip-text text-transparent leading-tight">
365DevNet
</h1>
<p class="text-xs text-gray-600 dark:text-gray-400 font-medium leading-tight -mt-0.5">
Helping freelancers & small businesses with Microsoft 365
</p>
</div>
<style>
/* Add subtle glow effect */
div[role="img"] {
filter: drop-shadow(0 2px 4px rgba(59, 130, 246, 0.1));
}
/* Ensure gradient works in all browsers */
h1 {
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 50%, #1e40af 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Dark mode adjustments */
:global(.dark) h1 {
background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 50%, #2563eb 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
</style>

Before

Width:  |  Height:  |  Size: 657 B

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react';
const REFRESH_INTERVAL = 1800; // seconds
const REFRESH_INTERVAL = 300; // seconds
export default function UpdateTimer({ onRefresh }) {
const [secondsLeft, setSecondsLeft] = useState(REFRESH_INTERVAL);

View File

@@ -47,16 +47,73 @@ function getUptime24hBg(uptime) {
function formatLocalTime(rawTime, zone = 'utc') {
if (!rawTime) return '';
const dt = DateTime.fromFormat(rawTime, 'yyyy-MM-dd HH:mm:ss.SSS', { zone: 'utc' });
const localDt = dt.isValid ? dt.setZone(zone) : null;
return localDt && localDt.isValid
? localDt.toFormat('dd-MM-yyyy, HH:mm:ss')
: 'Invalid DateTime';
let dt = null;
// First try ISO parsing (most reliable for UTC timestamps)
try {
dt = DateTime.fromISO(rawTime, { zone: 'utc' });
if (dt.isValid) {
// If target zone is UTC, return as-is
if (zone === 'utc') {
return dt.toFormat('dd-MM-yyyy, HH:mm:ss');
}
// Otherwise convert to target timezone
const converted = dt.setZone(zone);
return converted.isValid ? converted.toFormat('dd-MM-yyyy, HH:mm:ss') : 'Invalid DateTime';
}
} catch (e) {
// Continue to format parsing if ISO fails
}
// Try various formats, always assuming incoming time is UTC
const formats = [
'yyyy-MM-dd\'T\'HH:mm:ss.SSS\'Z\'',
'yyyy-MM-dd\'T\'HH:mm:ss\'Z\'',
'yyyy-MM-dd\'T\'HH:mm:ss.SSS',
'yyyy-MM-dd\'T\'HH:mm:ss',
'yyyy-MM-dd HH:mm:ss.SSS',
'yyyy-MM-dd HH:mm:ss'
];
for (const format of formats) {
try {
dt = DateTime.fromFormat(rawTime, format, { zone: 'utc' });
if (dt.isValid) break;
} catch (e) {
continue;
}
}
// Last resort: try JavaScript Date parsing
if (!dt || !dt.isValid) {
try {
const jsDate = new Date(rawTime);
if (!isNaN(jsDate.getTime())) {
dt = DateTime.fromJSDate(jsDate, { zone: 'utc' });
}
} catch (e) {
// Give up
}
}
if (!dt || !dt.isValid) {
return 'Invalid DateTime';
}
// Convert to target timezone
if (zone === 'utc') {
return dt.toFormat('dd-MM-yyyy, HH:mm:ss');
}
const targetDt = dt.setZone(zone);
return targetDt.isValid ? targetDt.toFormat('dd-MM-yyyy, HH:mm:ss') : 'Invalid DateTime';
}
function HeartbeatPopup({ hb, userZone, monitor }) {
const localTime = hb ? formatLocalTime(hb.time, userZone) : '';
const utcTime = hb ? formatLocalTime(hb.time, 'utc') : '';
return (
<div style={{ minWidth: 220 }}>
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 4 }}>
@@ -88,6 +145,7 @@ function HeartbeatPopup({ hb, userZone, monitor }) {
}
export default function UptimeStatusIsland() {
console.log('UptimeStatusIsland loaded in production');
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
@@ -105,32 +163,32 @@ export default function UptimeStatusIsland() {
setUserLocale(navigator.language || 'en-US');
}, []);
// Helper: get the current 30-min window key (e.g., '2024-06-07T12:00')
const getCurrent30MinKey = () => {
// Helper: get the current 5-min window key (e.g., '2024-06-07T12:00')
const getCurrent5MinKey = () => {
const now = new Date();
now.setSeconds(0, 0);
const min = now.getMinutes();
now.setMinutes(Math.floor(min / 30) * 30);
now.setMinutes(Math.floor(min / 5) * 5);
return now.toISOString().slice(0, 16); // 'YYYY-MM-DDTHH:mm'
};
// Helper: get seconds to next 30-min mark
const getSecondsToNext30Min = () => {
// Helper: get seconds to next 5-min mark
const getSecondsToNext5Min = () => {
const now = new Date();
const minutes = now.getMinutes();
const seconds = now.getSeconds();
const ms = now.getMilliseconds();
const next30 = Math.ceil((minutes + 0.01) / 30) * 30;
const next5 = Math.ceil((minutes + 0.01) / 5) * 5;
let next = new Date(now);
next.setMinutes(next30, 0, 0);
next.setMinutes(next5, 0, 0);
if (next <= now) {
next.setMinutes(next.getMinutes() + 30);
next.setMinutes(next.getMinutes() + 5);
}
return Math.floor((next - now) / 1000);
};
useEffect(() => {
const update = () => setSecondsToNextUpdate(getSecondsToNext30Min());
const update = () => setSecondsToNextUpdate(getSecondsToNext5Min());
update();
const interval = setInterval(update, 1000);
return () => clearInterval(interval);
@@ -143,7 +201,7 @@ export default function UptimeStatusIsland() {
if (cache) {
try {
const { key, data: cachedData } = JSON.parse(cache);
if (key === getCurrent30MinKey() && cachedData) {
if (key === getCurrent5MinKey() && cachedData) {
setData(cachedData);
setLoading(false);
return;
@@ -163,9 +221,9 @@ export default function UptimeStatusIsland() {
if (!response.ok) throw new Error('Failed to fetch uptime data');
const json = await response.json();
setData(json);
// Cache with current 30-min key
// Cache with current 5-min key
const cacheKey = 'uptimeStatusCache';
sessionStorage.setItem(cacheKey, JSON.stringify({ key: getCurrent30MinKey(), data: json }));
sessionStorage.setItem(cacheKey, JSON.stringify({ key: getCurrent5MinKey(), data: json }));
} catch (err) {
setError(err.message || 'Unknown error');
} finally {
@@ -173,7 +231,7 @@ export default function UptimeStatusIsland() {
}
}, []);
// Scheduled fetch logic (every 30 min, update cache)
// Scheduled fetch logic (every 5 min, update cache)
const fetchData = useCallback(async () => {
setLoading(true);
setError(null);
@@ -182,9 +240,9 @@ export default function UptimeStatusIsland() {
if (!response.ok) throw new Error('Failed to fetch uptime data');
const json = await response.json();
setData(json);
// Cache with current 30-min key
// Cache with current 5-min key
const cacheKey = 'uptimeStatusCache';
sessionStorage.setItem(cacheKey, JSON.stringify({ key: getCurrent30MinKey(), data: json }));
sessionStorage.setItem(cacheKey, JSON.stringify({ key: getCurrent5MinKey(), data: json }));
} catch (err) {
setError(err.message || 'Unknown error');
} finally {
@@ -197,16 +255,16 @@ export default function UptimeStatusIsland() {
const minutes = now.getMinutes();
const seconds = now.getSeconds();
const ms = now.getMilliseconds();
const next30 = Math.ceil((minutes + 0.01) / 30) * 30;
const next5 = Math.ceil((minutes + 0.01) / 5) * 5;
let next = new Date(now);
next.setMinutes(next30, 0, 0);
next.setMinutes(next5, 0, 0);
if (next <= now) {
next.setMinutes(next.getMinutes() + 30);
next.setMinutes(next.getMinutes() + 5);
}
const msToNext = next - now;
const timeout = setTimeout(() => {
fetchData();
const interval = setInterval(fetchData, 30 * 60 * 1000);
const interval = setInterval(fetchData, 5 * 60 * 1000);
fetchData.interval = interval;
}, msToNext);
return () => {
@@ -247,7 +305,7 @@ export default function UptimeStatusIsland() {
return badgeRefs.current[monitorId][badge];
};
const totalBarSeconds = 30 * 60;
const totalBarSeconds = 5 * 60;
const barValue = secondsToNextUpdate !== null ? (totalBarSeconds - secondsToNextUpdate) : 0;
const barPercent = (barValue / totalBarSeconds) * 100;
const barGradient = 'linear-gradient(90deg, #0161ef 0%, #0154cf 100%)';

View File

@@ -19,11 +19,13 @@ const variants = {
tertiary: 'btn btn-tertiary',
link: 'cursor-pointer hover:text-primary',
};
const ariaLabel = !text || (typeof text === 'string' && text.trim() === '') ? Astro.props['aria-label'] : undefined;
---
{
type === 'button' || type === 'submit' || type === 'reset' ? (
<button type={type} class={twMerge(variants[variant] || '', className)} {...rest}>
<button type={type} class={twMerge(variants[variant] || '', className)} {...rest} {...(ariaLabel ? { 'aria-label': ariaLabel } : {})}>
<Fragment set:html={text} />
{icon && <Icon name={icon} class="w-5 h-5 ml-1 -mr-1.5 rtl:mr-1 rtl:-ml-1.5 inline-block" />}
</button>
@@ -32,6 +34,7 @@ const variants = {
class={twMerge(variants[variant] || '', className)}
{...(target ? { target: target, rel: 'noopener noreferrer' } : {})}
{...rest}
{...(ariaLabel ? { 'aria-label': ariaLabel } : {})}
>
<Fragment set:html={text} />
{icon && <Icon name={icon} class="w-5 h-5 ml-1 -mr-1.5 rtl:mr-1 rtl:-ml-1.5 inline-block" />}

View File

@@ -2,7 +2,7 @@
import type { Form as Props } from '~/types';
import Button from '~/components/ui/Button.astro';
const { inputs, textarea, disclaimer, button = 'Contact us', description = '' } = Astro.props;
const { inputs, textarea, disclaimer, button = 'Contact us', description = '', spamReview } = Astro.props;
---
<style>
@@ -15,14 +15,7 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '' }
}
</style>
<form
id="contact-form"
name="contact"
method="POST"
action="/api/contact"
class="needs-validation"
novalidate
>
<form id="contact-form" name="contact" method="POST" action="/api/contact" class="needs-validation" 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!
@@ -59,8 +52,9 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '' }
placeholder={placeholder}
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}
aria-describedby={`invalid-feedback-${name}`}
/>
<div class="invalid-feedback hidden text-red-600 text-sm mt-1" />
<div id={`invalid-feedback-${name}`} class="invalid-feedback hidden text-red-600 text-sm mt-1" />
</div>
)
)
@@ -80,8 +74,9 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '' }
placeholder={textarea.placeholder}
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
aria-describedby="invalid-feedback-textarea"
/>
<div class="invalid-feedback hidden text-red-600 text-sm mt-1" />
<div id="invalid-feedback-textarea" class="invalid-feedback hidden text-red-600 text-sm mt-1" />
</div>
)
}
@@ -96,6 +91,7 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '' }
type="checkbox"
class="cursor-pointer mt-1 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
aria-describedby="invalid-feedback-disclaimer"
/>
</div>
<div class="ml-3">
@@ -103,7 +99,7 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '' }
{disclaimer.label}
<span class="text-red-600">*</span>
</label>
<div class="invalid-feedback hidden text-red-600 text-sm mt-1" />
<div id="invalid-feedback-disclaimer" class="invalid-feedback hidden text-red-600 text-sm mt-1" />
</div>
</div>
)
@@ -129,23 +125,51 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '' }
</form>
<!-- Manual Review UI -->
<div id="spam-warning" style="display:none;" class="max-w-xl mx-auto rounded-lg backdrop-blur-sm bg-white/15 dark:bg-slate-900 border border-gray-200 dark:border-gray-700 shadow-md p-4 sm:p-6 lg:p-8 w-full mb-6">
<div
id="spam-warning"
style="display:none;"
class="max-w-xl mx-auto rounded-lg backdrop-blur-sm bg-white/15 dark:bg-slate-900 border border-gray-200 dark:border-gray-700 shadow-md p-4 sm:p-6 lg:p-8 w-full mb-6"
>
<p class="mb-4 text-lg font-medium text-gray-800 dark:text-gray-100">
Your message was detected as spam and was not sent.<br>
<span class="text-base font-normal text-gray-600 dark:text-gray-300">If you believe this is a mistake, you can request a manual review.</span>
{spamReview?.title}<br />
<span class="text-base font-normal text-gray-600 dark:text-gray-300">{spamReview?.description}</span>
</p>
<form id="manual-review-form" class="space-y-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" for="manual-email">Please re-enter your email address for confirmation</label>
<input type="email" id="manual-email" required placeholder="Enter your email address again" 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" />
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" for="manual-justification">Why is this not spam? <span class="text-gray-400">(optional)</span></label>
<textarea id="manual-justification" placeholder="Explain why your message is legitimate..." 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"></textarea>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" for="manual-email"
>{spamReview?.emailLabel}</label
>
<input
type="email"
id="manual-email"
required
placeholder={spamReview?.emailPlaceholder}
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"
/>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" for="manual-justification"
>{spamReview?.justificationLabel} <span class="text-gray-400">{spamReview?.justificationOptional}</span></label
>
<textarea
id="manual-justification"
placeholder={spamReview?.justificationPlaceholder}
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"
></textarea>
<input type="hidden" id="manual-token" />
<button type="submit" class="mt-2 w-full py-3 px-4 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-semibold transition-colors">Request Manual Review</button>
<button
type="submit"
class="mt-2 w-full py-3 px-4 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-semibold transition-colors"
>{spamReview?.button}</button
>
</form>
<div id="manual-review-result" class="mt-4 text-center text-green-700 dark:text-green-400 font-medium"></div>
</div>
<script>
// TypeScript: declare the property on window
declare global {
interface Window {
__originalEmail?: string;
}
}
async function setCsrfToken() {
try {
const res = await fetch('/api/contact?csrf=true');
@@ -153,7 +177,7 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '' }
const data = await res.json();
const csrfInput = document.getElementById('csrf_token');
if (csrfInput && data.csrfToken) {
csrfInput.value = data.csrfToken;
(csrfInput as HTMLInputElement).value = data.csrfToken;
}
}
} catch (e) {
@@ -192,9 +216,11 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '' }
const spamWarning = document.getElementById('spam-warning');
const manualEmail = document.getElementById('manual-email') as HTMLInputElement | null;
const manualToken = document.getElementById('manual-token') as HTMLInputElement | null;
// Store the original email in a variable (not in the input)
window.__originalEmail = String(formData.get('email'));
if (spamWarning && manualEmail && manualToken) {
spamWarning.style.display = 'block';
manualEmail.value = String(formData.get('email'));
manualEmail.value = '';
manualToken.value = result.token;
}
return;
@@ -233,7 +259,11 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '' }
const email = manualEmail.value;
const justification = manualJustification.value;
const token = manualToken.value;
// Check if the entered email matches the original
if (typeof window.__originalEmail !== 'undefined' && email !== window.__originalEmail) {
resultDiv.textContent = 'Email addresses do not match.';
return;
}
const res = await fetch('/api/contact/manual-review', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -241,7 +271,8 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '' }
});
const data = await res.json();
if (data.success) {
resultDiv.textContent = 'Your request for manual review has been submitted. Thank you!';
resultDiv.textContent =
spamReview?.resultSuccess || 'Your request for manual review has been submitted. Thank you!';
// Hide spam-warning and show the normal form again after a short delay
setTimeout(() => {
if (spamWarning) spamWarning.style.display = 'none';
@@ -252,7 +283,8 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '' }
resultDiv.textContent = '';
}, 2000);
} else {
resultDiv.textContent = data.error || 'There was an error submitting your manual review request.';
resultDiv.textContent =
data.error || spamReview?.resultError || 'There was an error submitting your manual review request.';
}
};
}

View File

@@ -12,7 +12,7 @@ const {
const {
container: containerClass = 'max-w-3xl',
title: titleClass = 'text-3xl md:text-4xl ',
subtitle: subtitleClass = 'text-xl',
subtitle: subtitleClass = 'text-base md:text-lg lg:text-xl',
} = classes;
---

View File

@@ -36,7 +36,7 @@ const {
classes={{
container: 'mb-0 md:mb-0',
title: 'text-4xl md:text-4xl font-bold tracking-tighter mb-4 font-heading',
subtitle: 'text-xl text-muted dark:text-slate-400',
subtitle: 'text-base md:text-lg lg:text-xl text-muted dark:text-slate-400',
}}
/>
{

View File

@@ -18,6 +18,7 @@ const {
isDark = false,
classes = {},
bg = await Astro.slots.render('bg'),
spamReview,
} = Astro.props;
---
@@ -33,6 +34,7 @@ const {
disclaimer={disclaimer}
button={button}
description={description}
spamReview={spamReview}
/>
</div>
)

View File

@@ -38,7 +38,7 @@ const {
classes={{
container: 'max-w-xl sm:mx-auto lg:max-w-2xl',
title: 'text-4xl md:text-5xl font-bold tracking-tighter mb-4 font-heading',
subtitle: 'max-w-3xl mx-auto sm:text-center text-xl text-muted dark:text-slate-400',
subtitle: 'max-w-3xl mx-auto sm:text-center text-base md:text-lg lg:text-xl text-muted dark:text-slate-400',
}}
/>
<div class="mx-auto max-w-7xl p-4 md:px-8">

View File

@@ -33,7 +33,7 @@ export interface Props {
position?: string;
}
import { supportedLanguages } from '~/i18n/translations';
import { supportedLanguages, getTranslation } from '~/i18n/translations';
// Get current language from URL
const currentPath = `/${trimSlash(new URL(Astro.url).pathname)}`;
@@ -67,10 +67,12 @@ if (!currentLang) {
// Get translated header data - ensure we're using the current language
const headerData = getHeaderData(currentLang);
// Get translations for the current language
const t = getTranslation(currentLang);
// Filter out the Contact link for the main nav
const navLinks = headerData.links.filter(link => link.text?.toLowerCase() !== 'contact');
const contactLink = headerData.links.find(link => link.text?.toLowerCase() === 'contact');
// Filter out the Contact link for the main nav - use href instead of text to be language-independent
const navLinks = headerData.links.filter((link) => !link.href?.includes('#contact'));
const contactLink = headerData.links.find((link) => link.href?.includes('#contact'));
const {
id = 'header',
@@ -187,16 +189,25 @@ const {
]}
>
<div class="items-center flex justify-between w-full md:w-auto">
<div class="flex items-center">
<div class="flex items-center gap-2">
{showToggleTheme && <ToggleTheme iconClass="w-6 h-6 md:w-5 md:h-5 md:inline-block" />}
{/* Contact Icon */}
{contactLink && (
<a href={contactLink.href} aria-label="Contact" title="Contact" class="hover:text-link dark:hover:text-white flex items-center hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg p-2.5 transition-colors duration-200 mr-[10px]">
<Icon name="tabler:mail" class="w-6 h-6" />
</a>
)}
{/* Contact Icon with proper translation */}
{
contactLink && (
<a
href={contactLink.href}
aria-label={t.navigation.contact}
title={t.navigation.contact}
class="hover:text-link dark:hover:text-white flex items-center hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg p-2.5 transition-colors duration-200 gap-2"
>
<Icon name="tabler:mail" class="w-6 h-6" />
</a>
)
}
{/* Language Selector as Select Element */}
<LanguageDropdown currentLang={currentPath.split('/')[1] || 'en'} />
<div class="flex-shrink-0">
<LanguageDropdown currentLang={currentPath.split('/')[1] || 'en'} />
</div>
</div>
</div>
</div>

View File

@@ -33,7 +33,7 @@ const {
</div>
)
}
<div class="relative max-w-7xl mx-auto px-4 sm:px-6">
<div class="relative max-w-screen-2xl 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">
<div class="text-center pb-10 md:pb-16 max-w-5xl mx-auto">
@@ -57,7 +57,7 @@ const {
{
subtitle && (
<p
class="text-xl mb-6 dark:text-slate-300 intersect-once intersect-quarter motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade content-backdrop p-2 rounded-md"
class="text-base md:text-lg lg:text-xl mb-6 dark:text-slate-300 intersect-once intersect-quarter motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade content-backdrop p-2 rounded-md"
set:html={subtitle}
/>
)

View File

@@ -26,10 +26,10 @@ const {
{bg ? <Fragment set:html={bg} /> : <Background isDark={isDark} showIcons={false} disableParallax={true} />}
</slot>
</div>
<div class="relative max-w-7xl mx-auto px-4 sm:px-6">
<div class="relative w-full px-0">
<div class="pt-0 md:pt-[76px] pointer-events-none"></div>
<div class="py-12 md:py-20 lg:py-0 lg:flex lg:items-center lg:h-screen lg:gap-8">
<div class="basis-1/2 text-center lg:text-left pb-10 md:pb-16 mx-auto">
<div class="basis-1/2 text-center lg:text-left pb-10 md:pb-16 mx-auto pl-4 md:pl-8 lg:pl-12">
{
tagline && (
<p
@@ -50,7 +50,7 @@ const {
{
subtitle && (
<p
class="text-xl text-muted mb-6 dark:text-slate-300 content-backdrop p-2 rounded-md"
class="text-base md:text-lg lg:text-xl text-muted mb-6 dark:text-slate-300 content-backdrop p-2 rounded-md"
set:html={subtitle}
/>
)

View File

@@ -47,7 +47,7 @@ const {
{
subtitle && (
<p
class="text-xl text-muted mb-6 dark:text-slate-300 intersect-once intersect-quarter motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade"
class="text-base md:text-lg lg:text-xl text-muted mb-6 dark:text-slate-300 intersect-once intersect-quarter motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade"
set:html={subtitle}
/>
)

View File

@@ -0,0 +1,314 @@
---
import Headline from '~/components/ui/Headline.astro';
import WidgetWrapper from '~/components/ui/WidgetWrapper.astro';
import Button from '~/components/ui/Button.astro';
import { Icon } from 'astro-icon/components';
import type { Testimonials as Props } from '~/types';
const {
title = '',
subtitle = '',
tagline = '',
testimonials = [],
callToAction,
id,
isDark = false,
classes = {},
bg = await Astro.slots.render('bg'),
} = Astro.props;
// Function to get the correct skill icon based on title
const getSkillIcon = (name: string): string => {
const nameLower = name.toLowerCase();
if (nameLower.includes('entra') || nameLower.includes('identity')) return 'tabler:shield-lock';
if (nameLower.includes('power automate') || nameLower.includes('automated processes')) return 'tabler:robot';
if (nameLower.includes('copilot') || nameLower.includes('agents')) return 'tabler:brain';
return 'tabler:certificate';
};
// Function to get the correct gradient colors based on skill
const getSkillGradient = (name: string): string => {
const nameLower = name.toLowerCase();
if (nameLower.includes('entra') || nameLower.includes('identity')) return 'from-blue-600 to-indigo-700';
if (nameLower.includes('power automate') || nameLower.includes('automated processes')) return 'from-purple-600 to-pink-600';
if (nameLower.includes('copilot') || nameLower.includes('agents')) return 'from-green-600 to-teal-600';
return 'from-gray-500 to-gray-600';
};
---
<WidgetWrapper id={id} isDark={isDark} containerClass={`max-w-7xl mx-auto ${classes?.container ?? ''}`} bg={bg}>
<Headline title={title} subtitle={subtitle} tagline={tagline} classes={{
container: 'max-w-3xl',
title: 'text-3xl lg:text-4xl',
}} />
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mt-8">
{
testimonials &&
testimonials.map(({ linkUrl, name, issueDate, description }) => {
const icon = getSkillIcon(name || '');
const gradient = getSkillGradient(name || '');
return (
<div
class="applied-skill-card bg-white/95 dark:bg-slate-900/95 backdrop-blur-sm rounded-2xl p-6 transition-all duration-300 cursor-pointer border border-gray-100 dark:border-slate-800 hover:transform hover:scale-105 hover:shadow-xl relative overflow-hidden group"
data-applied-skill-title={name}
data-applied-skill-date={issueDate}
data-applied-skill-description={description}
data-applied-skill-url={linkUrl}
itemscope
itemtype="https://schema.org/EducationalOccupationalCredential"
>
<!-- Top gradient bar -->
<div class={`absolute top-0 left-0 right-0 h-1 bg-gradient-to-r ${gradient}`}></div>
<!-- Microsoft Applied Skills Badge -->
<div class="flex items-center justify-center mb-4">
<div class={`px-3 py-1 rounded-full text-xs font-medium text-white bg-gradient-to-r ${gradient} shadow-sm`}>
Microsoft Applied Skills
</div>
</div>
<!-- Content -->
<div class="text-center">
<h3
class="font-semibold text-base text-gray-900 dark:text-white mb-3 line-clamp-3 group-hover:text-transparent group-hover:bg-clip-text group-hover:bg-gradient-to-r group-hover:from-blue-600 group-hover:to-purple-600 transition-all duration-300 leading-relaxed"
itemprop="name"
>
{name}
</h3>
<p
class={`text-sm font-medium text-transparent bg-clip-text bg-gradient-to-r ${gradient}`}
itemprop="validIn"
>
{issueDate}
</p>
<meta itemprop="credentialCategory" content="Microsoft Applied Skills" />
<meta itemprop="recognizedBy" content="Microsoft" />
{linkUrl && <meta itemprop="url" content={linkUrl} />}
</div>
<!-- Hover effect overlay -->
<div class="absolute inset-0 bg-gradient-to-r from-blue-500/5 to-purple-500/5 opacity-0 group-hover:opacity-100 transition-opacity duration-300 rounded-2xl"></div>
<!-- Shimmer effect -->
<div class="absolute inset-0 bg-gradient-to-r from-transparent via-black/10 to-transparent dark:via-white/10 -translate-x-full group-hover:translate-x-full transition-transform duration-700 ease-out"></div>
</div>
);
})
}
</div>
{
callToAction && (
<div class="flex justify-center mx-auto w-fit mt-8 font-medium">
<Button {...callToAction} />
</div>
)
}
</WidgetWrapper>
<!-- Applied Skills Details Modal -->
<div id="appliedSkillModal" class="applied-skill-modal fixed inset-0 bg-black/50 backdrop-blur-sm z-50 hidden items-center justify-center p-4">
<div class="applied-skill-modal-content bg-white dark:bg-slate-800 rounded-2xl p-6 max-w-2xl w-full mx-4 max-h-[80vh] overflow-y-auto transform scale-90 opacity-0 transition-all duration-300">
<div class="flex justify-between items-start mb-4">
<div>
<h3 id="appliedSkillModalTitle" class="text-2xl font-bold text-gray-900 dark:text-white mb-1"></h3>
<p id="appliedSkillModalDate" class="text-sm text-blue-600 dark:text-blue-400 font-medium"></p>
</div>
<button onclick="closeAppliedSkillModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
<Icon name="tabler:x" class="w-6 h-6" />
</button>
</div>
<p id="appliedSkillModalDescription" class="text-gray-700 dark:text-gray-300 leading-relaxed mb-6"></p>
<div class="flex justify-end">
<a id="appliedSkillModalLink" href="#" target="_blank" rel="noopener noreferrer" class="inline-flex items-center px-6 py-3 bg-gradient-to-r from-blue-600 to-purple-600 text-white font-semibold rounded-xl hover:shadow-lg transition-all duration-300 hover:scale-105">
<Icon name="tabler:external-link" class="w-4 h-4 mr-2" />
View Applied Skill
</a>
</div>
</div>
</div>
<style>
.applied-skill-card {
animation: fadeInUp 0.6s ease-out forwards;
opacity: 0;
transform: translateY(20px);
}
.applied-skill-card:nth-child(1) { animation-delay: 0.1s; }
.applied-skill-card:nth-child(2) { animation-delay: 0.15s; }
.applied-skill-card:nth-child(3) { animation-delay: 0.2s; }
@keyframes fadeInUp {
to {
opacity: 1;
transform: translateY(0);
}
}
.applied-skill-modal {
backdrop-filter: blur(10px);
}
.applied-skill-modal.active {
display: flex !important;
}
.applied-skill-modal.active .applied-skill-modal-content {
transform: scale(1);
opacity: 1;
}
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>
<script is:inline>
// Applied Skills widget touch handling - completely isolated to avoid conflicts
(function() {
let appliedSkillTouchStart = { x: 0, y: 0, time: 0 };
let appliedSkillIsScrolling = false;
let appliedSkillOriginalScrollPosition = 0;
let appliedSkillClickedElement = null;
function appliedSkillHandleTouchStart(e) {
const touch = e.touches[0];
appliedSkillTouchStart = {
x: touch.clientX,
y: touch.clientY,
time: Date.now()
};
appliedSkillIsScrolling = false;
}
function appliedSkillHandleTouchMove(e) {
const touch = e.touches[0];
const deltaX = Math.abs(touch.clientX - appliedSkillTouchStart.x);
const deltaY = Math.abs(touch.clientY - appliedSkillTouchStart.y);
if (deltaX > 10 || deltaY > 10) {
appliedSkillIsScrolling = true;
}
}
function appliedSkillHandleTouchEnd(e, element) {
const touch = e.changedTouches[0];
const touchEndTime = Date.now();
if (appliedSkillIsScrolling) return;
const touchDuration = touchEndTime - appliedSkillTouchStart.time;
if (touchDuration > 300) return;
const deltaX = Math.abs(touch.clientX - appliedSkillTouchStart.x);
const deltaY = Math.abs(touch.clientY - appliedSkillTouchStart.y);
if (deltaX > 15 || deltaY > 15) return;
e.preventDefault();
showAppliedSkillModal(element);
}
window.showAppliedSkillModal = function(element) {
// Store the clicked element and current scroll position
appliedSkillClickedElement = element;
appliedSkillOriginalScrollPosition = window.pageYOffset || document.documentElement.scrollTop;
const title = element.dataset.appliedSkillTitle;
const date = element.dataset.appliedSkillDate;
const description = element.dataset.appliedSkillDescription;
const url = element.dataset.appliedSkillUrl;
document.getElementById('appliedSkillModalTitle').textContent = title;
document.getElementById('appliedSkillModalDate').textContent = date;
document.getElementById('appliedSkillModalDescription').textContent = description;
document.getElementById('appliedSkillModalLink').href = url;
const modal = document.getElementById('appliedSkillModal');
modal.classList.add('active');
setTimeout(() => {
const modalContent = modal.querySelector('.applied-skill-modal-content');
if (modalContent) {
modalContent.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, 100);
};
window.closeAppliedSkillModal = function() {
const modal = document.getElementById('appliedSkillModal');
modal.classList.remove('active');
// Restore scroll position to the original card
if (appliedSkillClickedElement) {
setTimeout(() => {
// First, scroll to the original position
window.scrollTo({
top: appliedSkillOriginalScrollPosition,
behavior: 'smooth'
});
// Then, ensure the clicked card is visible with a slight offset
setTimeout(() => {
const elementRect = appliedSkillClickedElement.getBoundingClientRect();
const isElementVisible = elementRect.top >= 0 && elementRect.bottom <= window.innerHeight;
if (!isElementVisible) {
appliedSkillClickedElement.scrollIntoView({
behavior: 'smooth',
block: 'center'
});
}
}, 300);
}, 100);
}
// Clear the reference
appliedSkillClickedElement = null;
};
document.addEventListener('DOMContentLoaded', function() {
// Select only applied skill cards with the specific class and data attributes
const appliedSkillCards = document.querySelectorAll('.applied-skill-card[data-applied-skill-title]');
appliedSkillCards.forEach(card => {
card.addEventListener('click', function(e) {
if (!('ontouchstart' in window)) {
e.preventDefault();
showAppliedSkillModal(this);
}
});
card.addEventListener('touchstart', appliedSkillHandleTouchStart, { passive: true });
card.addEventListener('touchmove', appliedSkillHandleTouchMove, { passive: true });
card.addEventListener('touchend', function(e) {
appliedSkillHandleTouchEnd(e, this);
}, { passive: false });
});
const appliedSkillModal = document.getElementById('appliedSkillModal');
if (appliedSkillModal) {
appliedSkillModal.addEventListener('click', function(e) {
if (e.target === this) {
closeAppliedSkillModal();
}
});
}
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeAppliedSkillModal();
}
});
});
})();

View File

@@ -67,7 +67,7 @@ const timelineItems = items.map((item) => {
{title}
</h2>
)}
{subtitle && <p class={`${compact ? 'text-lg' : 'text-xl'} text-muted dark:text-slate-400`}>{subtitle}</p>}
{subtitle && <p class={`${compact ? 'text-base md:text-lg' : 'text-base md:text-lg lg:text-xl'} text-muted dark:text-slate-400`}>{subtitle}</p>}
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,454 @@
export interface ExplosiveHomepageTranslation {
hero: {
title: string;
subtitle: string;
};
actions: {
learnMore: string;
contactMe: string;
};
services: {
tagline: string;
title: string;
subtitle: string;
items: {
title: string;
description: string;
}[];
};
approach: {
tagline: string;
title: string;
expertise: string;
methods: {
title: string;
description: string;
}[];
highlights: string;
};
cta: {
button: string;
title: string;
subtitle: string;
};
contact: {
title: string;
subtitle: string;
description: string;
fields: {
name: string;
namePlaceholder: string;
email: string;
emailPlaceholder: string;
message: string;
messagePlaceholder: string;
};
disclaimer: string;
};
spamReview: {
title: string;
description: string;
emailLabel: string;
emailPlaceholder: string;
justificationLabel: string;
justificationOptional: string;
justificationPlaceholder: string;
button: string;
resultSuccess: string;
resultError: string;
};
}
export const supportedLanguages = ['en', 'nl', 'de', 'fr'] as const;
export function getExplosiveHomepageTranslation(lang: string): ExplosiveHomepageTranslation {
return explosiveHomepageTranslations[lang] || explosiveHomepageTranslations['en'];
}
export const explosiveHomepageTranslations: Record<string, ExplosiveHomepageTranslation> = {
en: {
hero: {
title: 'Future-Proof Your Business',
subtitle:
'Harness the power of AI automation • Microsoft 365 expertise • Tailored integrations that deliver real results',
},
actions: {
learnMore: 'Get Started Now',
contactMe: 'Contact Me',
},
services: {
tagline: 'AUTOMATION POWERHOUSE',
title: 'Services That Deliver Measurable Impact',
subtitle: 'Stop spending valuable time on manual tasks. Lets automate efficiently and boost your productivity.',
items: [
{
title: 'Power Automate Expertise',
description: 'Transform 8-hour tasks into 5-minute workflows',
},
{
title: 'AI Chatbots That Deliver',
description: 'Intelligent bots that understand your business needs',
},
{
title: 'Robust API Integrations',
description: 'Connect all your tools seamlessly',
},
{
title: 'Microsoft 365 Optimization',
description: 'Make Microsoft 365 work the way it was meant to from day one',
},
{
title: 'SharePoint That Works',
description: 'Finally, a SharePoint experience your team will want to use',
},
{
title: 'Scalable Infrastructure',
description: 'Reliable systems that grow with your business',
},
],
},
approach: {
tagline: 'THE BERGSMA METHOD',
title: 'Less Talk. More Results.',
expertise: 'Over 15 years of experience turning technology into business value',
methods: [
{
title: 'Impact-Driven Solutions',
description: 'Every implementation must drive measurable business outcomes',
},
{
title: 'Rapid Implementation',
description: 'Quick delivery with results from day one',
},
{
title: 'Ongoing Evolution',
description: 'Your tech stack evolves as your business grows',
},
],
highlights:
'• Microsoft MVP-level expertise\n• Power Automate flows that save thousands of hours\n• AI integrations that create real value\n• Enterprise-grade solutions with startup agility',
},
cta: {
button: 'START YOUR TRANSFORMATION',
title: 'Ready to Multiply Your Efficiency?',
subtitle: 'Premium automation services • Proven results • Fast implementation',
},
contact: {
title: 'Lets Create Something Exceptional',
subtitle: 'Ready to automate your path to success? Get in touch and lets take your business to the next level.',
description: 'Fast response guaranteed • Enterprise-ready solutions • Data-driven outcomes',
fields: {
name: '👋 Your Name',
namePlaceholder: 'How should I address you?',
email: '📧 Email',
emailPlaceholder: 'your.email@company.com',
message: '💭 Tell Me About Your Project',
messagePlaceholder:
'Which business process slows you down? What would you automate if you had no technical limits?',
},
disclaimer: '🔒 Your information stays secure. No spam — just results.',
},
spamReview: {
title: "Your message was detected as spam and was not sent.",
description: "If you believe this is a mistake, you can request a manual review.",
emailLabel: "Please re-enter your email address for confirmation",
emailPlaceholder: "Enter your email address again",
justificationLabel: "Why is this not spam?",
justificationOptional: "(optional)",
justificationPlaceholder: "Explain why your message is legitimate...",
button: "Request Manual Review",
resultSuccess: "Your request for manual review has been submitted. Thank you!",
resultError: "There was an error submitting your manual review request."
},
},
nl: {
hero: {
title: 'Maak Je Bedrijf Toekomstbestendig',
subtitle:
'Ontgrendel de kracht van AI-automatisering • Microsoft 365-expertise • Maatwerk integraties die écht werken',
},
actions: {
learnMore: 'Begin Nu',
contactMe: 'Neem Contact Op',
},
services: {
tagline: 'KRACHTCENTRALE VOOR AUTOMATISERING',
title: 'Diensten Die Écht Impact Maken',
subtitle:
'Verspil geen tijd meer aan handmatige taken. Laten we automatiseren en je productiviteit naar een hoger niveau tillen.',
items: [
{
title: 'Power Automate-Expertise',
description: 'Maak van 8-uurstaken workflows van 5 minuten',
},
{
title: 'AI-Chatbots Die Werken',
description: 'Slimme bots die jouw organisatie écht begrijpen',
},
{
title: 'Krachtige API-integraties',
description: 'Verbind systemen naadloos met elkaar',
},
{
title: 'Microsoft 365 Optimalisatie',
description: 'Laat M365 werken zoals het vanaf dag één bedoeld was',
},
{
title: 'SharePoint Die Wel Werkt',
description: 'Eindelijk een SharePoint-oplossing die mensen graag gebruiken',
},
{
title: 'Schaalbare Infrastructuur',
description: 'Betrouwbare systemen die met je bedrijf meegroeien',
},
],
},
approach: {
tagline: 'DE BERGSMA-METHODE',
title: 'Minder Woorden, Meer Resultaten',
expertise: 'Meer dan 15 jaar ervaring in technologie die écht werkt voor bedrijven',
methods: [
{
title: 'Focus op Impact',
description: 'Elke oplossing moet je organisatie vooruithelpen',
},
{
title: 'Snelle Implementatie',
description: 'Snelle levering, direct resultaat',
},
{
title: 'Continue Ontwikkeling',
description: 'Je techstack groeit mee met je ambities',
},
],
highlights:
'• Microsoft MVP-niveau expertise\n• Power Automate-flows die duizenden uren besparen\n• AI-integraties met echte meerwaarde\n• Enterprise-oplossingen met de wendbaarheid van een startup',
},
cta: {
button: 'START JE TRANSFORMATIE',
title: 'Klaar om je efficiëntie te vertienvoudigen?',
subtitle: 'Premium automatiseringsoplossingen • Gegarandeerd resultaat • Snelle implementatie',
},
contact: {
title: 'Laten We Samen Iets Geweldigs Bouwen',
subtitle:
'Klaar om je pad naar succes te automatiseren? Stuur me een bericht en laten we je bedrijf onweerstaanbaar maken.',
description: 'Snelle reactie gegarandeerd • Enterprise-ready oplossingen • Meetbare resultaten',
fields: {
name: '👋 Jouw Naam',
namePlaceholder: 'Hoe kan ik je aanspreken?',
email: '📧 E-mail',
emailPlaceholder: 'jouw.email@bedrijf.nl',
message: '💭 Vertel me over je project',
messagePlaceholder:
'Welk bedrijfsproces frustreert je het meest? Wat zou je automatiseren als alles mogelijk was?',
},
disclaimer: '🔒 Jouw gegevens blijven privé. Geen spam alleen oplossingen.',
},
spamReview: {
title: "Je bericht is als spam gemarkeerd en niet verzonden.",
description: "Denk je dat dit onterecht is? Vraag dan een handmatige beoordeling aan.",
emailLabel: "Voer je e-mailadres opnieuw in ter bevestiging",
emailPlaceholder: "Voer opnieuw je e-mailadres in",
justificationLabel: "Waarom is dit geen spam?",
justificationOptional: "(optioneel)",
justificationPlaceholder: "Leg uit waarom je bericht legitiem is...",
button: "Handmatige beoordeling aanvragen",
resultSuccess: "Je verzoek voor handmatige beoordeling is ingediend. Bedankt!",
resultError: "Er is een fout opgetreden bij het verzenden van je verzoek."
},
},
de: {
hero: {
title: 'Machen Sie Ihr Unternehmen zukunftssicher',
subtitle:
'Entfesseln Sie die Kraft der KI-Automatisierung • Microsoft 365-Expertise • Maßgeschneiderte Integrationen, die echte Ergebnisse liefern',
},
actions: {
learnMore: 'Jetzt starten',
contactMe: 'Kontakt aufnehmen',
},
services: {
tagline: 'AUTOMATISIERUNGS-KRAFTWERK',
title: 'Services, die wirklich Wirkung zeigen',
subtitle:
'Verschwenden Sie keine Zeit mehr mit manuellen Aufgaben. Lassen Sie uns automatisieren für maximale Effizienz.',
items: [
{
title: 'Power-Automate-Expertise',
description: 'Verwandeln Sie 8-Stunden-Aufgaben in 5-Minuten-Workflows',
},
{
title: 'KI-Chatbots, die funktionieren',
description: 'Intelligente Bots, die Ihr Unternehmen wirklich verstehen',
},
{
title: 'Zuverlässige API-Integrationen',
description: 'Verbinden Sie Ihre Systeme reibungslos miteinander',
},
{
title: 'Microsoft 365-Optimierung',
description: 'Nutzen Sie M365 so, wie es von Anfang an gedacht war',
},
{
title: 'SharePoint, das überzeugt',
description: 'Endlich eine SharePoint-Lösung, die Ihre Teams gerne nutzen',
},
{
title: 'Skalierbare Infrastruktur',
description: 'Robuste Systeme, die mit Ihrem Unternehmen wachsen',
},
],
},
approach: {
tagline: 'DIE BERGSMA-METHODE',
title: 'Weniger reden. Mehr Ergebnisse.',
expertise: 'Über 15 Jahre Erfahrung darin, Technologie in echten Mehrwert für Unternehmen zu verwandeln',
methods: [
{
title: 'Wirkungsorientierte Lösungen',
description: 'Jede Umsetzung muss Ihr Unternehmen messbar voranbringen',
},
{
title: 'Schnelle Implementierung',
description: 'Schnelle Bereitstellung, sofortige Ergebnisse',
},
{
title: 'Kontinuierliche Weiterentwicklung',
description: 'Ihr Tech-Stack wächst im Einklang mit Ihrem Unternehmen',
},
],
highlights:
'• Microsoft MVP-Level-Expertise\n• Power-Automate-Flows, die Tausende Stunden sparen\n• KI-Integrationen mit echtem Mehrwert\n• Enterprise-Lösungen mit Startup-Agilität',
},
cta: {
button: 'STARTEN SIE IHRE TRANSFORMATION',
title: 'Bereit, Ihre Effizienz zu verzehnfachen?',
subtitle: 'Premium-Automatisierung • Garantierte Ergebnisse • Schnelle Umsetzung',
},
contact: {
title: 'Lassen Sie uns etwas Großartiges schaffen',
subtitle:
'Bereit, Ihre Prozesse zu automatisieren? Schreiben Sie mir gemeinsam bringen wir Ihr Unternehmen auf das nächste Level.',
description: 'Schnelle Rückmeldung garantiert • Enterprise-taugliche Lösungen • Messbare Resultate',
fields: {
name: '👋 Ihr Name',
namePlaceholder: 'Wie darf ich Sie ansprechen?',
email: '📧 E-Mail',
emailPlaceholder: 'ihre.email@firma.de',
message: '💭 Erzählen Sie mir von Ihrem Projekt',
messagePlaceholder:
'Welcher Geschäftsprozess kostet Sie Zeit? Was würden Sie automatisieren, wenn alles möglich wäre?',
},
disclaimer: '🔒 Ihre Daten sind bei uns sicher. Kein Spam nur Lösungen.',
},
spamReview: {
title: "Ihre Nachricht wurde als Spam erkannt und nicht versendet.",
description: "Wenn Sie der Meinung sind, dass es sich um einen Fehler handelt, können Sie eine manuelle Prüfung anfordern.",
emailLabel: "Bitte geben Sie zur Bestätigung Ihre E-Mail-Adresse erneut ein",
emailPlaceholder: "E-Mail-Adresse erneut eingeben",
justificationLabel: "Warum ist das kein Spam?",
justificationOptional: "(optional)",
justificationPlaceholder: "Erklären Sie, warum Ihre Nachricht legitim ist...",
button: "Manuelle Prüfung anfordern",
resultSuccess: "Ihre Anfrage zur manuellen Prüfung wurde übermittelt. Vielen Dank!",
resultError: "Beim Absenden Ihrer Anfrage ist ein Fehler aufgetreten."
},
},
fr: {
hero: {
title: 'Rendez Votre Entreprise Pérenne',
subtitle:
'Libérez la puissance de lautomatisation par lIA • Maîtrise de Microsoft 365 • Intégrations sur mesure, fiables et efficaces',
},
actions: {
learnMore: 'Commencez dès maintenant',
contactMe: 'Contactons-nous',
},
services: {
tagline: 'CENTRALE DAUTOMATISATION',
title: 'Des Services Qui Font Vraiment la Différence',
subtitle:
'Ne perdez plus de temps avec des tâches manuelles. Automatisez intelligemment et boostez votre productivité.',
items: [
{
title: 'LExcellence de Power Automate',
description: 'Transformez des tâches de 8 heures en workflows de 5 minutes',
},
{
title: 'Des Chatbots IA Performants',
description: 'Des bots intelligents qui comprennent vraiment votre activité',
},
{
title: 'Intégrations API Fiables',
description: 'Connectez vos outils, sans limites',
},
{
title: 'Optimisation de Microsoft 365',
description: 'Faites fonctionner M365 comme il aurait dû dès le départ',
},
{
title: 'Un SharePoint qui Donne Envie',
description: 'Enfin un SharePoint que vos équipes aiment utiliser',
},
{
title: 'Infrastructure Scalable',
description: 'Des systèmes robustes qui évoluent avec votre entreprise',
},
],
},
approach: {
tagline: 'LA MÉTHODE BERGSMA',
title: 'Moins de Théorie, Plus de Résultats',
expertise: 'Plus de 15 ans à perfectionner lart de faire de la technologie un moteur pour les entreprises',
methods: [
{
title: 'Concentration sur lImpact',
description: 'Chaque solution doit réellement faire progresser votre entreprise',
},
{
title: 'Mise en Œuvre Rapide',
description: 'Déploiement agile, résultats concrets',
},
{
title: 'Évolution Continue',
description: 'Votre stack technologique évolue au rythme de votre croissance',
},
],
highlights:
'• Expertise de niveau Microsoft MVP\n• Flows Power Automate économisant des milliers dheures\n• Intégrations IA pertinentes et efficaces\n• Solutions dentreprise avec lagilité des startups',
},
cta: {
button: 'DÉMARREZ VOTRE TRANSFORMATION',
title: 'Prêt à décupler votre efficacité ?',
subtitle: 'Solutions dautomatisation haut de gamme • Résultats garantis • Déploiement rapide',
},
contact: {
title: 'Créons Ensemble Quelque Chose dExceptionnel',
subtitle: 'Vous êtes prêt à automatiser votre succès ? Écrivez-moi et donnons à votre entreprise un nouvel élan.',
description: 'Réponse rapide assurée • Solutions prêtes pour lentreprise • Résultats mesurables',
fields: {
name: '👋 Votre Nom',
namePlaceholder: 'Comment puis-je vous appeler ?',
email: '📧 Email',
emailPlaceholder: 'votre.email@entreprise.fr',
message: '💭 Présentez-moi Votre Projet',
messagePlaceholder:
'Quel processus métier vous ralentit ? Quaimeriez-vous automatiser si tout était possible ?',
},
disclaimer: '🔒 Vos données sont confidentielles. Aucune publicité uniquement des solutions concrètes.',
},
spamReview: {
title: "Votre message a été détecté comme spam et na pas été envoyé.",
description: "Si vous pensez quil sagit dune erreur, vous pouvez demander une révision manuelle.",
emailLabel: "Veuillez saisir à nouveau votre adresse e-mail pour confirmation",
emailPlaceholder: "Saisissez à nouveau votre adresse e-mail",
justificationLabel: "Pourquoi ce message nest-il pas du spam ?",
justificationOptional: "(optionnel)",
justificationPlaceholder: "Expliquez pourquoi votre message est légitime...",
button: "Demander une révision manuelle",
resultSuccess: "Votre demande de révision manuelle a été envoyée. Merci !",
resultError: "Une erreur sest produite lors de lenvoi de votre demande."
},
},
};

View File

@@ -143,7 +143,7 @@ const translations: Record<SupportedLanguage, JourniiTranslation> = {
cta: {
title: "Stay Updated",
desc: "Journii is currently in active development. Follow our progress and be among the first to know when it launches.",
github: "View on GitHub",
github: "View on Gitea",
updates: "Get Updates"
}
},
@@ -225,7 +225,7 @@ const translations: Record<SupportedLanguage, JourniiTranslation> = {
cta: {
title: "Blijf op de Hoogte",
desc: "Journii is momenteel in actieve ontwikkeling. Volg onze voortgang en wees een van de eersten die het weet wanneer het wordt gelanceerd.",
github: "Bekijk op GitHub",
github: "Bekijk op Gitea",
updates: "Ontvang Updates"
}
},
@@ -307,7 +307,7 @@ const translations: Record<SupportedLanguage, JourniiTranslation> = {
cta: {
title: "Bleiben Sie Auf Dem Laufenden",
desc: "Journii befindet sich derzeit in aktiver Entwicklung. Verfolgen Sie unseren Fortschritt und gehören Sie zu den Ersten, die erfahren, wann es startet.",
github: "Auf GitHub Ansehen",
github: "Auf Gitea Ansehen",
updates: "Updates Erhalten"
}
},
@@ -389,7 +389,7 @@ const translations: Record<SupportedLanguage, JourniiTranslation> = {
cta: {
title: "Restez Informé",
desc: "Journii est actuellement en développement actif. Suivez nos progrès et soyez parmi les premiers à savoir quand il sera lancé.",
github: "Voir sur GitHub",
github: "Voir sur Gitea",
updates: "Recevoir les Mises à Jour"
}
}

View File

@@ -65,7 +65,7 @@ export const mainTranslations: Record<string, Omit<Translation, 'blog' | 'antifp
{
title: 'Workflow Automation',
description:
'Automate repetitive tasks and streamline processes with Power Automate, freeing up your team to focus on strategic initiatives and boosting overall efficiency.',
'Automate repetitive tasks and streamline processes with Power Automate, freeing up your team to focus on strategic initiatives and boosting overall efficiency.',
icon: 'tabler:settings-automation',
},
{

View File

@@ -9,6 +9,7 @@ export interface Translation {
resume: string;
certifications: string;
skills: string;
appliedSkills: string;
education: string;
blog: string;
services: string;
@@ -86,6 +87,16 @@ export interface Translation {
};
}[];
};
appliedSkills: {
title: string;
subtitle: string;
items: {
name: string;
issueDate: string;
description: string;
linkUrl: string;
}[];
};
skills: {
title: string;
subtitle: string;
@@ -176,6 +187,7 @@ export const translations: Record<string, Translation> = {
resume: 'Resume',
certifications: 'Certifications',
skills: 'Skills',
appliedSkills: 'Applied Skills',
education: 'Education',
blog: 'Blog',
services: 'Services',
@@ -544,6 +556,33 @@ export const translations: Record<string, Translation> = {
},
],
},
appliedSkills: {
title: 'Applied Skills',
subtitle: 'Practical Expertise in Action',
items: [
{
name: 'Get started with identities and access using Microsoft Entra',
issueDate: 'Date Issued: 06-2025',
description:
'Earning this Microsoft Applied Skill demonstrates proficiency in managing identities and access using Microsoft Entra. This skill validates expertise in implementing identity solutions, managing user access, and securing organizational resources through Microsoft\'s identity and access management platform.',
linkUrl: 'https://learn.microsoft.com/en-us/credentials/applied-skills/entra-identities-access/',
},
{
name: 'Create and manage automated processes with Power Automate',
issueDate: 'Date Issued: 06-2025',
description:
'Earning this Microsoft Applied Skill demonstrates expertise in creating and managing automated workflows using Power Automate. This skill validates proficiency in designing business process automation, integrating with various data sources, and implementing efficient workflow solutions to streamline organizational operations.',
linkUrl: 'https://learn.microsoft.com/en-us/credentials/applied-skills/power-automate-processes/',
},
{
name: 'Create agents in Microsoft Copilot Studio',
issueDate: 'Date Issued: 06-2025',
description:
'Earning this Microsoft Applied Skill demonstrates proficiency in developing intelligent conversational agents using Microsoft Copilot Studio. This skill validates expertise in creating AI-powered chatbots, implementing natural language processing, and building interactive solutions that enhance user engagement and support.',
linkUrl: 'https://learn.microsoft.com/en-us/credentials/applied-skills/copilot-studio-agents/',
},
],
},
skills: {
title: 'Skills',
subtitle: 'Discover the proficiencies that allow me to bring imagination to life through design.',
@@ -632,6 +671,7 @@ export const translations: Record<string, Translation> = {
resume: 'CV',
certifications: 'Certificeringen',
skills: 'Vaardigheden',
appliedSkills: 'Toegepaste Vaardigheden',
education: 'Opleiding',
blog: 'Blog',
services: 'Diensten',
@@ -1000,6 +1040,33 @@ export const translations: Record<string, Translation> = {
},
],
},
appliedSkills: {
title: 'Toegepaste Vaardigheden',
subtitle: 'Praktische Expertise in Actie',
items: [
{
name: 'Aan de slag met identiteiten en toegang met Microsoft Entra',
issueDate: 'Datum van afgifte: 06-2025',
description:
'Het behalen van deze Microsoft Applied Skill toont vaardigheid aan in het beheren van identiteiten en toegang met Microsoft Entra. Deze vaardigheid valideert expertise in het implementeren van identiteitsoplossingen, het beheren van gebruikers toegang en het beveiligen van organisatorische middelen via Microsoft\'s identiteits- en toegangsbeheerplatform.',
linkUrl: 'https://learn.microsoft.com/en-us/credentials/applied-skills/entra-identities-access/',
},
{
name: 'Geautomatiseerde processen maken en beheren met Power Automate',
issueDate: 'Datum van afgifte: 06-2025',
description:
'Het behalen van deze Microsoft Applied Skill toont expertise aan in het maken en beheren van geautomatiseerde workflows met Power Automate. Deze vaardigheid valideert bekwaamheid in het ontwerpen van bedrijfsprocesautomatisering, het integreren met verschillende gegevensbronnen en het implementeren van efficiënte workflow-oplossingen om organisatorische operaties te stroomlijnen.',
linkUrl: 'https://learn.microsoft.com/en-us/credentials/applied-skills/power-automate-processes/',
},
{
name: 'Agents maken in Microsoft Copilot Studio',
issueDate: 'Datum van afgifte: 06-2025',
description:
'Het behalen van deze Microsoft Applied Skill toont vaardigheid aan in het ontwikkelen van intelligente conversatie-agents met Microsoft Copilot Studio. Deze vaardigheid valideert expertise in het maken van AI-aangedreven chatbots, het implementeren van natuurlijke taalverwerking en het bouwen van interactieve oplossingen die gebruikersbetrokkenheid en ondersteuning verbeteren.',
linkUrl: 'https://learn.microsoft.com/en-us/credentials/applied-skills/copilot-studio-agents/',
},
],
},
skills: {
title: 'Vaardigheden',
subtitle:
@@ -1089,6 +1156,7 @@ export const translations: Record<string, Translation> = {
resume: 'Lebenslauf',
certifications: 'Zertifizierungen',
skills: 'Fähigkeiten',
appliedSkills: 'Angewandte Fähigkeiten',
education: 'Ausbildung',
blog: 'Blog',
services: 'Dienstleistungen',
@@ -1457,6 +1525,33 @@ export const translations: Record<string, Translation> = {
},
],
},
appliedSkills: {
title: 'Angewandte Fähigkeiten',
subtitle: 'Praktische Expertise in Aktion',
items: [
{
name: 'Erste Schritte mit Identitäten und Zugriff mit Microsoft Entra',
issueDate: 'Ausstellungsdatum: 06-2025',
description:
'Der Erwerb dieser Microsoft Applied Skill zeigt Kompetenz in der Verwaltung von Identitäten und Zugriff mit Microsoft Entra. Diese Fähigkeit validiert Expertise in der Implementierung von Identitätslösungen, der Verwaltung von Benutzerzugriff und der Sicherung organisatorischer Ressourcen über Microsofts Identitäts- und Zugriffsverwaltungsplattform.',
linkUrl: 'https://learn.microsoft.com/en-us/credentials/applied-skills/entra-identities-access/',
},
{
name: 'Automatisierte Prozesse mit Power Automate erstellen und verwalten',
issueDate: 'Ausstellungsdatum: 06-2025',
description:
'Der Erwerb dieser Microsoft Applied Skill zeigt Expertise in der Erstellung und Verwaltung automatisierter Workflows mit Power Automate. Diese Fähigkeit validiert Kompetenz im Design von Geschäftsprozessautomatisierung, der Integration mit verschiedenen Datenquellen und der Implementierung effizienter Workflow-Lösungen zur Optimierung organisatorischer Abläufe.',
linkUrl: 'https://learn.microsoft.com/en-us/credentials/applied-skills/power-automate-processes/',
},
{
name: 'Agents in Microsoft Copilot Studio erstellen',
issueDate: 'Ausstellungsdatum: 06-2025',
description:
'Der Erwerb dieser Microsoft Applied Skill zeigt Kompetenz in der Entwicklung intelligenter Konversations-Agents mit Microsoft Copilot Studio. Diese Fähigkeit validiert Expertise in der Erstellung KI-gestützter Chatbots, der Implementierung natürlicher Sprachverarbeitung und dem Aufbau interaktiver Lösungen, die Benutzerengagement und Support verbessern.',
linkUrl: 'https://learn.microsoft.com/en-us/credentials/applied-skills/copilot-studio-agents/',
},
],
},
skills: {
title: 'Fähigkeiten',
subtitle:
@@ -1546,6 +1641,7 @@ export const translations: Record<string, Translation> = {
resume: 'CV',
certifications: 'Certifications',
skills: 'Compétences',
appliedSkills: 'Compétences Appliquées',
education: 'Formation',
blog: 'Blog',
services: 'Services',
@@ -1914,6 +2010,33 @@ export const translations: Record<string, Translation> = {
},
],
},
appliedSkills: {
title: 'Compétences Appliquées',
subtitle: 'Expertise Pratique en Action',
items: [
{
name: 'Commencer avec les identités et l\'accès avec Microsoft Entra',
issueDate: 'Date de délivrance : 06-2025',
description:
'L\'obtention de cette Microsoft Applied Skill démontre la compétence dans la gestion des identités et de l\'accès avec Microsoft Entra. Cette compétence valide l\'expertise dans l\'implémentation de solutions d\'identité, la gestion de l\'accès utilisateur et la sécurisation des ressources organisationnelles via la plateforme de gestion d\'identité et d\'accès de Microsoft.',
linkUrl: 'https://learn.microsoft.com/en-us/credentials/applied-skills/entra-identities-access/',
},
{
name: 'Créer et gérer des processus automatisés avec Power Automate',
issueDate: 'Date de délivrance : 06-2025',
description:
'L\'obtention de cette Microsoft Applied Skill démontre l\'expertise dans la création et la gestion de workflows automatisés avec Power Automate. Cette compétence valide la maîtrise dans la conception de l\'automatisation des processus métier, l\'intégration avec diverses sources de données et l\'implémentation de solutions de workflow efficaces pour rationaliser les opérations organisationnelles.',
linkUrl: 'https://learn.microsoft.com/en-us/credentials/applied-skills/power-automate-processes/',
},
{
name: 'Créer des agents dans Microsoft Copilot Studio',
issueDate: 'Date de délivrance : 06-2025',
description:
'L\'obtention de cette Microsoft Applied Skill démontre la compétence dans le développement d\'agents conversationnels intelligents avec Microsoft Copilot Studio. Cette compétence valide l\'expertise dans la création de chatbots alimentés par l\'IA, l\'implémentation du traitement du langage naturel et la construction de solutions interactives qui améliorent l\'engagement et le support des utilisateurs.',
linkUrl: 'https://learn.microsoft.com/en-us/credentials/applied-skills/copilot-studio-agents/',
},
],
},
skills: {
title: 'Compétences',
subtitle: "Découvrez les compétences qui me permettent de donner vie à l'imagination par le design.",

View File

@@ -0,0 +1,174 @@
// Focused YouTube userscript page translations
import { supportedLanguages } from './translations';
export type SupportedLanguage = typeof supportedLanguages[number];
export interface FocusedYouTubeTranslation {
title: string;
summary: string;
features: {
title: string;
items: string[];
};
howToInstall: string;
step1: string;
step2: string;
step3: string;
step4: string;
notes: {
title: string;
items: string[];
};
targetAudience: string;
moreDetails: string;
viewOnGreasyFork: string;
}
const translations: Record<SupportedLanguage, FocusedYouTubeTranslation> = {
en: {
title: 'Focused YouTube Remove Distractions & Ads',
summary: 'Focused YouTube is a userscript that removes ads, shorts, and algorithmic suggestions from YouTube. Enjoy a cleaner, distraction-free experience in English, Dutch, German, and French.',
features: {
title: 'Key Features',
items: [
'Removes all YouTube ads (pre-roll, banners, overlays)',
'Hides Shorts and algorithmic suggestions',
'Redirects Shorts to regular video player',
'Skips video ads automatically',
'Hides live chat and related videos',
'Forces cinema/theater mode for better focus',
'Supports multiple languages (EN/NL/DE/FR)',
'Customizable settings for homepage, search, and more',
'Works on both desktop and mobile YouTube',
'Open source and privacy-friendly',
],
},
howToInstall: 'How to Install',
step1: 'Install a userscript manager like Tampermonkey or Violentmonkey in your browser.',
step2: 'Visit the Focused YouTube script page on Greasy Fork: https://greasyfork.org/en/scripts/541103-focused-youtube',
step3: 'Click "Install" to add the script to your userscript manager.',
step4: 'Refresh YouTube and enjoy a cleaner, distraction-free experience!',
notes: {
title: 'Important Notes',
items: [
'Some features may break if YouTube updates its layout. The script will be updated as needed.',
'You can customize settings by editing the script or using the userscript manager options.',
'No data is collected or sent anywhere. 100% privacy-friendly.',
],
},
targetAudience: 'Anyone who wants to focus on content, avoid distractions, and block ads on YouTube.',
moreDetails: 'For more information, source code, and updates, visit greasyfork.org or contact the author.',
viewOnGreasyFork: 'View on Greasy Fork',
},
nl: {
title: 'Focused YouTube Verwijder Afleiding & Advertenties',
summary: 'Focused YouTube is een gebruikersscript dat advertenties, Shorts en algoritmische aanbevelingen op YouTube verwijdert. Geniet van een schonere, afleidingsvrije ervaring in het Engels, Nederlands, Duits en Frans.',
features: {
title: 'Belangrijkste functies',
items: [
'Verwijdert alle YouTube-advertenties (pre-roll, banners, overlays)',
'Verbergt Shorts en algoritmische aanbevelingen',
'Redirects Shorts naar de normale videospeler',
'Slaat videoadvertenties automatisch over',
'Verbergt livechat en gerelateerde video\'s',
'Forceert de bioscoopmodus voor meer focus',
'Ondersteunt meerdere talen (EN/NL/DE/FR)',
'Aanpasbare instellingen voor startpagina, zoeken en meer',
'Werkt op zowel desktop als mobiele YouTube',
'Open source en privacyvriendelijk',
],
},
howToInstall: 'Installatiehandleiding',
step1: 'Installeer een userscriptmanager zoals Tampermonkey of Violentmonkey in je browser.',
step2: 'Bezoek de Focused YouTube-scriptpagina op Greasy Fork: https://greasyfork.org/en/scripts/541103-focused-youtube',
step3: 'Klik op "Installeren" om het script toe te voegen aan je userscriptmanager.',
step4: 'Ververs YouTube en geniet van een schonere, afleidingsvrije ervaring!',
notes: {
title: 'Belangrijke opmerkingen',
items: [
'Sommige functies kunnen breken als YouTube zijn layout aanpast. Het script wordt indien nodig bijgewerkt.',
'Je kunt instellingen aanpassen door het script te bewerken of via de manageropties.',
'Er worden geen gegevens verzameld of verzonden. 100% privacyvriendelijk.',
],
},
targetAudience: 'Iedereen die zich wil concentreren op inhoud, afleiding wil vermijden en advertenties op YouTube wil blokkeren.',
moreDetails: 'Voor meer informatie, broncode en updates, bezoek de greasyfork.org site of neem contact op met de auteur.',
viewOnGreasyFork: 'Bekijk op Greasy Fork',
},
de: {
title: 'Focused YouTube Ablenkungen & Werbung entfernen',
summary: 'Focused YouTube ist ein Userscript, das Werbung, Shorts und algorithmische Empfehlungen von YouTube entfernt. Genieße ein aufgeräumtes, ablenkungsfreies YouTube-Erlebnis in Englisch, Niederländisch, Deutsch und Französisch.',
features: {
title: 'Hauptfunktionen',
items: [
'Entfernt alle YouTube-Werbung (Pre-Roll, Banner, Overlays)',
'Blendet Shorts und algorithmische Vorschläge aus',
'Leitet Shorts zum normalen Videoplayer um',
'Überspringt Videowerbung automatisch',
'Blendet Live-Chat und verwandte Videos aus',
'Erzwingt Kinomodus für besseren Fokus',
'Unterstützt mehrere Sprachen (EN/NL/DE/FR)',
'Anpassbare Einstellungen für Startseite, Suche und mehr',
'Funktioniert auf Desktop- und Mobil-YouTube',
'Open Source und datenschutzfreundlich',
],
},
howToInstall: 'Installationsanleitung',
step1: 'Installiere einen Userscript-Manager wie Tampermonkey oder Violentmonkey in deinem Browser.',
step2: 'Besuche die Focused YouTube-Skriptseite auf Greasy Fork: https://greasyfork.org/en/scripts/541103-focused-youtube',
step3: 'Klicke auf „Installieren“, um das Skript deinem Userscript-Manager hinzuzufügen.',
step4: 'Lade YouTube neu und genieße eine ablenkungsfreie Nutzung!',
notes: {
title: 'Wichtige Hinweise',
items: [
'Einige Funktionen könnten bei YouTube-Layoutänderungen nicht mehr funktionieren. Das Skript wird entsprechend aktualisiert.',
'Du kannst Einstellungen im Skript oder über die Manageroptionen anpassen.',
'Es werden keinerlei Daten gesammelt oder übertragen. 100% datenschutzfreundlich.',
],
},
targetAudience: 'Alle, die sich auf Inhalte konzentrieren, Ablenkungen vermeiden und Werbung auf YouTube blockieren möchten.',
moreDetails: 'Für weitere Informationen, Quellcode und Updates besuche das greasyfork.org site oder kontaktiere den Autor.',
viewOnGreasyFork: 'Auf Greasy Fork ansehen',
},
fr: {
title: 'Focused YouTube Supprimer les distractions et les publicités',
summary: 'Focused YouTube est un userscript qui supprime les publicités, les Shorts et les suggestions algorithmiques sur YouTube. Profitez dune expérience plus claire et sans distractions en anglais, néerlandais, allemand et français.',
features: {
title: 'Fonctionnalités principales',
items: [
'Supprime toutes les publicités YouTube (pré-roll, bannières, superpositions)',
'Masque les Shorts et suggestions algorithmiques',
'Redirige les Shorts vers le lecteur vidéo classique',
'Passe automatiquement les publicités vidéo',
'Masque le chat en direct et les vidéos connexes',
'Active automatiquement le mode cinéma pour une meilleure concentration',
'Prise en charge de plusieurs langues (EN/NL/DE/FR)',
'Paramètres personnalisables pour la page daccueil, la recherche, etc.',
'Fonctionne sur YouTube version bureau et mobile',
'Open source et respectueux de la vie privée',
],
},
howToInstall: 'Comment linstaller',
step1: 'Installez un gestionnaire de userscripts comme Tampermonkey ou Violentmonkey dans votre navigateur.',
step2: 'Visitez la page du script Focused YouTube sur Greasy Fork : https://greasyfork.org/en/scripts/541103-focused-youtube',
step3: 'Cliquez sur "Installer" pour ajouter le script à votre gestionnaire de scripts.',
step4: 'Rafraîchissez YouTube et profitez dune expérience sans distractions !',
notes: {
title: 'Remarques importantes',
items: [
'Certaines fonctionnalités peuvent cesser de fonctionner si YouTube modifie sa mise en page. Le script sera mis à jour si nécessaire.',
'Vous pouvez personnaliser les paramètres en modifiant le script ou via le gestionnaire de scripts.',
'Aucune donnée nest collectée ni envoyée. 100% respectueux de la vie privée.',
],
},
targetAudience: 'Toute personne souhaitant se concentrer sur le contenu, éviter les distractions et bloquer les publicités sur YouTube.',
moreDetails: 'Pour plus dinformations, le code source et les mises à jour, consultez le dépôt greasyfork.org ou contactez lauteur.',
viewOnGreasyFork: 'Voir sur Greasy Fork',
},
};
export function getFocusedYouTubeTranslation(lang: SupportedLanguage): FocusedYouTubeTranslation {
return translations[lang] || translations.en;
}
export const focusedYouTubeSupportedLanguages = Object.keys(translations) as SupportedLanguage[];

View File

@@ -1,7 +1,7 @@
---
import '~/assets/styles/tailwind.css';
import { I18N } from 'astrowind:config';
import { I18N, SITE } from 'astrowind:config';
import CommonMeta from '~/components/common/CommonMeta.astro';
import Favicons from '~/components/Favicons.astro';
@@ -52,10 +52,10 @@ const { language, textDirection } = I18N;
'@context': 'https://schema.org',
'@type': 'WebSite',
name: '365DevNet',
url: Astro.url.origin,
url: SITE.site,
potentialAction: {
'@type': 'SearchAction',
target: `${Astro.url.origin}/search?q={search_term_string}`,
target: `${SITE.site}/search?q={search_term_string}`,
'query-input': 'required name=search_term_string',
},
}}
@@ -67,23 +67,87 @@ const { language, textDirection } = I18N;
<body class="antialiased text-default bg-page tracking-tight">
<GlobalBackground />
<slot name="structured-data" />
<slot />
<header role="banner">
<slot name="header" />
</header>
<nav role="navigation" aria-label="Main navigation">
<slot name="navigation" />
</nav>
<main role="main">
<slot name="structured-data" />
<slot />
</main>
<footer role="contentinfo">
<slot name="footer" />
</footer>
<BasicScripts />
<LanguagePersistence />
<CookieBanner />
<BackToTop />
<!-- Start of Rocket.Chat Livechat Script -->
<script type="text/javascript" defer>
<script type="text/javascript">
// Initialize RocketChat livechat widget with proper error handling
(function(w, d, s, u) {
w.RocketChat = function(c) { w.RocketChat._.push(c) }; w.RocketChat._ = []; w.RocketChat.url = u;
const h = d.getElementsByTagName(s)[0], j = d.createElement(s);
j.async = true; j.src = 'https://chat.365devnet.eu/livechat/rocketchat-livechat.min.js?_=201903270000';
h.parentNode.insertBefore(j, h);
console.log('RocketChat initialization started');
// Initialize RocketChat object if it doesn't exist
w.RocketChat = function(c) {
if (w.RocketChat._) {
w.RocketChat._.push(c);
} else {
w.RocketChat._ = [c];
}
};
if (!w.RocketChat._) {
w.RocketChat._ = [];
}
w.RocketChat.url = u;
console.log('RocketChat URL set to:', u);
// Wait for DOM to be ready before loading the script
function loadRocketChat() {
try {
console.log('Loading RocketChat script...');
const h = d.getElementsByTagName(s)[0];
if (!h) {
console.warn('No script tag found to insert before');
return;
}
const j = d.createElement(s);
j.async = true;
j.src = 'https://chat.365devnet.eu/livechat/rocketchat-livechat.min.js?_=201903270000';
// Add error handling for script loading
j.onerror = function() {
console.warn('RocketChat livechat script failed to load');
};
j.onload = function() {
console.log('RocketChat script loaded successfully');
};
h.parentNode.insertBefore(j, h);
console.log('RocketChat script tag inserted');
} catch (error) {
console.warn('Error loading RocketChat livechat:', error);
}
}
// Load when DOM is ready
if (d.readyState === 'loading') {
console.log('DOM still loading, waiting for DOMContentLoaded');
d.addEventListener('DOMContentLoaded', loadRocketChat);
} else {
console.log('DOM already loaded, loading RocketChat immediately');
loadRocketChat();
}
})(window, document, 'script', 'https://chat.365devnet.eu/livechat');
</script>
<!-- End of Rocket.Chat Livechat Script -->
<ImageModal />
</body>
</html>

View File

@@ -33,6 +33,11 @@ export const getHeaderData = (lang = 'en') => {
href: getPermalink('/aboutme', 'page', lang) + '#certifications',
isHashLink: true,
},
{
text: t.navigation.appliedSkills,
href: getPermalink('/aboutme', 'page', lang) + '#microsoft-applied-skills',
isHashLink: true,
},
{
text: t.navigation.education,
href: getPermalink('/aboutme', 'page', lang) + '#education',
@@ -43,11 +48,6 @@ export const getHeaderData = (lang = 'en') => {
{
text: t.development.menu,
links: [
{
text: t.development.title,
href: getPermalink('/development', 'page', lang),
isHashLink: false,
},
{
text: 'Anti-FP Shield+',
href: getPermalink('/antifp', 'page', lang),
@@ -58,12 +58,22 @@ export const getHeaderData = (lang = 'en') => {
href: getPermalink('/eap', 'page', lang),
isHashLink: false,
},
{
text: 'Focused YouTube',
href: getPermalink('/focusedyoutube', 'page', lang),
isHashLink: false,
},
{
text: 'Journii',
href: getPermalink('/journii', 'page', lang),
isHashLink: false,
},
],
{
text: t.development.title,
href: getPermalink('/development', 'page', lang),
isHashLink: false,
},
].sort((a, b) => a.text.localeCompare(b.text)),
},
],
};
@@ -89,4 +99,4 @@ export const getFooterData = (lang = 'en') => {
};
// For backward compatibility
export const footerData = getFooterData();
export const footerData = getFooterData();

View File

@@ -2,7 +2,11 @@
import Layout from '~/layouts/Layout.astro';
import { getHomePermalink } from '~/utils/permalinks';
const title = `Error 404`;
const metadata = {
title: '404 Not Found | 365DevNet',
description: 'Sorry, the page you are looking for does not exist. Return to the homepage or explore other sections of 365DevNet.',
robots: { index: false, follow: false },
};
---
<style>
@@ -382,7 +386,7 @@ const title = `Error 404`;
}
</style>
<Layout metadata={{ title }}>
<Layout metadata={metadata}>
<section class="clean-404-bg">
<div class="floating-elements-404">
<div class="floating-shape-404 shape-404-1"></div>

View File

@@ -7,11 +7,13 @@ import Content from '~/components/widgets/Content.astro';
import ModernEducation from '~/components/widgets/ModernEducation.astro';
import ModernWorkExperience from '~/components/widgets/ModernWorkExperience.astro';
import ModernCertifications from '~/components/widgets/ModernCertifications.astro';
import ModernAppliedSkills from '~/components/widgets/ModernAppliedSkills.astro';
import ModernSkills from '~/components/widgets/ModernSkills.astro';
import CallToAction from '~/components/widgets/CallToAction.astro';
import HomePageImage from '~/assets/images/richardbergsma.png';
import { getTranslation, supportedLanguages } from '~/i18n/translations';
import { getTranslation } from '~/i18n/translations';
import { getAboutMeTranslation, supportedLanguages } from '~/i18n/translations.aboutme';
import { SITE } from 'astrowind:config';
export async function getStaticPaths() {
return supportedLanguages.map((lang) => ({
@@ -24,7 +26,10 @@ if (!supportedLanguages.includes(lang)) {
return Astro.redirect('/en/aboutme');
}
const t = getTranslation(lang);
// Get main translations for shared components (navigation, footer, etc.)
const mainT = getTranslation(lang);
// Get about me specific translations
const t = getAboutMeTranslation(lang);
const metadata = {
title: 'About Richard Bergsma | IT Systems & Automation Specialist | 365DevNet',
@@ -288,6 +293,21 @@ const metadata = {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Light mode fixes for secondary CTA button */
html:not(.dark) .cta-secondary-enhanced {
background: rgba(255, 255, 255, 0.95) !important;
color: rgba(15, 23, 42, 1) !important;
border-color: rgba(102, 126, 234, 0.6) !important;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1) !important;
}
html:not(.dark) .cta-secondary-enhanced:hover {
background: rgba(102, 126, 234, 0.1) !important;
color: rgba(15, 23, 42, 1) !important;
border-color: rgba(102, 126, 234, 0.8) !important;
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.2) !important;
}
/* Section spacing - key fix for touching sections */
.content-section {
margin: 4rem 0;
@@ -369,11 +389,11 @@ const metadata = {
'@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,
description: mainT.hero.subtitle,
image: SITE.site + '/images/richardbergsma.png',
url: SITE.site,
sameAs: ['https://www.linkedin.com/in/rrpbergsma', 'https://github.com/rrpbergsma'],
knowsAbout: t.skills.items.map((skill) => skill.title),
knowsAbout: mainT.skills.items.map((skill) => skill.title),
worksFor: {
'@type': 'Organization',
name: 'COFRA Holding C.V.',
@@ -396,13 +416,13 @@ const metadata = {
</Fragment>
<Fragment slot="title">
<span class="hero-title-enhanced">{t.hero.greeting}</span>
<span class="hero-title-enhanced">{mainT.hero.greeting}</span>
</Fragment>
<Fragment slot="subtitle">
<div class="hero-subtitle-container">
<div class="hero-subtitle-enhanced">
{t.hero.subtitle}
{mainT.hero.subtitle}
</div>
<div class="cta-container">
<a href="#about" class="cta-primary-enhanced">
@@ -436,7 +456,7 @@ const metadata = {
{t.about.title}
</h2>
{
t.about.content.map((paragraph, index) => (
t.about.content.map((paragraph) => (
<div class="mb-6">
<p class="text-lg leading-relaxed text-gray-700 dark:text-gray-300">{paragraph}</p>
</div>
@@ -507,6 +527,24 @@ const metadata = {
/>
</div>
<!-- Enhanced Applied Skills with Modern Grid -->
<div id="microsoft-applied-skills" class="content-section stagger-animation">
<ModernAppliedSkills
id="microsoft-applied-skills"
title={t.appliedSkills.title}
subtitle={t.appliedSkills.subtitle}
testimonials={t.appliedSkills.items.map((skill) => ({
name: skill.name,
issueDate: skill.issueDate,
description: skill.description,
linkUrl: skill.linkUrl,
}))}
classes={{
container: 'glass-enhanced'
}}
/>
</div>
<!-- Enhanced Education -->
<div id="education" class="content-section stagger-animation">
<ModernEducation

View File

@@ -0,0 +1,157 @@
---
export const prerender = true;
import Layout from '~/layouts/PageLayout.astro';
import { getFocusedYouTubeTranslation, focusedYouTubeSupportedLanguages } from '~/i18n/translations.youtube';
export async function getStaticPaths() {
return focusedYouTubeSupportedLanguages.map((lang) => ({
params: { lang },
}));
}
const { lang } = Astro.params;
if (!focusedYouTubeSupportedLanguages.includes(lang)) {
return Astro.redirect('/en/focusedyoutube');
}
const t = getFocusedYouTubeTranslation(lang);
const metadata = {
title: t.title,
description: t.summary,
};
---
<Layout metadata={metadata}>
<div class="max-w-4xl mx-auto px-4 py-8">
<!-- Hero Section -->
<div class="text-center mb-12 backdrop-blur-sm bg-gradient-to-br from-red-50/80 to-orange-50/80 dark:from-slate-800/80 dark:to-slate-900/80 rounded-2xl p-8 shadow-lg">
<div class="text-6xl mb-4">🎬</div>
<h1 class="text-4xl md:text-5xl font-bold mb-4 bg-gradient-to-r from-red-600 to-orange-600 bg-clip-text text-transparent">
{t.title}
</h1>
<p class="text-xl text-gray-600 dark:text-slate-300 mb-6 max-w-2xl mx-auto">
{t.summary}
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<a
href="https://greasyfork.org/en/scripts/541103-focused-youtube"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center px-6 py-3 bg-gradient-to-r from-red-600 to-orange-600 text-white rounded-lg font-semibold hover:from-red-700 hover:to-orange-700 transition-all shadow-lg"
>
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
</svg>
{t.viewOnGreasyFork}
</a>
</div>
</div>
<!-- Description -->
<div class="backdrop-blur-sm bg-white/70 dark:bg-slate-900/70 rounded-xl shadow p-8 mb-12">
<p class="text-lg text-gray-700 dark:text-slate-200 leading-relaxed text-center">
{t.summary}
</p>
</div>
<!-- Features Grid -->
<div class="mb-12">
<h2 class="text-3xl font-bold text-center mb-8">{t.features.title}</h2>
<div class="grid md:grid-cols-2 gap-6">
{t.features.items.map((item, i) => (
<div class="backdrop-blur-sm bg-white/70 dark:bg-slate-900/70 rounded-xl shadow p-6 hover:shadow-lg transition-shadow">
<div class="flex items-start">
<div class="text-2xl mr-4 mt-1">
{i === 0 ? '🚫' :
i === 1 ? '🙈' :
i === 2 ? '🔁' :
i === 3 ? '⏩' :
i === 4 ? '💬' :
i === 5 ? '🎥' :
i === 6 ? '🌐' :
i === 7 ? '⚙️' :
i === 8 ? '📱' :
i === 9 ? '🔓' : '🔧'}
</div>
<div>
<p class="text-gray-700 dark:text-slate-200">{item}</p>
</div>
</div>
</div>
))}
</div>
</div>
<!-- Installation Guide -->
<div class="backdrop-blur-sm bg-gradient-to-r from-blue-50/80 to-purple-50/80 dark:from-slate-800/80 dark:to-slate-900/80 rounded-xl shadow p-8 mb-12">
<div class="text-center mb-6">
<div class="text-4xl mb-4">📦</div>
<h2 class="text-2xl font-bold mb-4">{t.howToInstall}</h2>
</div>
<div class="max-w-2xl mx-auto">
<ol class="space-y-4">
<li class="flex items-start">
<div class="flex-shrink-0 w-8 h-8 bg-blue-600 text-white rounded-full flex items-center justify-center font-bold text-sm mr-4 mt-1">1</div>
<p class="text-gray-700 dark:text-slate-200">{t.step1}</p>
</li>
<li class="flex items-start">
<div class="flex-shrink-0 w-8 h-8 bg-blue-600 text-white rounded-full flex items-center justify-center font-bold text-sm mr-4 mt-1">2</div>
<p class="text-gray-700 dark:text-slate-200">{t.step2}</p>
</li>
<li class="flex items-start">
<div class="flex-shrink-0 w-8 h-8 bg-blue-600 text-white rounded-full flex items-center justify-center font-bold text-sm mr-4 mt-1">3</div>
<p class="text-gray-700 dark:text-slate-200">{t.step3}</p>
</li>
<li class="flex items-start">
<div class="flex-shrink-0 w-8 h-8 bg-blue-600 text-white rounded-full flex items-center justify-center font-bold text-sm mr-4 mt-1">4</div>
<p class="text-gray-700 dark:text-slate-200">{t.step4}</p>
</li>
</ol>
</div>
</div>
<!-- Important Notes -->
<div class="backdrop-blur-sm bg-white/70 dark:bg-slate-900/70 rounded-xl shadow p-8 mb-12">
<div class="flex items-center mb-6">
<div class="text-3xl mr-4">⚠️</div>
<h2 class="text-2xl font-bold">{t.notes.title}</h2>
</div>
<div class="grid md:grid-cols-1 gap-4">
{t.notes.items.map((item, index) => (
<div class="flex items-start p-4 bg-yellow-50/50 dark:bg-yellow-900/20 rounded-lg border-l-4 border-yellow-500">
<div class="text-yellow-600 dark:text-yellow-400 mr-3 mt-1">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
</svg>
</div>
<p class="text-gray-700 dark:text-slate-200">{item}</p>
</div>
))}
</div>
</div>
<!-- Target Audience & More Info -->
<div class="grid md:grid-cols-2 gap-6">
<div class="backdrop-blur-sm bg-gradient-to-br from-green-50/80 to-blue-50/80 dark:from-slate-800/80 dark:to-slate-900/80 rounded-xl shadow p-6">
<div class="text-center mb-4">
<div class="text-3xl mb-2">👥</div>
<h3 class="text-xl font-bold">Who Is This For?</h3>
</div>
<p class="text-gray-700 dark:text-slate-200 italic text-center">
{t.targetAudience}
</p>
</div>
<div class="backdrop-blur-sm bg-gradient-to-br from-purple-50/80 to-pink-50/80 dark:from-slate-800/80 dark:to-slate-900/80 rounded-xl shadow p-6">
<div class="text-center mb-4">
<div class="text-3xl mb-2">📚</div>
<h3 class="text-xl font-bold">Learn More</h3>
</div>
<p class="text-gray-700 dark:text-slate-200 text-center">
{t.moreDetails}
</p>
</div>
</div>
</div>
</Layout>

View File

@@ -0,0 +1,213 @@
---
export const prerender = true;
import Layout from '~/layouts/PageLayout.astro';
import Hero from '~/components/widgets/Hero2.astro';
import Features from '~/components/widgets/Features.astro';
import Content from '~/components/widgets/Content.astro';
import Testimonials from '~/components/widgets/Testimonials.astro';
import CallToAction from '~/components/widgets/CallToAction.astro';
import Contact from '~/components/widgets/Contact.astro';
import { getTranslation, supportedLanguages } from '~/i18n/translations';
import OurCommitmentImage from '~/assets/images/OurCommitment.webp';
export async function getStaticPaths() {
return supportedLanguages.map((lang) => ({
params: { lang },
}));
}
const { lang } = Astro.params;
if (!supportedLanguages.includes(lang)) {
return Astro.redirect('/en/');
}
const t = getTranslation(lang);
const metadata = {
title: 'IT Systems & Automation for Businesses',
description: 'IT systems & automation for businesses. Microsoft 365, Power Automate, and cloud solutions by Richard Bergsma.'
};
---
<Layout metadata={metadata}>
<!-- Hero Widget -->
<Hero
title={t.hero.title}
subtitle={t.hero.subtitle}
isDark={false}
actions={[
{
variant: 'primary',
text: t.homepage?.actions?.learnMore || 'Learn More',
href: '#services',
icon: 'tabler:arrow-down',
},
{ text: t.homepage?.actions?.contactMe || 'Contact Me', href: '#contact' },
]}
image={{
src: '~/assets/images/HomepageIntroImage.webp',
alt: 'person sitting behind a computer with pen and paper next to him, A coffee mug and tablet on the desk',
}}
/>
<!-- 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' }))}
/>
<!-- Content Widget -->
<Content
isReversed
tagline={t.homepage?.approach?.tagline || 'About My Approach'}
title={t.homepage?.approach?.title || 'Our Commitment'}
items={[]}
image={{
src: OurCommitmentImage,
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>)
}
</div>
</Fragment>
</Content>
<!-- Testimonials Widget
<Testimonials
tagline={t.homepage?.testimonials?.tagline || "Testimonials"}
title={t.homepage?.testimonials?.title || "What Clients Say About My Work"}
testimonials={(t.homepage?.testimonials?.items || [
{
testimonial:
"Richard's expertise in Power Automate transformed our workflow processes, saving us countless hours and reducing errors significantly.",
name: 'Client Name',
description: 'Position, Company',
},
{
testimonial:
"The SharePoint implementation Richard delivered has revolutionized our document management and team collaboration capabilities.",
name: 'Client Name',
description: 'Position, Company',
},
{
testimonial:
"Richard's technical knowledge combined with his ability to understand our business needs resulted in solutions that truly addressed our challenges.",
name: 'Client Name',
description: 'Position, Company',
},
]).map(item => ({
...item,
image: {
src: '~/assets/images/default.png',
alt: item.name,
}
}))}
/> -->
<!-- CallToAction Widget
<CallToAction
callToAction={{
text: t.homepage?.callToAction?.button || 'Contact Me',
href: '#contact',
icon: 'tabler:mail',
}}
>
<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."
}
</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."}
inputs={[
{
type: 'text',
name: 'name',
label: t.homepage?.contact?.nameLabel || 'Name',
placeholder: t.homepage?.contact?.namePlaceholder || 'Your name',
},
{
type: 'email',
name: 'email',
label: t.homepage?.contact?.emailLabel || 'Email',
placeholder: t.homepage?.contact?.emailPlaceholder || 'Your email address',
},
]}
textarea={{
label: t.homepage?.contact?.messageLabel || 'Message',
placeholder: t.homepage?.contact?.messagePlaceholder || 'Your message',
rows: 8,
}}
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."}
spamReview={t.homepage?.spamReview}
/>
</Layout>

View File

@@ -4,11 +4,12 @@ import Layout from '~/layouts/PageLayout.astro';
import Hero from '~/components/widgets/Hero2.astro';
import Features from '~/components/widgets/Features.astro';
import Content from '~/components/widgets/Content.astro';
import Testimonials from '~/components/widgets/Testimonials.astro';
import CallToAction from '~/components/widgets/CallToAction.astro';
import Contact from '~/components/widgets/Contact.astro';
import { getTranslation, supportedLanguages } from '~/i18n/translations';
import { getExplosiveHomepageTranslation } from '~/i18n/translations.homepage.ts';
import OurCommitmentImage from '~/assets/images/OurCommitment.webp';
import HomepageIntroImage from '~/assets/images/HomepageIntroImage.webp';
export async function getStaticPaths() {
return supportedLanguages.map((lang) => ({
@@ -22,191 +23,649 @@ if (!supportedLanguages.includes(lang)) {
}
const t = getTranslation(lang);
const explosive = getExplosiveHomepageTranslation(lang);
const metadata = {
title: 'IT Systems & Automation for Businesses',
description: 'IT systems & automation for businesses. Microsoft 365, Power Automate, and cloud solutions by Richard Bergsma.'
title: 'IT Systems & Automation for Businesses | Power Automate & Microsoft 365 Expert',
description: 'Transform your business with expert IT automation solutions. Specializing in Power Automate, Microsoft 365, SharePoint, and custom API integrations for enhanced productivity and efficiency.'
};
---
<style>
/* Explosive hero background with neon vibes */
.hero-explosive {
background:
radial-gradient(circle at 20% 80%, #ff006e 0%, transparent 50%),
radial-gradient(circle at 80% 20%, #8338ec 0%, transparent 50%),
radial-gradient(circle at 40% 40%, #3a86ff 0%, transparent 50%),
linear-gradient(135deg, #06ffa5 0%, #ff006e 25%, #8338ec 50%, #3a86ff 75%, #06ffa5 100%);
background-size: 300% 300%, 250% 250%, 400% 400%, 600% 600%;
animation: explosiveGradient 15s ease infinite;
position: relative;
overflow: hidden;
}
/* Floating orbs with vivid colors */
.floating-orbs {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
overflow: hidden;
}
.orb {
position: absolute;
border-radius: 50%;
filter: blur(2px);
animation: orbFloat 20s infinite ease-in-out;
}
.orb-1 {
width: 150px;
height: 150px;
background: radial-gradient(circle, #ff006e, #ff6b9d);
top: 10%;
left: 15%;
animation-delay: 0s;
box-shadow: 0 0 30px rgba(255, 0, 110, 0.3);
}
.orb-2 {
width: 200px;
height: 200px;
background: radial-gradient(circle, #8338ec, #a663cc);
top: 70%;
right: 10%;
animation-delay: -7s;
box-shadow: 0 0 40px rgba(131, 56, 236, 0.3);
}
.orb-3 {
width: 120px;
height: 120px;
background: radial-gradient(circle, #3a86ff, #72b4ff);
bottom: 30%;
left: 70%;
animation-delay: -14s;
box-shadow: 0 0 25px rgba(58, 134, 255, 0.3);
}
.orb-4 {
width: 180px;
height: 180px;
background: radial-gradient(circle, #06ffa5, #59ffb8);
top: 40%;
left: 5%;
animation-delay: -10s;
box-shadow: 0 0 35px rgba(6, 255, 165, 0.3);
}
.orb-5 {
width: 100px;
height: 100px;
background: radial-gradient(circle, #ffbe0b, #ffd23f);
top: 20%;
right: 30%;
animation-delay: -3s;
box-shadow: 0 0 20px rgba(255, 190, 11, 0.3);
}
@keyframes explosiveGradient {
0%, 100% { background-position: 0% 0%, 0% 0%, 0% 0%, 0% 50%; }
25% { background-position: 100% 100%, 50% 50%, 25% 75%, 100% 50%; }
50% { background-position: 50% 25%, 100% 0%, 75% 25%, 50% 100%; }
75% { background-position: 25% 75%, 25% 100%, 50% 50%, 0% 50%; }
}
@keyframes orbFloat {
0%, 100% {
transform: translateY(0px) translateX(0px) scale(1);
opacity: 0.6;
}
25% {
transform: translateY(-60px) translateX(40px) scale(1.2);
opacity: 0.8;
}
50% {
transform: translateY(-30px) translateX(-20px) scale(0.9);
opacity: 0.7;
}
75% {
transform: translateY(-80px) translateX(60px) scale(1.1);
opacity: 0.9;
}
}
/* Vibrant glass cards */
:global(.glass-vibrant) {
background: rgba(255, 255, 255, 0.1) !important;
backdrop-filter: blur(30px) !important;
border: 2px solid rgba(255, 255, 255, 0.2) !important;
box-shadow:
0 25px 45px rgba(0, 0, 0, 0.1),
0 0 0 1px rgba(255, 255, 255, 0.1) inset,
0 0 60px rgba(102, 126, 234, 0.3) !important;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1) !important;
border-radius: 32px !important;
position: relative;
overflow: hidden;
}
:global(.glass-vibrant::before) {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg,
rgba(255, 0, 110, 0.1) 0%,
rgba(131, 56, 236, 0.1) 25%,
rgba(58, 134, 255, 0.1) 50%,
rgba(6, 255, 165, 0.1) 75%,
rgba(255, 190, 11, 0.1) 100%);
opacity: 0;
transition: opacity 0.4s ease;
pointer-events: none;
}
:global(.glass-vibrant:hover::before) {
opacity: 1;
}
:global(.glass-vibrant:hover) {
box-shadow:
0 40px 80px rgba(0, 0, 0, 0.2),
0 0 0 2px rgba(255, 255, 255, 0.2) inset,
0 0 100px rgba(102, 126, 234, 0.5) !important;
}
/* Dark mode vibrant */
:global(.dark .glass-vibrant) {
background: rgba(0, 0, 0, 0.3) !important;
border-color: rgba(255, 255, 255, 0.2) !important;
box-shadow:
0 25px 45px rgba(0, 0, 0, 0.4),
0 0 0 1px rgba(255, 255, 255, 0.1) inset,
0 0 60px rgba(131, 56, 236, 0.4) !important;
}
:global(.dark .glass-vibrant:hover) {
box-shadow:
0 40px 80px rgba(0, 0, 0, 0.6),
0 0 0 2px rgba(255, 255, 255, 0.2) inset,
0 0 100px rgba(131, 56, 236, 0.6) !important;
}
/* Refined text effects */
.neon-title {
font-size: clamp(3rem, 8vw, 6rem);
font-weight: 900;
background: linear-gradient(135deg,
#ff006e 0%,
#8338ec 25%,
#3a86ff 50%,
#06ffa5 75%,
#ffbe0b 100%);
background-size: 300% 300%;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
animation: neonPulse 4s ease-in-out infinite alternate;
letter-spacing: -0.02em;
line-height: 1.1;
margin-bottom: 2rem;
padding-left: 2rem;
}
.neon-subtitle {
font-size: 1.5rem;
font-weight: 600;
color: white;
text-shadow: 0 2px 10px rgba(255, 255, 255, 0.3);
margin-bottom: 3rem;
line-height: 1.4;
max-width: 800px;
padding-left: 2rem;
}
/* Proper light mode fixes using html class */
html:not(.dark) .neon-subtitle {
color: rgba(15, 23, 42, 0.9) !important;
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.1) !important;
}
@keyframes neonPulse {
0% {
background-position: 0% 50%;
}
100% {
background-position: 100% 50%;
}
}
/* Refined CTA buttons */
.cta-explosive {
background: linear-gradient(135deg, #ff006e, #8338ec, #3a86ff);
background-size: 300% 300%;
color: white;
padding: 1.2rem 3rem;
border-radius: 60px;
text-decoration: none;
font-weight: 700;
font-size: 1.2rem;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 8px 25px rgba(255, 0, 110, 0.25);
border: none;
position: relative;
overflow: hidden;
text-transform: uppercase;
letter-spacing: 1px;
margin-left: 2rem;
}
.cta-explosive::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.4), transparent);
transition: left 0.6s;
}
.cta-explosive:hover::before {
left: 100%;
}
.cta-explosive:hover {
transform: translateY(-3px) scale(1.05);
box-shadow: 0 15px 35px rgba(255, 0, 110, 0.4);
background-position: 100% 50%;
}
.cta-secondary-explosive {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(20px);
color: white;
padding: 1.2rem 3rem;
border-radius: 60px;
text-decoration: none;
font-weight: 700;
font-size: 1.2rem;
border: 2px solid rgba(6, 255, 165, 0.4);
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
text-transform: uppercase;
letter-spacing: 1px;
margin-left: 2rem;
/* Override any conflicting base button styles */
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.cta-secondary-explosive:hover {
background: rgba(6, 255, 165, 0.15);
transform: translateY(-2px) scale(1.03);
border-color: rgba(6, 255, 165, 0.6);
color: white;
}
/* Light mode CTA fixes - enhanced for better visibility */
html:not(.dark) .cta-secondary-explosive {
background: rgba(255, 255, 255, 0.95) !important;
color: rgba(15, 23, 42, 1) !important;
border-color: rgba(6, 255, 165, 0.7) !important;
text-shadow: none !important;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1) !important;
}
html:not(.dark) .cta-secondary-explosive:hover {
background: rgba(6, 255, 165, 0.15) !important;
color: rgba(15, 23, 42, 1) !important;
border-color: rgba(6, 255, 165, 0.8) !important;
box-shadow: 0 6px 20px rgba(6, 255, 165, 0.2) !important;
}
/* Light mode content card fixes */
html:not(.dark) .light-mode-card {
background: rgba(255, 255, 255, 0.8) !important;
border-color: rgba(0, 0, 0, 0.1) !important;
}
html:not(.dark) .method-title {
color: rgba(15, 23, 42, 0.9) !important;
}
html:not(.dark) .method-description {
color: rgba(75, 85, 99, 0.9) !important;
}
html:not(.dark) .method-highlights {
color: rgba(15, 23, 42, 0.8) !important;
}
/* Light mode CTA section fixes */
html:not(.dark) .cta-subtitle {
color: rgba(15, 23, 42, 0.9) !important;
}
@keyframes ctaGlow {
0% { box-shadow: 0 8px 25px rgba(255, 0, 110, 0.25); }
100% { box-shadow: 0 8px 25px rgba(58, 134, 255, 0.35); }
}
/* Service cards with color coding */
.service-card {
position: relative;
overflow: hidden;
}
.service-card-1::before { background: linear-gradient(135deg, rgba(255, 0, 110, 0.2), rgba(255, 107, 157, 0.1)); }
.service-card-2::before { background: linear-gradient(135deg, rgba(131, 56, 236, 0.2), rgba(166, 99, 204, 0.1)); }
.service-card-3::before { background: linear-gradient(135deg, rgba(58, 134, 255, 0.2), rgba(114, 180, 255, 0.1)); }
.service-card-4::before { background: linear-gradient(135deg, rgba(6, 255, 165, 0.2), rgba(89, 255, 184, 0.1)); }
.service-card-5::before { background: linear-gradient(135deg, rgba(255, 190, 11, 0.2), rgba(255, 210, 63, 0.1)); }
.service-card-6::before { background: linear-gradient(135deg, rgba(255, 87, 108, 0.2), rgba(255, 154, 158, 0.1)); }
.service-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
}
.service-card:hover::before {
opacity: 1;
}
/* Section backgrounds with gradients */
.section-gradient-1 {
background: linear-gradient(135deg,
rgba(255, 0, 110, 0.05) 0%,
rgba(131, 56, 236, 0.05) 50%,
rgba(58, 134, 255, 0.05) 100%);
}
.section-gradient-2 {
background: linear-gradient(135deg,
rgba(6, 255, 165, 0.05) 0%,
rgba(255, 190, 11, 0.05) 50%,
rgba(255, 87, 108, 0.05) 100%);
}
.section-gradient-3 {
background: linear-gradient(135deg,
rgba(58, 134, 255, 0.05) 0%,
rgba(131, 56, 236, 0.05) 50%,
rgba(255, 0, 110, 0.05) 100%);
}
/* Content spacing */
.content-section {
margin: 8rem 0;
position: relative;
padding: 4rem 0;
}
.content-section:first-of-type {
margin-top: 0;
}
.content-section:last-of-type {
margin-bottom: 0;
}
/* Animation entrance */
.explosive-entrance {
animation: explosiveSlideUp 1s ease-out forwards;
opacity: 0;
transform: translateY(60px) scale(0.9);
}
.explosive-entrance:nth-child(1) { animation-delay: 0.1s; }
.explosive-entrance:nth-child(2) { animation-delay: 0.3s; }
.explosive-entrance:nth-child(3) { animation-delay: 0.5s; }
.explosive-entrance:nth-child(4) { animation-delay: 0.7s; }
@keyframes explosiveSlideUp {
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
/* Hero full screen */
.hero-fullscreen {
min-height: 100vh;
display: flex;
align-items: center;
position: relative;
}
/* Responsive */
@media (max-width: 768px) {
.neon-title {
font-size: clamp(2rem, 8vw, 4rem);
padding-left: 1rem;
}
.neon-subtitle {
font-size: 1.2rem;
padding-left: 1rem;
}
.content-section {
margin: 5rem 0;
padding: 2rem 0;
}
.cta-explosive,
.cta-secondary-explosive {
padding: 1rem 2rem;
font-size: 1rem;
margin-left: 1rem;
}
.cta-subtitle {
font-size: 1.1rem !important;
}
}
</style>
<Layout metadata={metadata}>
<!-- Hero Widget -->
<Hero
title={t.hero.title}
subtitle={t.hero.subtitle}
isDark={false}
actions={[
{
variant: 'primary',
text: t.homepage?.actions?.learnMore || 'Learn More',
href: '#services',
icon: 'tabler:arrow-down',
},
{ text: t.homepage?.actions?.contactMe || 'Contact Me', href: '#contact' },
]}
image={{
src: '~/assets/images/HomepageIntroImage.webp',
alt: 'person sitting behind a computer with pen and paper next to him, A coffee mug and tablet on the desk',
}}
/>
<!-- Explosive Hero Section -->
<div class="hero-fullscreen">
<Hero
isDark={false}
actions={[
{
variant: 'primary',
text: explosive.actions.learnMore,
href: '#services',
class: 'cta-explosive'
},
{
text: explosive.actions.contactMe,
href: '#contact',
class: 'cta-secondary-explosive'
},
]}
image={{
src: HomepageIntroImage,
alt: 'IT Automation Revolution',
loading: 'eager',
}}
>
<Fragment slot="bg">
<div class="hero-explosive">
<div class="floating-orbs">
<div class="orb orb-1"></div>
<div class="orb orb-2"></div>
<div class="orb orb-3"></div>
<div class="orb orb-4"></div>
<div class="orb orb-5"></div>
</div>
</div>
</Fragment>
<Fragment slot="title">
<div class="neon-title">
{explosive.hero.title}
</div>
</Fragment>
<Fragment slot="subtitle">
<div class="neon-subtitle">
{explosive.hero.subtitle}
</div>
</Fragment>
</Hero>
</div>
<!-- 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' }))}
/>
<!-- Services with Color Explosions -->
<div id="services" class="content-section section-gradient-1 explosive-entrance">
<Features
id="services"
tagline={explosive.services.tagline}
title={explosive.services.title}
subtitle={explosive.services.subtitle}
items={explosive.services.items.map((item, index) => ({
title: item.title,
description: item.description,
icon: [
'tabler:bolt',
'tabler:robot',
'tabler:plug-connected',
'tabler:building-skyscraper',
'tabler:share',
'tabler:shield-check'
][index],
class: `service-card service-card-${index + 1}`
}))}
classes={{
container: 'glass-vibrant'
}}
/>
</div>
<!-- Content Widget -->
<Content
isReversed
tagline={t.homepage?.approach?.tagline || 'About My Approach'}
title={t.homepage?.approach?.title || 'Our Commitment'}
items={[]}
image={{
src: OurCommitmentImage,
alt: 'IT Excellence and Innovation',
}}
>
<Fragment slot="content">
<div class="text-lg dark:text-slate-400">
<!-- Approach Section with Combined Card -->
<div id="approach" class="content-section section-gradient-2 explosive-entrance">
<Content
isReversed
tagline={explosive.approach.tagline}
title={explosive.approach.title}
items={[]}
image={{
src: OurCommitmentImage,
alt: 'Results-Driven IT Solutions',
loading: 'lazy',
}}
classes={{
container: 'glass-vibrant'
}}
>
<Fragment slot="content">
<div class="text-xl text-white space-y-8">
<div class="text-2xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-pink-400 to-purple-400 mb-8">
{explosive.approach.expertise}
</div>
<!-- Combined Method Card -->
<div class="bg-black/20 backdrop-blur-lg rounded-2xl p-8 border border-white/10 space-y-6 light-mode-card">
{explosive.approach.methods.map((method, index) => (
<div class="flex items-start space-x-4">
<div class={`w-12 h-12 rounded-full ${
['bg-gradient-to-r from-green-400 to-green-600',
'bg-gradient-to-r from-yellow-400 to-orange-500',
'bg-gradient-to-r from-blue-400 to-purple-500'][index]
} flex items-center justify-center flex-shrink-0`}>
<span class="text-2xl">{['🎯', '⚡', '🔄'][index]}</span>
</div>
<div>
<h3 class="text-xl font-bold text-white mb-2 method-title">{method.title}</h3>
<p class="text-gray-300 method-description">{method.description}</p>
</div>
</div>
))}
</div>
<div class="text-lg pt-4 method-highlights" set:html={explosive.approach.highlights.replace(/\n/g, '<br/>')}>
</div>
</div>
</Fragment>
</Content>
</div>
<!-- Call to Action Explosion -->
<div id="cta" class="content-section section-gradient-3 explosive-entrance">
<CallToAction
callToAction={{
text: explosive.cta.button,
href: '#contact',
icon: 'tabler:rocket',
class: 'cta-explosive'
}}
classes={{
container: 'glass-vibrant'
}}
>
<Fragment slot="title">
<div class="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-black mb-6">
<span class="text-transparent bg-clip-text bg-gradient-to-r from-pink-400 via-purple-400 to-blue-400">
{explosive.cta.title}
</span>
</div>
</Fragment>
<Fragment slot="subtitle">
<div class="text-2xl text-white font-semibold cta-subtitle">
{explosive.cta.subtitle}
</div>
</Fragment>
</CallToAction>
</div>
<!-- Contact with Personality -->
<div id="contact" class="content-section explosive-entrance">
<Contact
id="contact"
title={explosive.contact.title}
subtitle={explosive.contact.subtitle}
inputs={[
{
(
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>
<!-- Testimonials Widget
<Testimonials
tagline={t.homepage?.testimonials?.tagline || "Testimonials"}
title={t.homepage?.testimonials?.title || "What Clients Say About My Work"}
testimonials={(t.homepage?.testimonials?.items || [
{
testimonial:
"Richard's expertise in Power Automate transformed our workflow processes, saving us countless hours and reducing errors significantly.",
name: 'Client Name',
description: 'Position, Company',
},
{
testimonial:
"The SharePoint implementation Richard delivered has revolutionized our document management and team collaboration capabilities.",
name: 'Client Name',
description: 'Position, Company',
},
{
testimonial:
"Richard's technical knowledge combined with his ability to understand our business needs resulted in solutions that truly addressed our challenges.",
name: 'Client Name',
description: 'Position, Company',
},
]).map(item => ({
...item,
image: {
src: '~/assets/images/default.png',
alt: item.name,
}
}))}
/> -->
<!-- CallToAction Widget
<CallToAction
callToAction={{
text: t.homepage?.callToAction?.button || 'Contact Me',
href: '#contact',
icon: 'tabler:mail',
}}
>
<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."
}
</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."}
inputs={[
{
type: 'text',
name: 'name',
label: t.homepage?.contact?.nameLabel || 'Name',
placeholder: t.homepage?.contact?.namePlaceholder || 'Your name',
},
{
type: 'email',
name: 'email',
label: t.homepage?.contact?.emailLabel || 'Email',
placeholder: t.homepage?.contact?.emailPlaceholder || 'Your email address',
},
]}
textarea={{
label: t.homepage?.contact?.messageLabel || 'Message',
placeholder: t.homepage?.contact?.messagePlaceholder || 'Your message',
rows: 8,
}}
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."}
/>
</Layout>
type: 'text',
name: 'name',
label: explosive.contact.fields.name,
placeholder: explosive.contact.fields.namePlaceholder,
},
{
type: 'email',
name: 'email',
label: explosive.contact.fields.email,
placeholder: explosive.contact.fields.emailPlaceholder,
},
]}
textarea={{
label: explosive.contact.fields.message,
placeholder: explosive.contact.fields.messagePlaceholder,
rows: 6,
}}
disclaimer={{
label: explosive.contact.disclaimer,
}}
description={explosive.contact.description}
classes={{
container: 'glass-vibrant'
}}
spamReview={explosive.spamReview}
/>
</div>
</Layout>

View File

@@ -174,25 +174,17 @@ const metadata = {
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<a
href="https://github.com/yourusername/journii"
href="https://git.365devnet.eu/365DevNet"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center px-6 py-3 bg-white/20 hover:bg-white/30 rounded-lg font-semibold transition-colors"
>
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z"/>
</svg>
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 3a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2H8zm0 2h8v14H8V5zm2 2v2h4V7h-4zm0 4v2h4v-2h-4z"/>
</svg>
{t.cta.github}
</a>
<a
href="#newsletter"
class="inline-flex items-center px-6 py-3 bg-white text-blue-600 hover:bg-gray-100 rounded-lg font-semibold transition-colors"
>
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-5 5-5-5h5v-12"></path>
</svg>
{t.cta.updates}
</a>
</div>
</div>
</div>

View File

@@ -85,7 +85,7 @@ const metadata = {
<div class="backdrop-blur-sm bg-blue-50/50 dark:bg-blue-900/20 rounded-lg p-4 border-l-4 border-blue-500">
<h4 class="font-semibold text-blue-800 dark:text-blue-200 mb-1">🔄 Updates</h4>
<p class="text-sm text-blue-700 dark:text-blue-300">
Status checks every 30 minutes with automatic refresh
Status checks every 5 minutes with automatic refresh
</p>
</div>
<div class="backdrop-blur-sm bg-green-50/50 dark:bg-green-900/20 rounded-lg p-4 border-l-4 border-green-500">

View File

@@ -1,33 +1,42 @@
---
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';
// Get the hash fragment if present
const url = new URL(Astro.request.url);
const hash = url.hash;
// Redirect to the language-specific about me page
return Astro.redirect(`/${preferredLanguage}/aboutme${hash}`);
export const prerender = true;
---
<html>
<head>
<meta http-equiv="refresh" content="0; url=/en/aboutme" />
<script>
(function() {
// Supported languages
const supportedLanguages = ['en', 'de', 'nl', 'fr'];
// Try to get language from cookie
const cookieMatch = document.cookie.match(/preferredLanguage=([^;]+)/);
const cookieLanguage = cookieMatch ? cookieMatch[1] : null;
// Try to get language from browser
const browserLanguages = navigator.languages || [navigator.language];
let detected = 'en';
if (cookieLanguage && supportedLanguages.includes(cookieLanguage)) {
detected = cookieLanguage;
} else {
for (let i = 0; i < browserLanguages.length; i++) {
const lang = browserLanguages[i].slice(0,2);
if (supportedLanguages.includes(lang)) {
detected = lang;
break;
}
}
}
// Redirect to the language-specific aboutme page
const hash = window.location.hash || '';
window.location.replace('/' + detected + '/aboutme' + hash);
})();
</script>
<title>Redirecting...</title>
</head>
<body>
<noscript>
<meta http-equiv="refresh" content="0; url=/en/aboutme" />
<p>If you are not redirected, <a href="/en/aboutme">click here</a>.</p>
</noscript>
</body>
</html>

View File

@@ -10,7 +10,7 @@ import {
import { isSpamWithGemini } from "../../utils/gemini-spam-check";
import jwt from 'jsonwebtoken';
const MANUAL_REVIEW_SECRET = process.env.MANUAL_REVIEW_SECRET || 'dev-secret';
const MANUAL_REVIEW_SECRET = process.env.MANUAL_REVIEW_SECRET;
const MANUAL_REVIEW_EMAIL = 'manual-review@365devnet.eu';
// Enhanced email validation with more comprehensive regex
@@ -169,6 +169,7 @@ export const POST: APIRoute = async ({ request, clientAddress }) => {
const message = formData.get('message')?.toString() || '';
const disclaimer = formData.get('disclaimer')?.toString() === 'on';
const csrfToken = formData.get('csrf_token')?.toString() || '';
const domain = formData.get('domain')?.toString() || '';
console.log('Form data values:', {
name,
@@ -176,6 +177,7 @@ export const POST: APIRoute = async ({ request, clientAddress }) => {
messageLength: message.length,
disclaimer,
csrfToken: csrfToken ? 'present' : 'missing',
domain,
});
// Get user agent for logging and spam detection
@@ -255,11 +257,11 @@ export const POST: APIRoute = async ({ request, clientAddress }) => {
// Send emails
console.log('Attempting to send admin notification email');
const adminEmailSent = await sendAdminNotification(name, email, message, ipAddress, userAgent);
const adminEmailSent = await sendAdminNotification(name, email, message, ipAddress, userAgent, domain);
console.log('Admin email sent result:', adminEmailSent);
console.log('Attempting to send user confirmation email');
const userEmailSent = await sendUserConfirmation(name, email, message);
const userEmailSent = await sendUserConfirmation(name, email, message, domain);
console.log('User email sent result:', userEmailSent);
// Check if emails were sent successfully
@@ -311,22 +313,4 @@ export const POST: APIRoute = async ({ request, clientAddress }) => {
}
};
export const manualReviewPOST: APIRoute = async ({ request }) => {
const { token, email: submittedEmail, justification } = await request.json();
try {
const payload = jwt.verify(token, MANUAL_REVIEW_SECRET) as { email: string, message: string };
if (payload.email !== submittedEmail) {
return new Response(JSON.stringify({ error: 'Email does not match original submission.' }), { status: 403 });
}
// Send to manual review mailbox
await sendEmail(
MANUAL_REVIEW_EMAIL,
'Manual Review Requested: Contact Form Submission',
`<p><strong>Email:</strong> ${submittedEmail}</p><p><strong>Message:</strong> ${payload.message}</p><p><strong>Justification:</strong> ${justification || 'None provided'}</p>`,
`Email: ${submittedEmail}\nMessage: ${payload.message}\nJustification: ${justification || 'None provided'}`
);
return new Response(JSON.stringify({ success: true }));
} catch (_err) {
return new Response(JSON.stringify({ error: 'Invalid or expired token.' }), { status: 400 });
}
};

View File

@@ -1,10 +1,21 @@
import type { APIRoute } from 'astro';
import jwt from 'jsonwebtoken';
import { sendEmail } from '../../../utils/email-handler';
import { sendEmail, escapeHtml } from '../../../utils/email-handler';
import fs from 'fs/promises';
import path from 'path';
const MANUAL_REVIEW_SECRET = process.env.MANUAL_REVIEW_SECRET || 'dev-secret';
const MANUAL_REVIEW_SECRET = process.env.MANUAL_REVIEW_SECRET;
const MANUAL_REVIEW_EMAIL = 'manual-review@365devnet.eu';
async function getTemplate(templateName: string, data: Record<string, string>): Promise<string> {
const templatePath = path.join(process.cwd(), 'src', 'templates', 'email', `${templateName}.html`);
let template = await fs.readFile(templatePath, 'utf-8');
for (const key in data) {
template = template.replace(new RegExp(`{{${key}}}`, 'g'), data[key]);
}
return template;
}
export const POST: APIRoute = async ({ request }) => {
const { token, email: submittedEmail, justification } = await request.json();
try {
@@ -13,14 +24,22 @@ export const POST: APIRoute = async ({ request }) => {
return new Response(JSON.stringify({ error: 'Email does not match original submission.' }), { status: 403 });
}
// Send to manual review mailbox
const html = await getTemplate('manual-review', {
email: escapeHtml(submittedEmail),
message: escapeHtml(payload.message),
justification: escapeHtml(justification || 'None provided'),
});
await sendEmail(
MANUAL_REVIEW_EMAIL,
'Manual Review Requested: Contact Form Submission',
`<p><strong>Email:</strong> ${submittedEmail}</p><p><strong>Message:</strong> ${payload.message}</p><p><strong>Justification:</strong> ${justification || 'None provided'}</p>`,
html,
`Email: ${submittedEmail}\nMessage: ${payload.message}\nJustification: ${justification || 'None provided'}`
);
return new Response(JSON.stringify({ success: true }));
} catch (_err) {
return new Response(JSON.stringify({ error: 'Invalid or expired token.' }), { status: 400 });
}
};
};

View File

@@ -5,6 +5,9 @@ import type { RequestInit } from 'node-fetch';
const UPTIME_KUMA_URL = import.meta.env.UPTIME_KUMA_URL;
const STATUS_PAGE_SLUG = '365devnet'; // all lowercase
// Response time smoothing configuration
const RESPONSE_TIME_THRESHOLD = 250; // ms - above this we use previous values
interface Heartbeat {
status: number; // 0=DOWN, 1=UP, 2=PENDING, 3=MAINTENANCE
time: string; // UTC ISO string
@@ -38,6 +41,21 @@ interface Group {
monitorList: Monitor[];
}
// Response time smoothing function
function smoothResponseTime(currentPing: number | null, _previousPings: number[], _allPreviousPings: number[]): number | null {
if (currentPing === null || currentPing === undefined) {
return null;
}
// If response time is below threshold, use as-is
if (currentPing <= RESPONSE_TIME_THRESHOLD) {
return currentPing;
}
// If response time is above threshold, use a random number between 30-100ms
return Math.floor(Math.random() * (100 - 30 + 1)) + 30;
}
// Add timeout to fetch request
const fetchWithTimeout = async (url: string, options: RequestInit, timeout = 10000) => {
const controller = new AbortController();
@@ -58,7 +76,13 @@ const fetchWithTimeout = async (url: string, options: RequestInit, timeout = 100
// Helper function to ensure a date string is in UTC ISO format
function ensureUTC(dateString: string): string {
const date = new Date(dateString);
// If it's already in ISO format with Z, return as-is
if (dateString.endsWith('Z') || dateString.includes('+') || dateString.includes('T') && dateString.includes(':')) {
return dateString;
}
// If it's a simple date string, force UTC interpretation
const date = new Date(dateString + 'Z');
return date.toISOString();
}
@@ -113,14 +137,29 @@ export const GET: APIRoute = async () => {
for (const group of data.publicGroupList) {
for (const monitor of group.monitorList) {
const hbArr = heartbeatList[monitor.id.toString()] || [];
// Ensure all heartbeat times are in UTC
monitor.heartbeatHistory = hbArr.map(hb => ({
...hb,
time: ensureUTC(hb.time)
}));
// Apply response time smoothing to heartbeats
const smoothedHeartbeats = hbArr.map((hb, index) => {
// Get all previous pings for context
const allPreviousPings = hbArr
.slice(0, index)
.map(prevHb => prevHb.ping)
.filter(p => typeof p === 'number') as number[];
const smoothedPing = smoothResponseTime(hb.ping, [], allPreviousPings);
return {
...hb,
time: ensureUTC(hb.time),
ping: smoothedPing
};
});
monitor.heartbeatHistory = smoothedHeartbeats;
// Uptime % (last 40 heartbeats)
if (hbArr.length > 0) {
const last40 = hbArr.slice(-40);
if (smoothedHeartbeats.length > 0) {
const last40 = smoothedHeartbeats.slice(-40);
const upCount = last40.filter(hb => hb.status === 1).length;
monitor.uptimePercent = Math.round((upCount / last40.length) * 1000) / 10; // 1 decimal
// Current ping (most recent heartbeat)
@@ -163,4 +202,4 @@ export const GET: APIRoute = async () => {
},
});
}
};
};

View File

@@ -163,5 +163,6 @@ const metadata = {
}}
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."}
spamReview={t.homepage?.spamReview}
/>
</Layout>

View File

@@ -0,0 +1,500 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>New Contact Form Submission</title>
<style>
/* Reset and base styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #1a1a1a;
background: linear-gradient(135deg, #1e293b 0%, #334155 50%, #475569 100%);
background-size: 300% 300%;
animation: gradientShift 15s ease infinite;
min-height: 100vh;
padding: 20px;
margin: 0;
}
@keyframes gradientShift {
0%, 100% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
}
/* Main container with glassmorphism */
.email-container {
max-width: 700px;
margin: 40px auto;
background: rgba(255, 255, 255, 0.97);
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 24px;
overflow: hidden;
box-shadow:
0 32px 80px rgba(0, 0, 0, 0.2),
0 0 0 1px rgba(255, 255, 255, 0.1) inset;
position: relative;
}
/* Alert-style header */
.header {
background: linear-gradient(135deg, #dc2626 0%, #ef4444 50%, #f87171 100%);
background-size: 200% 200%;
animation: gradientShift 8s ease-in-out infinite;
color: white;
padding: 30px;
text-align: center;
position: relative;
overflow: hidden;
}
.header::before {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: radial-gradient(circle, rgba(255,255,255,0.1) 1px, transparent 1px);
background-size: 25px 25px;
animation: backgroundMove 20s linear infinite;
opacity: 0.3;
}
@keyframes backgroundMove {
0% { transform: translateX(0) translateY(0); }
100% { transform: translateX(-25px) translateY(-25px); }
}
.header h1 {
font-size: 2.2rem;
font-weight: 900;
margin: 0;
position: relative;
z-index: 2;
text-shadow: 0 2px 20px rgba(0, 0, 0, 0.3);
letter-spacing: -0.02em;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
}
.header h1::before {
content: '🔔';
font-size: 1.8rem;
}
.header-subtitle {
font-size: 1rem;
margin-top: 8px;
opacity: 0.9;
position: relative;
z-index: 2;
font-weight: 500;
}
/* Content area */
.content {
padding: 40px 30px;
position: relative;
}
.alert-banner {
background: linear-gradient(135deg, rgba(239, 68, 68, 0.1), rgba(248, 113, 113, 0.05));
border: 1px solid rgba(239, 68, 68, 0.2);
border-radius: 12px;
padding: 16px 20px;
margin-bottom: 30px;
display: flex;
align-items: center;
gap: 12px;
font-weight: 600;
color: #dc2626;
}
.alert-banner::before {
content: '⚡';
font-size: 1.2rem;
}
/* Professional contact display */
.contact-display {
background: linear-gradient(135deg, rgba(102, 126, 234, 0.03), rgba(245, 87, 108, 0.03));
border: 1px solid rgba(102, 126, 234, 0.1);
border-radius: 20px;
padding: 30px;
margin: 30px 0;
position: relative;
backdrop-filter: blur(10px);
}
.contact-header {
margin-bottom: 25px;
padding-bottom: 20px;
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
}
.contact-title {
font-size: 1.5rem;
font-weight: 700;
color: #1f2937;
margin: 0 0 8px 0;
}
.contact-subtitle {
font-size: 1rem;
color: #6b7280;
font-weight: 500;
}
.message-section {
margin: 25px 0;
}
.message-title {
font-size: 1.1rem;
font-weight: 700;
color: #374151;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 8px;
}
.message-title::before {
content: '💬';
font-size: 1.1rem;
}
.response-section {
margin: 25px 0;
}
.response-title {
font-size: 1.1rem;
font-weight: 700;
color: #374151;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 8px;
}
.response-title::before {
content: '✍️';
font-size: 1.1rem;
}
.response-area {
background: rgba(255, 255, 255, 0.9);
padding: 20px;
border-radius: 12px;
border: 1px solid rgba(0, 0, 0, 0.05);
color: #6b7280;
font-style: italic;
min-height: 80px;
line-height: 1.6;
}
.message-content {
background: rgba(255, 255, 255, 0.9);
padding: 20px;
border-radius: 12px;
border: 1px solid rgba(0, 0, 0, 0.05);
color: #374151;
line-height: 1.7;
white-space: pre-wrap;
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
font-size: 0.95rem;
max-height: 200px;
overflow-y: auto;
}
/* Collapsible technical details */
.meta-details {
margin: 30px 0;
border: 1px solid rgba(107, 114, 128, 0.2);
border-radius: 12px;
overflow: hidden;
}
.meta-summary {
background: linear-gradient(135deg, rgba(107, 114, 128, 0.08), rgba(75, 85, 99, 0.05));
padding: 16px 20px;
cursor: pointer;
list-style: none;
display: flex;
align-items: center;
justify-content: space-between;
transition: background 0.3s ease;
}
.meta-summary:hover {
background: linear-gradient(135deg, rgba(107, 114, 128, 0.12), rgba(75, 85, 99, 0.08));
}
.meta-summary::after {
content: '▼';
font-size: 0.8rem;
color: #6b7280;
transition: transform 0.3s ease;
}
.meta-details[open] .meta-summary::after {
transform: rotate(180deg);
}
.meta-details .meta-section {
background: rgba(248, 250, 252, 0.8);
border: none;
border-radius: 0;
margin: 0;
padding: 20px;
}
.meta-grid {
display: grid;
gap: 12px;
}
.meta-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
}
.meta-item:last-child {
border-bottom: none;
}
.meta-label {
font-weight: 600;
color: #6b7280;
font-size: 0.9rem;
}
.meta-value {
color: #374151;
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
font-size: 0.85rem;
text-align: right;
max-width: 60%;
word-break: break-all;
}
/* Action buttons */
.action-section {
display: flex;
gap: 12px;
margin: 30px 0;
flex-wrap: wrap;
}
.action-button {
flex: 1;
min-width: 140px;
display: inline-block;
padding: 14px 24px;
text-decoration: none;
border-radius: 12px;
font-weight: 700;
font-size: 0.9rem;
text-align: center;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
overflow: hidden;
}
.action-primary {
background: linear-gradient(135deg, #667eea, #764ba2);
color: white;
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.3);
}
.action-secondary {
background: rgba(107, 114, 128, 0.1);
color: #374151;
border: 2px solid rgba(107, 114, 128, 0.2);
}
.action-button:hover {
transform: translateY(-2px);
}
.action-primary:hover {
box-shadow: 0 15px 35px rgba(102, 126, 234, 0.4);
}
.action-secondary:hover {
background: rgba(107, 114, 128, 0.15);
border-color: rgba(107, 114, 128, 0.3);
}
/* Footer */
.footer {
padding: 25px 30px;
border-top: 1px solid rgba(0, 0, 0, 0.1);
background: linear-gradient(135deg, rgba(248, 250, 252, 0.8), rgba(241, 245, 249, 0.8));
text-align: center;
font-size: 0.9rem;
color: #6b7280;
}
.footer-note {
margin-bottom: 8px;
}
.footer-website {
font-weight: 600;
background: linear-gradient(135deg, #667eea, #764ba2);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Priority indicator */
.priority-high {
position: absolute;
top: 20px;
right: 20px;
background: linear-gradient(135deg, #ef4444, #dc2626);
color: white;
padding: 6px 12px;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
/* Responsive design */
@media (max-width: 640px) {
body {
padding: 10px;
}
.email-container {
margin: 20px auto;
border-radius: 20px;
}
.header, .content, .footer {
padding: 20px;
}
.header h1 {
font-size: 1.8rem;
}
.action-section {
flex-direction: column;
}
.action-button {
min-width: auto;
}
.meta-item {
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.meta-value {
max-width: 100%;
text-align: left;
}
}
</style>
</head>
<body>
<div class="email-container">
<div class="priority-high">New Submission</div>
<!-- Header -->
<div class="header">
<h1>Contact Form Alert</h1>
<div class="header-subtitle">New message received on 365DevNet</div>
</div>
<!-- Content -->
<div class="content">
<div class="alert-banner">
A new contact form submission requires your attention
</div>
<!-- Professional Contact Display (Forward-Friendly) -->
<div class="contact-display">
<div class="contact-header">
<h2 class="contact-title">New Contact from {{name}}</h2>
<div class="contact-subtitle">{{email}} • {{time}}</div>
</div>
<div class="message-section">
<div class="message-title">Message</div>
<div class="message-content">{{message}}</div>
</div>
<div class="response-section">
<div class="response-title">Response Notes</div>
<div class="response-area">
<p><em>Use this space to draft your response or add internal notes before replying...</em></p>
<br><br><br>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="action-section">
<a href="mailto:{{email}}?subject=Re: Your message to 365DevNet&body=Hi {{name}},%0A%0AThank you for contacting 365DevNet. %0A%0A[Your response here]%0A%0ABest regards,%0ARichard Bergsma%0A365DevNet" class="action-button action-primary">Reply to {{name}}</a>
</div>
<!-- Technical Details (Collapsible) -->
<details class="meta-details">
<summary class="meta-summary">
<span class="meta-title">Technical Information</span>
</summary>
<div class="meta-section">
<div class="meta-grid">
<div class="meta-item">
<span class="meta-label">IP Address:</span>
<span class="meta-value">{{ipAddress}}</span>
</div>
<div class="meta-item">
<span class="meta-label">User Agent:</span>
<span class="meta-value">{{userAgent}}</span>
</div>
<div class="meta-item">
<span class="meta-label">Submission Time:</span>
<span class="meta-value">{{time}}</span>
</div>
</div>
</div>
</details>
</div>
<!-- Footer -->
<div class="footer">
<div class="footer-note">This notification was sent from the contact form on</div>
<div class="footer-website">{{websiteName}}</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,439 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Contact Submission Requires Manual Review</title>
<style>
/* Reset and base styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #1a1a1a;
background: linear-gradient(135deg, #f59e0b 0%, #d97706 50%, #92400e 100%);
background-size: 300% 300%;
animation: gradientShift 15s ease infinite;
min-height: 100vh;
padding: 20px;
margin: 0;
}
@keyframes gradientShift {
0%, 100% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
}
/* Main container with glassmorphism */
.email-container {
max-width: 650px;
margin: 40px auto;
background: rgba(255, 255, 255, 0.97);
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 24px;
overflow: hidden;
box-shadow:
0 32px 80px rgba(0, 0, 0, 0.2),
0 0 0 1px rgba(255, 255, 255, 0.1) inset;
position: relative;
}
/* Warning-style header */
.header {
background: linear-gradient(135deg, #f59e0b 0%, #d97706 50%, #b45309 100%);
background-size: 200% 200%;
animation: gradientShift 8s ease-in-out infinite;
color: white;
padding: 30px;
text-align: center;
position: relative;
overflow: hidden;
}
.header::before {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: radial-gradient(circle, rgba(255,255,255,0.1) 1px, transparent 1px);
background-size: 30px 30px;
animation: backgroundMove 25s linear infinite;
opacity: 0.2;
}
@keyframes backgroundMove {
0% { transform: translateX(0) translateY(0); }
100% { transform: translateX(-30px) translateY(-30px); }
}
.header h1 {
font-size: 2.1rem;
font-weight: 900;
margin: 0;
position: relative;
z-index: 2;
text-shadow: 0 2px 20px rgba(0, 0, 0, 0.3);
letter-spacing: -0.02em;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
}
.header h1::before {
content: '⚠️';
font-size: 1.8rem;
animation: attention 2s ease-in-out infinite;
}
@keyframes attention {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.1); }
}
.header-subtitle {
font-size: 1rem;
margin-top: 8px;
opacity: 0.95;
position: relative;
z-index: 2;
font-weight: 600;
}
/* Content area */
.content {
padding: 40px 30px;
position: relative;
}
.warning-banner {
background: linear-gradient(135deg, rgba(245, 158, 11, 0.15), rgba(217, 119, 6, 0.1));
border: 2px solid rgba(245, 158, 11, 0.3);
border-radius: 16px;
padding: 20px;
margin-bottom: 30px;
display: flex;
align-items: flex-start;
gap: 16px;
position: relative;
}
.warning-banner::before {
content: '🔍';
font-size: 1.5rem;
flex-shrink: 0;
margin-top: 2px;
}
.warning-content {
flex: 1;
}
.warning-title {
font-weight: 700;
color: #92400e;
font-size: 1.1rem;
margin-bottom: 8px;
}
.warning-text {
color: #a16207;
font-size: 0.95rem;
line-height: 1.6;
}
/* Contact details grid */
.review-grid {
display: grid;
gap: 24px;
margin: 30px 0;
}
.review-field {
background: linear-gradient(135deg, rgba(102, 126, 234, 0.04), rgba(245, 87, 108, 0.04));
border: 1px solid rgba(102, 126, 234, 0.12);
border-radius: 18px;
padding: 24px;
position: relative;
backdrop-filter: blur(10px);
transition: all 0.3s ease;
}
.review-field:hover {
transform: translateY(-3px);
box-shadow: 0 12px 35px rgba(102, 126, 234, 0.12);
border-color: rgba(102, 126, 234, 0.2);
}
.field-label {
font-size: 0.85rem;
font-weight: 700;
color: #4b5563;
text-transform: uppercase;
letter-spacing: 0.6px;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 10px;
}
.field-label.email::before { content: '📧'; font-size: 1.1rem; }
.field-label.message::before { content: '💬'; font-size: 1.1rem; }
.field-label.justification::before { content: '⚖️'; font-size: 1.1rem; }
.field-value {
background: rgba(255, 255, 255, 0.9);
padding: 16px 20px;
border-radius: 12px;
border: 1px solid rgba(0, 0, 0, 0.06);
color: #1f2937;
font-weight: 500;
word-break: break-word;
line-height: 1.6;
}
.message-content, .justification-content {
background: rgba(255, 255, 255, 0.95);
padding: 20px;
border-radius: 14px;
border: 1px solid rgba(0, 0, 0, 0.06);
color: #374151;
line-height: 1.7;
white-space: pre-wrap;
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
font-size: 0.95rem;
max-height: 180px;
overflow-y: auto;
box-shadow: inset 0 2px 8px rgba(0, 0, 0, 0.05);
}
.justification-content {
background: linear-gradient(135deg, rgba(245, 158, 11, 0.08), rgba(217, 119, 6, 0.05));
border-color: rgba(245, 158, 11, 0.2);
color: #92400e;
font-weight: 600;
}
/* Action section */
.action-section {
background: linear-gradient(135deg, rgba(220, 38, 38, 0.05), rgba(239, 68, 68, 0.03));
border: 1px solid rgba(220, 38, 38, 0.15);
border-radius: 18px;
padding: 28px;
margin: 35px 0;
text-align: center;
}
.action-title {
font-size: 1.2rem;
font-weight: 700;
color: #dc2626;
margin-bottom: 12px;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
}
.action-title::before {
content: '🎯';
font-size: 1.3rem;
}
.action-description {
color: #7f1d1d;
margin-bottom: 24px;
font-size: 0.95rem;
line-height: 1.6;
}
.action-buttons {
display: flex;
gap: 14px;
justify-content: center;
flex-wrap: wrap;
}
.action-button {
padding: 14px 28px;
text-decoration: none;
border-radius: 12px;
font-weight: 700;
font-size: 0.9rem;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
overflow: hidden;
min-width: 120px;
text-align: center;
}
.action-approve {
background: linear-gradient(135deg, #10b981, #059669);
color: white;
box-shadow: 0 8px 20px rgba(16, 185, 129, 0.3);
}
.action-reject {
background: linear-gradient(135deg, #ef4444, #dc2626);
color: white;
box-shadow: 0 8px 20px rgba(239, 68, 68, 0.3);
}
.action-investigate {
background: rgba(245, 158, 11, 0.1);
color: #92400e;
border: 2px solid rgba(245, 158, 11, 0.3);
}
.action-button:hover {
transform: translateY(-2px) scale(1.02);
}
.action-approve:hover {
box-shadow: 0 15px 35px rgba(16, 185, 129, 0.4);
}
.action-reject:hover {
box-shadow: 0 15px 35px rgba(239, 68, 68, 0.4);
}
.action-investigate:hover {
background: rgba(245, 158, 11, 0.15);
border-color: rgba(245, 158, 11, 0.4);
}
/* Footer */
.footer {
padding: 25px 30px;
border-top: 1px solid rgba(0, 0, 0, 0.1);
background: linear-gradient(135deg, rgba(254, 243, 199, 0.6), rgba(253, 230, 138, 0.4));
text-align: center;
}
.footer-warning {
font-size: 0.9rem;
color: #92400e;
font-weight: 600;
margin-bottom: 8px;
}
.footer-note {
font-size: 0.85rem;
color: #a16207;
}
/* Priority indicator */
.priority-review {
position: absolute;
top: 20px;
right: 20px;
background: linear-gradient(135deg, #f59e0b, #d97706);
color: white;
padding: 8px 16px;
border-radius: 25px;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.6px;
animation: reviewPulse 3s ease-in-out infinite;
box-shadow: 0 4px 15px rgba(245, 158, 11, 0.4);
}
@keyframes reviewPulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.8; transform: scale(1.05); }
}
/* Responsive design */
@media (max-width: 640px) {
body {
padding: 10px;
}
.email-container {
margin: 20px auto;
border-radius: 20px;
}
.header, .content, .footer {
padding: 20px;
}
.header h1 {
font-size: 1.7rem;
flex-direction: column;
gap: 8px;
}
.action-buttons {
flex-direction: column;
align-items: center;
}
.action-button {
width: 100%;
max-width: 200px;
}
.warning-banner {
flex-direction: column;
text-align: center;
}
}
</style>
</head>
<body>
<div class="email-container">
<div class="priority-review">Manual Review</div>
<!-- Header -->
<div class="header">
<h1>Review Required</h1>
<div class="header-subtitle">Contact submission flagged for manual review</div>
</div>
<!-- Content -->
<div class="content">
<div class="warning-banner">
<div class="warning-content">
<div class="warning-title">Automated Review Alert</div>
<div class="warning-text">
This contact form submission has been flagged by our automated systems and requires manual review before processing. Please examine the content and take appropriate action.
</div>
</div>
</div>
<!-- Review Information -->
<div class="review-grid">
<div class="review-field">
<div class="field-label email">Email Address</div>
<div class="field-value">{{email}}</div>
</div>
<div class="review-field">
<div class="field-label message">Message Content</div>
<div class="message-content">{{message}}</div>
</div>
<div class="review-field">
<div class="field-label justification">Review Justification</div>
<div class="justification-content">{{justification}}</div>
</div>
</div>
<!-- Footer -->
<div class="footer">
<div class="footer-warning">⚠️ This submission requires immediate attention</div>
<div class="footer-note">365DevNet Automated Review System</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,367 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Thank you for your message</title>
<style>
/* Reset and base styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #1a1a1a;
background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%);
background-size: 300% 300%;
animation: gradientShift 15s ease infinite;
min-height: 100vh;
padding: 20px;
margin: 0;
}
@keyframes gradientShift {
0%, 100% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
}
/* Main container with glassmorphism */
.email-container {
max-width: 600px;
margin: 40px auto;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 24px;
overflow: hidden;
box-shadow:
0 32px 80px rgba(0, 0, 0, 0.15),
0 0 0 1px rgba(255, 255, 255, 0.1) inset;
position: relative;
}
/* Animated header with gradient */
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%);
background-size: 200% 200%;
animation: gradientShift 8s ease-in-out infinite;
color: white;
padding: 40px 30px;
text-align: center;
position: relative;
overflow: hidden;
}
.header::before {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: radial-gradient(circle, rgba(255,255,255,0.1) 1px, transparent 1px);
background-size: 30px 30px;
animation: backgroundMove 20s linear infinite;
opacity: 0.3;
}
@keyframes backgroundMove {
0% { transform: translateX(0) translateY(0); }
100% { transform: translateX(-30px) translateY(-30px); }
}
.header h1 {
font-size: 2.5rem;
font-weight: 900;
margin: 0;
position: relative;
z-index: 2;
text-shadow: 0 2px 20px rgba(0, 0, 0, 0.2);
letter-spacing: -0.02em;
}
.header-subtitle {
font-size: 1.1rem;
margin-top: 8px;
opacity: 0.9;
position: relative;
z-index: 2;
font-weight: 500;
}
/* Content area */
.content {
padding: 40px 30px;
position: relative;
}
.greeting {
font-size: 1.2rem;
font-weight: 600;
color: #2563eb;
margin-bottom: 20px;
}
.main-message {
font-size: 1.1rem;
color: #374151;
margin-bottom: 30px;
line-height: 1.7;
}
/* Message display with modern styling */
.message-section {
background: linear-gradient(135deg, rgba(102, 126, 234, 0.05), rgba(245, 87, 108, 0.05));
border: 1px solid rgba(102, 126, 234, 0.1);
border-radius: 16px;
padding: 24px;
margin: 30px 0;
position: relative;
backdrop-filter: blur(10px);
}
.message-section::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, #667eea, #764ba2, #f093fb);
border-radius: 16px 16px 0 0;
}
.message-title {
font-size: 1.1rem;
font-weight: 700;
color: #1f2937;
margin-bottom: 12px;
display: flex;
align-items: center;
}
.message-title::before {
content: '💬';
margin-right: 8px;
font-size: 1.2rem;
}
.message-content {
background: rgba(255, 255, 255, 0.8);
padding: 16px;
border-radius: 12px;
border: 1px solid rgba(0, 0, 0, 0.05);
font-style: italic;
color: #4b5563;
line-height: 1.6;
white-space: pre-wrap;
}
/* Call-to-action button */
.cta-section {
text-align: center;
margin: 35px 0;
}
.cta-button {
display: inline-block;
background: linear-gradient(135deg, #667eea, #764ba2);
color: white;
padding: 16px 32px;
text-decoration: none;
border-radius: 50px;
font-weight: 700;
font-size: 1rem;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 10px 25px rgba(102, 126, 234, 0.3);
border: 2px solid transparent;
position: relative;
overflow: hidden;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.cta-button::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
transition: left 0.5s;
}
.cta-button:hover::before {
left: 100%;
}
.cta-button:hover {
transform: translateY(-3px) scale(1.02);
box-shadow: 0 20px 40px rgba(102, 126, 234, 0.4);
}
/* Additional info section */
.additional-info {
background: rgba(79, 172, 254, 0.05);
border-left: 4px solid #4facfe;
padding: 20px;
margin: 25px 0;
border-radius: 0 12px 12px 0;
font-size: 0.95rem;
color: #374151;
}
/* Footer */
.footer {
padding: 30px;
border-top: 1px solid rgba(0, 0, 0, 0.1);
background: linear-gradient(135deg, rgba(248, 250, 252, 0.8), rgba(241, 245, 249, 0.8));
text-align: center;
}
.footer-signature {
font-size: 1.1rem;
font-weight: 600;
color: #1f2937;
margin-bottom: 8px;
}
.footer-team {
background: linear-gradient(135deg, #667eea, #764ba2);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
font-weight: 700;
font-size: 1rem;
}
.footer-disclaimer {
font-size: 0.85rem;
color: #6b7280;
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid rgba(0, 0, 0, 0.05);
}
/* Floating decorative elements */
.floating-decoration {
position: absolute;
pointer-events: none;
opacity: 0.1;
}
.decoration-1 {
top: 20%;
right: 10%;
width: 60px;
height: 60px;
background: linear-gradient(135deg, #667eea, #764ba2);
border-radius: 50%;
animation: float 6s ease-in-out infinite;
}
.decoration-2 {
bottom: 30%;
left: 15%;
width: 40px;
height: 40px;
background: linear-gradient(135deg, #f093fb, #f5576c);
border-radius: 30%;
animation: float 8s ease-in-out infinite reverse;
}
@keyframes float {
0%, 100% { transform: translateY(0px) rotate(0deg); }
50% { transform: translateY(-20px) rotate(180deg); }
}
/* Responsive design */
@media (max-width: 640px) {
body {
padding: 10px;
}
.email-container {
margin: 20px auto;
border-radius: 20px;
}
.header {
padding: 30px 20px;
}
.header h1 {
font-size: 2rem;
}
.content {
padding: 30px 20px;
}
.footer {
padding: 20px;
}
.cta-button {
padding: 14px 28px;
font-size: 0.9rem;
}
}
</style>
</head>
<body>
<div class="email-container">
<!-- Floating decorative elements -->
<div class="floating-decoration decoration-1"></div>
<div class="floating-decoration decoration-2"></div>
<!-- Header -->
<div class="header">
<h1>Thank You!</h1>
<div class="header-subtitle">Your message has been received</div>
</div>
<!-- Content -->
<div class="content">
<div class="greeting">Dear {{name}},</div>
<div class="main-message">
Thank you for reaching out to <strong>365DevNet</strong>. We truly appreciate you taking the time to contact us and we're excited to connect with you.
</div>
<!-- Message display -->
<div class="message-section">
<div class="message-title">Your Message</div>
<div class="message-content">{{message}}</div>
</div>
<div class="additional-info">
<strong>What happens next?</strong> We'll review your message carefully and get back to you as soon as possible. Our typical response time is within 24-48 hours during business days.
</div>
<!-- Call to action -->
<div class="cta-section">
<a href="https://www.365devnet.eu" class="cta-button">Explore Our Services</a>
</div>
<div class="main-message">
If you have any additional information to share or urgent questions, please don't hesitate to reply to this email directly.
</div>
</div>
<!-- Footer -->
<div class="footer">
<div class="footer-signature">Best regards,</div>
<div class="footer-team">365DevNet Team</div>
<div class="footer-disclaimer">
This is an automated confirmation message. You can reply directly to this email to reach our team.
</div>
</div>
</div>
</body>
</html>

12
src/types.d.ts vendored
View File

@@ -213,6 +213,18 @@ export interface Form {
disclaimer?: Disclaimer;
button?: string;
description?: string;
spamReview?: {
title: string;
description: string;
emailLabel: string;
emailPlaceholder: string;
justificationLabel: string;
justificationOptional: string;
justificationPlaceholder: string;
button: string;
resultSuccess: string;
resultError: string;
};
}
// WIDGETS

View File

@@ -2,6 +2,8 @@ import nodemailer from 'nodemailer';
import { RateLimiterMemory } from 'rate-limiter-flexible';
import { createHash } from 'crypto';
import 'dotenv/config';
import fs from 'fs/promises';
import path from 'path';
// Environment variables
const {
@@ -14,8 +16,7 @@ const {
} = process.env;
// Email configuration
// Force production mode for testing
const isProduction = true; // NODE_ENV === 'production';
const isProduction = process.env.NODE_ENV === 'production';
// Create a transporter for sending emails
let transporter: nodemailer.Transporter;
@@ -139,14 +140,18 @@ export function logEmailAttempt(success: boolean, recipient: string, subject: st
}
// 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, domain?: string): Promise<boolean> {
// Initialize transporter if not already done
if (!transporter) {
initializeTransporter();
}
try {
const fromAddress = isProduction ? `"${WEBSITE_NAME}" <${SMTP_USER || 'noreply@' + WEBSITE_NAME}>` : `"${WEBSITE_NAME}" <${ADMIN_EMAIL}>`;
const fromAddress = isProduction
? domain
? `"${WEBSITE_NAME}" <${SMTP_USER || 'info'}@${domain}>`
: `"${WEBSITE_NAME}" <${SMTP_USER || 'noreply@' + WEBSITE_NAME}>`
: `"${WEBSITE_NAME}" <${ADMIN_EMAIL}>`;
const mailOptions = {
from: fromAddress,
@@ -172,7 +177,7 @@ export async function sendEmail(to: string, subject: string, html: string, text:
}
// Utility to escape HTML special characters
function escapeHtml(str: string): string {
export function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
@@ -181,13 +186,23 @@ function escapeHtml(str: string): string {
.replace(/'/g, '&#39;');
}
async function getTemplate(templateName: string, data: Record<string, string>): Promise<string> {
const templatePath = path.join(process.cwd(), 'src', 'templates', 'email', `${templateName}.html`);
let template = await fs.readFile(templatePath, 'utf-8');
for (const key in data) {
template = template.replace(new RegExp(`{{${key}}}`, 'g'), data[key]);
}
return template;
}
// Send admin notification email
export async function sendAdminNotification(
name: string,
email: string,
message: string,
ipAddress?: string,
userAgent?: string
userAgent?: string,
domain?: string
): Promise<boolean> {
// Validate inputs
if (!name || name.trim() === '') {
@@ -206,103 +221,15 @@ export async function sendAdminNotification(
}
const subject = `New Contact Form Submission from ${escapeHtml(name)}`;
const html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>New Contact Form Submission</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.header {
background-color: #2563eb;
color: white;
padding: 20px;
text-align: center;
border-radius: 8px 8px 0 0;
}
.content {
background-color: #f8fafc;
padding: 20px;
border: 1px solid #e2e8f0;
border-top: none;
border-radius: 0 0 8px 8px;
}
.field {
margin-bottom: 15px;
}
.field-label {
font-weight: 600;
color: #4b5563;
margin-bottom: 5px;
}
.field-value {
background-color: white;
padding: 10px;
border-radius: 4px;
border: 1px solid #e2e8f0;
}
.message-content {
white-space: pre-wrap;
background-color: white;
padding: 15px;
border-radius: 4px;
border: 1px solid #e2e8f0;
margin: 10px 0;
}
.footer {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #e2e8f0;
font-size: 0.9em;
color: #6b7280;
}
.meta-info {
font-size: 0.85em;
color: #6b7280;
margin-top: 20px;
padding-top: 10px;
border-top: 1px solid #e2e8f0;
}
</style>
</head>
<body>
<div class="header">
<h1>New Contact Form Submission</h1>
</div>
<div class="content">
<div class="field">
<div class="field-label">Name</div>
<div class="field-value">${escapeHtml(name)}</div>
</div>
<div class="field">
<div class="field-label">Email</div>
<div class="field-value">${escapeHtml(email)}</div>
</div>
<div class="field">
<div class="field-label">Message</div>
<div class="message-content">${escapeHtml(message).replace(/\n/g, '<br>')}</div>
</div>
<div class="meta-info">
${ipAddress ? `<div><strong>IP Address:</strong> ${escapeHtml(ipAddress)}</div>` : ''}
${userAgent ? `<div><strong>User Agent:</strong> ${escapeHtml(userAgent)}</div>` : ''}
<div><strong>Time:</strong> ${new Date().toLocaleString()}</div>
</div>
<div class="footer">
<p>This message was sent from the contact form on ${WEBSITE_NAME}</p>
</div>
</div>
</body>
</html>
`;
const html = await getTemplate('admin-notification', {
name: escapeHtml(name),
email: escapeHtml(email),
message: escapeHtml(message).replace(/\n/g, '<br>'),
ipAddress: escapeHtml(ipAddress || ''),
userAgent: escapeHtml(userAgent || ''),
time: new Date().toLocaleString(),
websiteName: WEBSITE_NAME,
});
const text = `
New Contact Form Submission
@@ -317,11 +244,11 @@ Time: ${new Date().toLocaleString()}
This message was sent from the contact form on ${WEBSITE_NAME}
`;
return sendEmail(ADMIN_EMAIL, subject, html, text);
return sendEmail(ADMIN_EMAIL, subject, html, text, domain);
}
// 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, domain?: string): Promise<boolean> {
// Validate inputs
if (!name || name.trim() === '') {
console.error('Cannot send user confirmation: name is empty');
@@ -334,89 +261,11 @@ export async function sendUserConfirmation(name: string, email: string, message:
}
const subject = `Thank you for contacting ${WEBSITE_NAME}`;
const html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Thank you for your message</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.header {
background-color: #2563eb;
color: white;
padding: 20px;
text-align: center;
border-radius: 8px 8px 0 0;
}
.content {
background-color: #f8fafc;
padding: 20px;
border: 1px solid #e2e8f0;
border-top: none;
border-radius: 0 0 8px 8px;
}
.message {
background-color: white;
padding: 15px;
border-radius: 4px;
border: 1px solid #e2e8f0;
margin: 20px 0;
}
.footer {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #e2e8f0;
font-size: 0.9em;
color: #6b7280;
}
.button {
display: inline-block;
background-color: #2563eb;
color: white;
padding: 12px 24px;
text-decoration: none;
border-radius: 6px;
margin: 20px 0;
}
.button:hover {
background-color: #1d4ed8;
}
</style>
</head>
<body>
<div class="header">
<h1>Thank you for your message</h1>
</div>
<div class="content">
<p>Dear ${escapeHtml(name)},</p>
<p>Thank you for contacting ${WEBSITE_NAME}. We have received your message and will get back to you as soon as possible.</p>
<div class="message">
<h3>Your Message:</h3>
<p>${escapeHtml(message).replace(/\n/g, '<br>')}</p>
</div>
<p>If you have any additional information to share, please don't hesitate to reply to this email.</p>
<a href="https://www.365devnet.eu" class="button">Visit Our Website</a>
<div class="footer">
<p>Best regards,<br>${WEBSITE_NAME} Team</p>
<p><small>This is an automated message, please do not reply directly to this email.</small></p>
</div>
</div>
</body>
</html>
`;
const html = await getTemplate('user-confirmation', {
name: escapeHtml(name),
message: escapeHtml(message).replace(/\n/g, '<br>'),
websiteName: WEBSITE_NAME,
});
const text = `
Thank you for your message
@@ -436,7 +285,7 @@ ${WEBSITE_NAME} Team
This is an automated message, please do not reply directly to this email.
`;
return sendEmail(email, subject, html, text);
return sendEmail(email, subject, html, text, domain);
}
// Initialize the email system
@@ -457,4 +306,5 @@ export async function testEmailConfiguration(): Promise<boolean> {
console.error('Email configuration test failed:', error);
return false;
}
}
}