summaryrefslogtreecommitdiff
path: root/src/components
diff options
context:
space:
mode:
authorDawid Rycerz <dawid@rycerz.xyz>2025-07-22 15:08:37 +0300
committerDawid Rycerz <dawid@rycerz.xyz>2025-07-22 15:08:37 +0300
commitfcc2f4704e39b0e69b377cc138f75027721dac22 (patch)
tree732fc94b354a26c08fba9cc9059f9c6c900182be /src/components
Initial template
Diffstat (limited to 'src/components')
-rw-r--r--src/components/CustomStyles.astro69
-rw-r--r--src/components/Favicons.astro10
-rw-r--r--src/components/Logo.astro9
-rw-r--r--src/components/blog/Grid.astro14
-rw-r--r--src/components/blog/GridItem.astro71
-rw-r--r--src/components/blog/Headline.astro12
-rw-r--r--src/components/blog/List.astro20
-rw-r--r--src/components/blog/ListItem.astro120
-rw-r--r--src/components/blog/Pagination.astro36
-rw-r--r--src/components/blog/RelatedPosts.astro31
-rw-r--r--src/components/blog/SinglePost.astro103
-rw-r--r--src/components/blog/Tags.astro43
-rw-r--r--src/components/blog/ToBlogLink.astro20
-rw-r--r--src/components/common/Analytics.astro13
-rw-r--r--src/components/common/ApplyColorMode.astro33
-rw-r--r--src/components/common/BasicScripts.astro255
-rw-r--r--src/components/common/CommonMeta.astro8
-rw-r--r--src/components/common/Image.astro61
-rw-r--r--src/components/common/Metadata.astro68
-rw-r--r--src/components/common/SiteVerification.astro5
-rw-r--r--src/components/common/SocialShare.astro65
-rw-r--r--src/components/common/SplitbeeAnalytics.astro6
-rw-r--r--src/components/common/ToggleMenu.astro29
-rw-r--r--src/components/common/ToggleTheme.astro28
-rw-r--r--src/components/ui/Background.astro11
-rw-r--r--src/components/ui/Button.astro40
-rw-r--r--src/components/ui/DListItem.astro22
-rw-r--r--src/components/ui/Form.astro87
-rw-r--r--src/components/ui/Headline.astro35
-rw-r--r--src/components/ui/ItemGrid.astro65
-rw-r--r--src/components/ui/ItemGrid2.astro59
-rw-r--r--src/components/ui/Timeline.astro60
-rw-r--r--src/components/ui/WidgetWrapper.astro34
-rw-r--r--src/components/widgets/Announcement.astro16
-rw-r--r--src/components/widgets/BlogHighlightedPosts.astro64
-rw-r--r--src/components/widgets/BlogLatestPosts.astro63
-rw-r--r--src/components/widgets/Brands.astro38
-rw-r--r--src/components/widgets/CallToAction.astro58
-rw-r--r--src/components/widgets/CallToActionImage.astro73
-rw-r--r--src/components/widgets/Contact.astro40
-rw-r--r--src/components/widgets/Content.astro94
-rw-r--r--src/components/widgets/FAQs.astro33
-rw-r--r--src/components/widgets/Features.astro36
-rw-r--r--src/components/widgets/Features2.astro38
-rw-r--r--src/components/widgets/Features3.astro70
-rw-r--r--src/components/widgets/Footer.astro61
-rw-r--r--src/components/widgets/Header.astro14
-rw-r--r--src/components/widgets/Hero.astro99
-rw-r--r--src/components/widgets/Hero2.astro99
-rw-r--r--src/components/widgets/HeroText.astro86
-rw-r--r--src/components/widgets/Note.astro23
-rw-r--r--src/components/widgets/Pricing.astro83
-rw-r--r--src/components/widgets/Stats.astro46
-rw-r--r--src/components/widgets/Steps.astro59
-rw-r--r--src/components/widgets/Steps2.astro79
-rw-r--r--src/components/widgets/Testimonials.astro75
56 files changed, 2889 insertions, 0 deletions
diff --git a/src/components/CustomStyles.astro b/src/components/CustomStyles.astro
new file mode 100644
index 0000000..9798cb6
--- /dev/null
+++ b/src/components/CustomStyles.astro
@@ -0,0 +1,69 @@
+---
+import '@fontsource/kanit';
+import '@fontsource/poppins';
+
+// 'DM Sans'
+// Nunito
+// Dosis
+// Outfit
+// Roboto
+// Literata
+// 'IBM Plex Sans'
+// Karla
+// Poppins
+// 'Fira Sans'
+// 'Libre Franklin'
+// Inconsolata
+// Raleway
+// Oswald
+// 'Space Grotesk'
+// Urbanist
+---
+
+<style is:inline>
+ :root {
+ --aw-font-sans: 'Poppins';
+ --aw-font-serif: 'Poppins';
+ --aw-font-heading: 'Kanit';
+
+ /* New color palette */
+ --aw-color-primary: #162130; /* Głęboki granat - główny kolor */
+ --aw-color-secondary: #162130; /* Głęboki granat - główny kolor */
+ --aw-color-accent: #162130; /* Głęboki granat - główny kolor */
+
+ --aw-color-text-heading: #162130; /* Głęboki granat dla nagłówków */
+ --aw-color-text-default: #000000; /* Czerń dla tekstu na jasnym tle */
+ --aw-color-text-muted: #888888; /* Stonowana szarość */
+ --aw-color-bg-page: #FFFFFF; /* Klasyczna biel */
+ --aw-color-bg-section: #FFFFFF; /* Klasyczna biel */
+
+ --aw-color-bg-page-dark: #162130; /* Głęboki granat */
+
+ ::selection {
+ background-color: #162130;
+ color: #FFFFFF;
+ }
+ }
+
+ .dark {
+ --aw-font-sans: 'Poppins';
+ --aw-font-serif: 'Poppins';
+ --aw-font-heading: 'Kanit';
+
+ /* Dark mode */
+ --aw-color-primary: #FFFFFF; /* Biały dla kontrastu na ciemnym tle */
+ --aw-color-secondary: #FFFFFF; /* Biały dla kontrastu na ciemnym tle */
+ --aw-color-accent: #FFFFFF; /* Biały dla kontrastu na ciemnym tle */
+
+ --aw-color-text-heading: #CCCCCC; /* Jasna szarość na ciemnym tle */
+ --aw-color-text-default: #CCCCCC; /* Jasna szarość na ciemnym tle */
+ --aw-color-text-muted: #888888; /* Stonowana szarość */
+ --aw-color-bg-page: #162130; /* Głęboki granat */
+ --aw-color-bg-section: #FFFFFF; /* Klasyczna biel dla jasnych sekcji */
+
+ ::selection {
+ background-color: #FFFFFF;
+ color: #162130;
+ }
+ }
+</style>
diff --git a/src/components/Favicons.astro b/src/components/Favicons.astro
new file mode 100644
index 0000000..fed6696
--- /dev/null
+++ b/src/components/Favicons.astro
@@ -0,0 +1,10 @@
+---
+import favIcon from '~/assets/favicons/favicon.ico';
+import favIconSvg from '~/assets/favicons/favicon.svg';
+import appleTouchIcon from '~/assets/favicons/apple-touch-icon.png';
+---
+
+<link rel="shortcut icon" href={favIcon} />
+<link rel="icon" type="image/svg+xml" href={favIconSvg.src} />
+<link rel="mask-icon" href={favIconSvg.src} color="#8D46E7" />
+<link rel="apple-touch-icon" sizes="180x180" href={appleTouchIcon.src} />
diff --git a/src/components/Logo.astro b/src/components/Logo.astro
new file mode 100644
index 0000000..8469792
--- /dev/null
+++ b/src/components/Logo.astro
@@ -0,0 +1,9 @@
+---
+import { SITE } from 'astrowind:config';
+---
+
+<span
+ class="self-center ml-2 rtl:ml-0 rtl:mr-2 text-2xl md:text-xl font-bold text-gray-900 whitespace-nowrap dark:text-white"
+>
+ 🚀 {SITE?.name}
+</span>
diff --git a/src/components/blog/Grid.astro b/src/components/blog/Grid.astro
new file mode 100644
index 0000000..1b62be4
--- /dev/null
+++ b/src/components/blog/Grid.astro
@@ -0,0 +1,14 @@
+---
+import Item from '~/components/blog/GridItem.astro';
+import type { Post } from '~/types';
+
+export interface Props {
+ posts: Array<Post>;
+}
+
+const { posts } = Astro.props;
+---
+
+<div class="grid gap-6 row-gap-5 md:grid-cols-2 lg:grid-cols-4 -mb-6">
+ {posts.map((post) => <Item post={post} />)}
+</div>
diff --git a/src/components/blog/GridItem.astro b/src/components/blog/GridItem.astro
new file mode 100644
index 0000000..73353ca
--- /dev/null
+++ b/src/components/blog/GridItem.astro
@@ -0,0 +1,71 @@
+---
+import { APP_BLOG } from 'astrowind:config';
+import type { Post } from '~/types';
+
+import Image from '~/components/common/Image.astro';
+
+import { findImage } from '~/utils/images';
+import { getPermalink } from '~/utils/permalinks';
+
+export interface Props {
+ post: Post;
+}
+
+const { post } = Astro.props;
+const image = await findImage(post.image);
+
+const link = APP_BLOG?.post?.isEnabled ? getPermalink(post.permalink, 'post') : '';
+---
+
+<article
+ class="mb-6 transition intersect-once intersect-quarter motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade"
+>
+ <div class="relative md:h-64 bg-gray-400 dark:bg-slate-700 rounded shadow-lg mb-6">
+ {
+ image &&
+ (link ? (
+ <a href={link}>
+ <Image
+ src={image}
+ class="w-full md:h-full rounded shadow-lg bg-gray-400 dark:bg-slate-700"
+ widths={[400, 900]}
+ width={400}
+ sizes="(max-width: 900px) 400px, 900px"
+ alt={post.title}
+ aspectRatio="16:9"
+ layout="cover"
+ loading="lazy"
+ decoding="async"
+ />
+ </a>
+ ) : (
+ <Image
+ src={image}
+ class="w-full md:h-full rounded shadow-lg bg-gray-400 dark:bg-slate-700"
+ widths={[400, 900]}
+ width={400}
+ sizes="(max-width: 900px) 400px, 900px"
+ alt={post.title}
+ aspectRatio="16:9"
+ layout="cover"
+ loading="lazy"
+ decoding="async"
+ />
+ ))
+ }
+ </div>
+
+ <h3 class="text-xl sm:text-2xl font-bold leading-tight mb-2 font-heading dark:text-slate-300">
+ {
+ link ? (
+ <a class="inline-block hover:text-primary dark:hover:text-secondary transition ease-in duration-200" href={link}>
+ {post.title}
+ </a>
+ ) : (
+ post.title
+ )
+ }
+ </h3>
+
+ <p class="text-muted dark:text-slate-400 text-lg">{post.excerpt}</p>
+</article>
diff --git a/src/components/blog/Headline.astro b/src/components/blog/Headline.astro
new file mode 100644
index 0000000..5d3ccc6
--- /dev/null
+++ b/src/components/blog/Headline.astro
@@ -0,0 +1,12 @@
+---
+const { title = await Astro.slots.render('default'), subtitle = await Astro.slots.render('subtitle') } = Astro.props;
+---
+
+<header class="mb-8 md:mb-16 text-center max-w-3xl mx-auto">
+ <h1 class="text-4xl md:text-5xl font-bold leading-tighter tracking-tighter font-heading" set:html={title} />
+ {
+ subtitle && (
+ <div class="mt-2 md:mt-3 mx-auto text-xl text-gray-500 dark:text-slate-400 font-medium" set:html={subtitle} />
+ )
+ }
+</header>
diff --git a/src/components/blog/List.astro b/src/components/blog/List.astro
new file mode 100644
index 0000000..6a80ae3
--- /dev/null
+++ b/src/components/blog/List.astro
@@ -0,0 +1,20 @@
+---
+import Item from '~/components/blog/ListItem.astro';
+import type { Post } from '~/types';
+
+export interface Props {
+ posts: Array<Post>;
+}
+
+const { posts } = Astro.props;
+---
+
+<ul>
+ {
+ posts.map((post) => (
+ <li class="mb-12 md:mb-20">
+ <Item post={post} />
+ </li>
+ ))
+ }
+</ul>
diff --git a/src/components/blog/ListItem.astro b/src/components/blog/ListItem.astro
new file mode 100644
index 0000000..36602f2
--- /dev/null
+++ b/src/components/blog/ListItem.astro
@@ -0,0 +1,120 @@
+---
+import type { ImageMetadata } from 'astro';
+import { Icon } from 'astro-icon/components';
+import Image from '~/components/common/Image.astro';
+import PostTags from '~/components/blog/Tags.astro';
+
+import { APP_BLOG } from 'astrowind:config';
+import type { Post } from '~/types';
+
+import { getPermalink } from '~/utils/permalinks';
+import { findImage } from '~/utils/images';
+import { getFormattedDate } from '~/utils/utils';
+
+export interface Props {
+ post: Post;
+}
+
+const { post } = Astro.props;
+const image = (await findImage(post.image)) as ImageMetadata | undefined;
+
+const link = APP_BLOG?.post?.isEnabled ? getPermalink(post.permalink, 'post') : '';
+---
+
+<article
+ class={`max-w-md mx-auto md:max-w-none grid gap-6 md:gap-8 intersect-once intersect-quarter motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade ${image ? 'md:grid-cols-2' : ''}`}
+>
+ {
+ image &&
+ (link ? (
+ <a class="relative block group" href={link ?? 'javascript:void(0)'}>
+ <div class="relative h-0 pb-[56.25%] md:pb-[75%] md:h-72 lg:pb-[56.25%] overflow-hidden bg-gray-400 dark:bg-slate-700 rounded shadow-lg">
+ {image && (
+ <Image
+ src={image}
+ class="absolute inset-0 object-cover w-full h-full mb-6 rounded shadow-lg bg-gray-400 dark:bg-slate-700"
+ widths={[400, 900]}
+ width={900}
+ sizes="(max-width: 900px) 400px, 900px"
+ alt={post.title}
+ aspectRatio="16:9"
+ loading="lazy"
+ decoding="async"
+ />
+ )}
+ </div>
+ </a>
+ ) : (
+ <div class="relative h-0 pb-[56.25%] md:pb-[75%] md:h-72 lg:pb-[56.25%] overflow-hidden bg-gray-400 dark:bg-slate-700 rounded shadow-lg">
+ {image && (
+ <Image
+ src={image}
+ class="absolute inset-0 object-cover w-full h-full mb-6 rounded shadow-lg bg-gray-400 dark:bg-slate-700"
+ widths={[400, 900]}
+ width={900}
+ sizes="(max-width: 900px) 400px, 900px"
+ alt={post.title}
+ aspectRatio="16:9"
+ loading="lazy"
+ decoding="async"
+ />
+ )}
+ </div>
+ ))
+ }
+ <div class="mt-2">
+ <header>
+ <div class="mb-1">
+ <span class="text-sm">
+ <Icon name="tabler:clock" class="w-3.5 h-3.5 inline-block -mt-0.5 dark:text-gray-400" />
+ <time datetime={String(post.publishDate)} class="inline-block">{getFormattedDate(post.publishDate)}</time>
+ {
+ post.author && (
+ <>
+ {' '}
+ · <Icon name="tabler:user" class="w-3.5 h-3.5 inline-block -mt-0.5 dark:text-gray-400" />
+ <span>{post.author.replaceAll('-', ' ')}</span>
+ </>
+ )
+ }
+ {
+ post.category && (
+ <>
+ {' '}
+ ·{' '}
+ <a class="hover:underline" href={getPermalink(post.category.slug, 'category')}>
+ {post.category.title}
+ </a>
+ </>
+ )
+ }
+ </span>
+ </div>
+ <h2 class="text-xl sm:text-2xl font-bold leading-tight mb-2 font-heading dark:text-slate-300">
+ {
+ link ? (
+ <a
+ class="inline-block hover:text-primary dark:hover:text-secondary transition ease-in duration-200"
+ href={link}
+ >
+ {post.title}
+ </a>
+ ) : (
+ post.title
+ )
+ }
+ </h2>
+ </header>
+
+ {post.excerpt && <p class="flex-grow text-muted dark:text-slate-400 text-lg">{post.excerpt}</p>}
+ {
+ post.tags && Array.isArray(post.tags) ? (
+ <footer class="mt-5">
+ <PostTags tags={post.tags} />
+ </footer>
+ ) : (
+ <Fragment />
+ )
+ }
+ </div>
+</article>
diff --git a/src/components/blog/Pagination.astro b/src/components/blog/Pagination.astro
new file mode 100644
index 0000000..051587c
--- /dev/null
+++ b/src/components/blog/Pagination.astro
@@ -0,0 +1,36 @@
+---
+import { Icon } from 'astro-icon/components';
+import { getPermalink } from '~/utils/permalinks';
+import Button from '~/components/ui/Button.astro';
+
+export interface Props {
+ prevUrl?: string;
+ nextUrl?: string;
+ prevText?: string;
+ nextText?: string;
+}
+
+const { prevUrl, nextUrl, prevText = 'Newer posts', nextText = 'Older posts' } = Astro.props;
+---
+
+{
+ (prevUrl || nextUrl) && (
+ <div class="container flex">
+ <div class="flex flex-row mx-auto container justify-between">
+ <Button
+ variant="tertiary"
+ class={`md:px-3 px-3 mr-2 ${!prevUrl ? 'invisible' : ''}`}
+ href={getPermalink(prevUrl)}
+ >
+ <Icon name="tabler:chevron-left" class="w-6 h-6" />
+ <p class="ml-2">{prevText}</p>
+ </Button>
+
+ <Button variant="tertiary" class={`md:px-3 px-3 ${!nextUrl ? 'invisible' : ''}`} href={getPermalink(nextUrl)}>
+ <span class="mr-2">{nextText}</span>
+ <Icon name="tabler:chevron-right" class="w-6 h-6" />
+ </Button>
+ </div>
+ </div>
+ )
+}
diff --git a/src/components/blog/RelatedPosts.astro b/src/components/blog/RelatedPosts.astro
new file mode 100644
index 0000000..f4036e9
--- /dev/null
+++ b/src/components/blog/RelatedPosts.astro
@@ -0,0 +1,31 @@
+---
+import { APP_BLOG } from 'astrowind:config';
+
+import { getRelatedPosts } from '~/utils/blog';
+import BlogHighlightedPosts from '../widgets/BlogHighlightedPosts.astro';
+import type { Post } from '~/types';
+import { getBlogPermalink } from '~/utils/permalinks';
+
+export interface Props {
+ post: Post;
+}
+
+const { post } = Astro.props;
+
+const relatedPosts = post.tags ? await getRelatedPosts(post, 4) : [];
+---
+
+{
+ APP_BLOG.isRelatedPostsEnabled ? (
+ <BlogHighlightedPosts
+ classes={{
+ container:
+ 'pt-0 lg:pt-0 md:pt-0 intersect-once intersect-quarter motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade',
+ }}
+ title="Related Posts"
+ linkText="View All Posts"
+ linkUrl={getBlogPermalink()}
+ postIds={relatedPosts.map((post) => post.id)}
+ />
+ ) : null
+}
diff --git a/src/components/blog/SinglePost.astro b/src/components/blog/SinglePost.astro
new file mode 100644
index 0000000..ac92cd3
--- /dev/null
+++ b/src/components/blog/SinglePost.astro
@@ -0,0 +1,103 @@
+---
+import { Icon } from 'astro-icon/components';
+
+import Image from '~/components/common/Image.astro';
+import PostTags from '~/components/blog/Tags.astro';
+import SocialShare from '~/components/common/SocialShare.astro';
+
+import { getPermalink } from '~/utils/permalinks';
+import { getFormattedDate } from '~/utils/utils';
+
+import type { Post } from '~/types';
+
+export interface Props {
+ post: Post;
+ url: string | URL;
+}
+
+const { post, url } = Astro.props;
+---
+
+<section class="py-8 sm:py-16 lg:py-20 mx-auto">
+ <article>
+ <header
+ class={post.image
+ ? 'intersect-once intersect-quarter motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade'
+ : 'intersect-once intersect-quarter motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade'}
+ >
+ <div class="flex justify-between flex-col sm:flex-row max-w-3xl mx-auto mt-0 mb-2 px-4 sm:px-6 sm:items-center">
+ <p>
+ <Icon name="tabler:clock" class="w-4 h-4 inline-block -mt-0.5 dark:text-gray-400" />
+ <time datetime={String(post.publishDate)} class="inline-block">{getFormattedDate(post.publishDate)}</time>
+ {
+ post.author && (
+ <>
+ {' '}
+ · <Icon name="tabler:user" class="w-4 h-4 inline-block -mt-0.5 dark:text-gray-400" />
+ <span class="inline-block">{post.author}</span>
+ </>
+ )
+ }
+ {
+ post.category && (
+ <>
+ {' '}
+ ·{' '}
+ <a class="hover:underline inline-block" href={getPermalink(post.category.slug, 'category')}>
+ {post.category.title}
+ </a>
+ </>
+ )
+ }
+ {
+ post.readingTime && (
+ <>
+ &nbsp;· <span>{post.readingTime}</span> min read
+ </>
+ )
+ }
+ </p>
+ </div>
+
+ <h1
+ class="px-4 sm:px-6 max-w-3xl mx-auto text-4xl md:text-5xl font-bold leading-tighter tracking-tighter font-heading"
+ >
+ {post.title}
+ </h1>
+ <p
+ class="max-w-3xl mx-auto mt-4 mb-8 px-4 sm:px-6 text-xl md:text-2xl text-muted dark:text-slate-400 text-justify"
+ >
+ {post.excerpt}
+ </p>
+
+ {
+ post.image ? (
+ <Image
+ src={post.image}
+ class="max-w-full lg:max-w-[900px] mx-auto mb-6 sm:rounded-md bg-gray-400 dark:bg-slate-700"
+ widths={[400, 900]}
+ sizes="(max-width: 900px) 400px, 900px"
+ alt={post?.excerpt || ''}
+ width={900}
+ height={506}
+ loading="eager"
+ decoding="async"
+ />
+ ) : (
+ <div class="max-w-3xl mx-auto px-4 sm:px-6">
+ <div class="border-t dark:border-slate-700" />
+ </div>
+ )
+ }
+ </header>
+ <div
+ class="mx-auto px-6 sm:px-6 max-w-3xl prose prose-md lg:prose-xl dark:prose-invert dark:prose-headings:text-slate-300 prose-headings:font-heading prose-headings:leading-tighter prose-headings:tracking-tighter prose-headings:font-bold prose-a:text-primary dark:prose-a:text-primary prose-img:rounded-md prose-img:shadow-lg mt-8 prose-headings:scroll-mt-[80px] prose-li:my-0"
+ >
+ <slot />
+ </div>
+ <div class="mx-auto px-6 sm:px-6 max-w-3xl mt-8 flex justify-between flex-col sm:flex-row">
+ <PostTags tags={post.tags} class="mr-5 rtl:mr-0 rtl:ml-5" />
+ <SocialShare url={url} text={post.title} class="mt-5 sm:mt-1 align-middle text-gray-500 dark:text-slate-600" />
+ </div>
+ </article>
+</section>
diff --git a/src/components/blog/Tags.astro b/src/components/blog/Tags.astro
new file mode 100644
index 0000000..ae46a24
--- /dev/null
+++ b/src/components/blog/Tags.astro
@@ -0,0 +1,43 @@
+---
+import { getPermalink } from '~/utils/permalinks';
+
+import { APP_BLOG } from 'astrowind:config';
+import type { Post } from '~/types';
+
+export interface Props {
+ tags: Post['tags'];
+ class?: string;
+ title?: string | undefined;
+ isCategory?: boolean;
+}
+
+const { tags, class: className = 'text-sm', title = undefined, isCategory = false } = Astro.props;
+---
+
+{
+ tags && Array.isArray(tags) && (
+ <>
+ {title !== undefined && (
+ <span class="align-super font-normal underline underline-offset-4 decoration-2 dark:text-slate-400">
+ {title}
+ </span>
+ )}
+ <ul class={className}>
+ {tags.map((tag) => (
+ <li class="bg-gray-100 dark:bg-slate-700 inline-block mr-2 rtl:mr-0 rtl:ml-2 mb-2 py-0.5 px-2 lowercase font-medium">
+ {!APP_BLOG?.tag?.isEnabled ? (
+ tag.title
+ ) : (
+ <a
+ href={getPermalink(tag.slug, isCategory ? 'category' : 'tag')}
+ class="text-muted dark:text-slate-300 hover:text-primary dark:hover:text-gray-200"
+ >
+ {tag.title}
+ </a>
+ )}
+ </li>
+ ))}
+ </ul>
+ </>
+ )
+}
diff --git a/src/components/blog/ToBlogLink.astro b/src/components/blog/ToBlogLink.astro
new file mode 100644
index 0000000..7fb7a49
--- /dev/null
+++ b/src/components/blog/ToBlogLink.astro
@@ -0,0 +1,20 @@
+---
+import { Icon } from 'astro-icon/components';
+import { getBlogPermalink } from '~/utils/permalinks';
+import { I18N } from 'astrowind:config';
+import Button from '~/components/ui/Button.astro';
+
+const { textDirection } = I18N;
+---
+
+<div class="mx-auto px-6 sm:px-6 max-w-3xl pt-8 md:pt-4 pb-12 md:pb-20">
+ <Button variant="tertiary" class="px-3 md:px-3" href={getBlogPermalink()}>
+ {
+ textDirection === 'rtl' ? (
+ <Icon name="tabler:chevron-right" class="w-5 h-5 mr-1 -ml-1.5 rtl:-mr-1.5 rtl:ml-1" />
+ ) : (
+ <Icon name="tabler:chevron-left" class="w-5 h-5 mr-1 -ml-1.5 rtl:-mr-1.5 rtl:ml-1" />
+ )
+ } Back to Blog
+ </Button>
+</div>
diff --git a/src/components/common/Analytics.astro b/src/components/common/Analytics.astro
new file mode 100644
index 0000000..a1a553d
--- /dev/null
+++ b/src/components/common/Analytics.astro
@@ -0,0 +1,13 @@
+---
+import { GoogleAnalytics } from '@astrolib/analytics';
+import { ANALYTICS } from 'astrowind:config';
+---
+
+{
+ ANALYTICS?.vendors?.googleAnalytics?.id ? (
+ <GoogleAnalytics
+ id={String(ANALYTICS.vendors.googleAnalytics.id)}
+ partytown={ANALYTICS?.vendors?.googleAnalytics?.partytown}
+ />
+ ) : null
+}
diff --git a/src/components/common/ApplyColorMode.astro b/src/components/common/ApplyColorMode.astro
new file mode 100644
index 0000000..d0d97fe
--- /dev/null
+++ b/src/components/common/ApplyColorMode.astro
@@ -0,0 +1,33 @@
+---
+import { UI } from 'astrowind:config';
+
+// TODO: This code is temporary
+---
+
+<script is:inline define:vars={{ defaultTheme: UI.theme || 'system' }}>
+ function applyTheme(theme) {
+ if (theme === 'dark') {
+ document.documentElement.classList.add('dark');
+ } else {
+ document.documentElement.classList.remove('dark');
+ }
+ const matches = document.querySelectorAll('[data-aw-toggle-color-scheme] > input');
+
+ if (matches && matches.length) {
+ matches.forEach((elem) => {
+ elem.checked = theme !== 'dark';
+ });
+ }
+ }
+
+ if ((defaultTheme && defaultTheme.endsWith(':only')) || (!localStorage.theme && defaultTheme !== 'system')) {
+ applyTheme(defaultTheme.replace(':only', ''));
+ } else if (
+ localStorage.theme === 'dark' ||
+ (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)
+ ) {
+ applyTheme('dark');
+ } else {
+ applyTheme('light');
+ }
+</script>
diff --git a/src/components/common/BasicScripts.astro b/src/components/common/BasicScripts.astro
new file mode 100644
index 0000000..c7290b2
--- /dev/null
+++ b/src/components/common/BasicScripts.astro
@@ -0,0 +1,255 @@
+---
+import { UI } from 'astrowind:config';
+---
+
+<script is:inline define:vars={{ defaultTheme: UI.theme }}>
+ if (window.basic_script) {
+ return;
+ }
+
+ window.basic_script = true;
+
+ function applyTheme(theme) {
+ if (theme === 'dark') {
+ document.documentElement.classList.add('dark');
+ } else {
+ document.documentElement.classList.remove('dark');
+ }
+ }
+
+ const initTheme = function () {
+ if ((defaultTheme && defaultTheme.endsWith(':only')) || (!localStorage.theme && defaultTheme !== 'system')) {
+ applyTheme(defaultTheme.replace(':only', ''));
+ } else if (
+ localStorage.theme === 'dark' ||
+ (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)
+ ) {
+ applyTheme('dark');
+ } else {
+ applyTheme('light');
+ }
+ };
+ initTheme();
+
+ function attachEvent(selector, event, fn) {
+ const matches = typeof selector === 'string' ? document.querySelectorAll(selector) : selector;
+ if (matches && matches.length) {
+ matches.forEach((elem) => {
+ elem.addEventListener(event, (e) => fn(e, elem), false);
+ });
+ }
+ }
+
+ const onLoad = function () {
+ let lastKnownScrollPosition = window.scrollY;
+ let ticking = true;
+
+ attachEvent('#header nav', 'click', function () {
+ document.querySelector('[data-aw-toggle-menu]')?.classList.remove('expanded');
+ document.body.classList.remove('overflow-hidden');
+ document.getElementById('header')?.classList.remove('h-screen');
+ document.getElementById('header')?.classList.remove('expanded');
+ document.getElementById('header')?.classList.remove('bg-page');
+ document.querySelector('#header nav')?.classList.add('hidden');
+ document.querySelector('#header > div > div:last-child')?.classList.add('hidden');
+ });
+
+ attachEvent('[data-aw-toggle-menu]', 'click', function (_, elem) {
+ elem.classList.toggle('expanded');
+ document.body.classList.toggle('overflow-hidden');
+ document.getElementById('header')?.classList.toggle('h-screen');
+ document.getElementById('header')?.classList.toggle('expanded');
+ document.getElementById('header')?.classList.toggle('bg-page');
+ document.querySelector('#header nav')?.classList.toggle('hidden');
+ document.querySelector('#header > div > div:last-child')?.classList.toggle('hidden');
+ });
+
+ attachEvent('[data-aw-toggle-color-scheme]', 'click', function () {
+ if (defaultTheme.endsWith(':only')) {
+ return;
+ }
+ document.documentElement.classList.toggle('dark');
+ localStorage.theme = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
+ });
+
+ attachEvent('[data-aw-social-share]', 'click', function (_, elem) {
+ const network = elem.getAttribute('data-aw-social-share');
+ const url = encodeURIComponent(elem.getAttribute('data-aw-url'));
+ const text = encodeURIComponent(elem.getAttribute('data-aw-text'));
+
+ let href;
+ switch (network) {
+ case 'facebook':
+ href = `https://www.facebook.com/sharer.php?u=${url}`;
+ break;
+ case 'twitter':
+ href = `https://twitter.com/intent/tweet?url=${url}&text=${text}`;
+ break;
+ case 'linkedin':
+ href = `https://www.linkedin.com/shareArticle?mini=true&url=${url}&title=${text}`;
+ break;
+ case 'whatsapp':
+ href = `https://wa.me/?text=${text}%20${url}`;
+ break;
+ case 'mail':
+ href = `mailto:?subject=%22${text}%22&body=${text}%20${url}`;
+ break;
+
+ default:
+ return;
+ }
+
+ const newlink = document.createElement('a');
+ newlink.target = '_blank';
+ newlink.href = href;
+ newlink.click();
+ });
+
+ const screenSize = window.matchMedia('(max-width: 767px)');
+ screenSize.addEventListener('change', function () {
+ document.querySelector('[data-aw-toggle-menu]')?.classList.remove('expanded');
+ document.body.classList.remove('overflow-hidden');
+ document.getElementById('header')?.classList.remove('h-screen');
+ document.getElementById('header')?.classList.remove('expanded');
+ document.getElementById('header')?.classList.remove('bg-page');
+ document.querySelector('#header nav')?.classList.add('hidden');
+ document.querySelector('#header > div > div:last-child')?.classList.add('hidden');
+ });
+
+ function applyHeaderStylesOnScroll() {
+ const header = document.querySelector('#header[data-aw-sticky-header]');
+ if (!header) return;
+ if (lastKnownScrollPosition > 60 && !header.classList.contains('scroll')) {
+ header.classList.add('scroll');
+ } else if (lastKnownScrollPosition <= 60 && header.classList.contains('scroll')) {
+ header.classList.remove('scroll');
+ }
+ ticking = false;
+ }
+ applyHeaderStylesOnScroll();
+
+ attachEvent([document], 'scroll', function () {
+ lastKnownScrollPosition = window.scrollY;
+
+ if (!ticking) {
+ window.requestAnimationFrame(() => {
+ applyHeaderStylesOnScroll();
+ });
+ ticking = true;
+ }
+ });
+ };
+ const onPageShow = function () {
+ document.documentElement.classList.add('motion-safe:scroll-smooth');
+ const elem = document.querySelector('[data-aw-toggle-menu]');
+ if (elem) {
+ elem.classList.remove('expanded');
+ }
+ document.body.classList.remove('overflow-hidden');
+ document.getElementById('header')?.classList.remove('h-screen');
+ document.getElementById('header')?.classList.remove('expanded');
+ document.querySelector('#header nav')?.classList.add('hidden');
+ };
+
+ window.onload = onLoad;
+ window.onpageshow = onPageShow;
+
+ document.addEventListener('astro:after-swap', () => {
+ initTheme();
+ onLoad();
+ onPageShow();
+ });
+</script>
+
+<script is:inline>
+ /* Inspired by: https://github.com/heidkaemper/tailwindcss-intersect */
+ const Observer = {
+ observer: null,
+ delayBetweenAnimations: 100,
+ animationCounter: 0,
+
+ start() {
+ const selectors = [
+ '[class*=" intersect:"]',
+ '[class*=":intersect:"]',
+ '[class^="intersect:"]',
+ '[class="intersect"]',
+ '[class*=" intersect "]',
+ '[class^="intersect "]',
+ '[class$=" intersect"]',
+ ];
+
+ const elements = Array.from(document.querySelectorAll(selectors.join(',')));
+
+ const getThreshold = (element) => {
+ if (element.classList.contains('intersect-full')) return 0.99;
+ if (element.classList.contains('intersect-half')) return 0.5;
+ if (element.classList.contains('intersect-quarter')) return 0.25;
+ return 0;
+ };
+
+ elements.forEach((el) => {
+ el.setAttribute('no-intersect', '');
+ el._intersectionThreshold = getThreshold(el);
+ });
+
+ const callback = (entries) => {
+ entries.forEach((entry) => {
+ requestAnimationFrame(() => {
+ const target = entry.target;
+ const intersectionRatio = entry.intersectionRatio;
+ const threshold = target._intersectionThreshold;
+
+ if (target.classList.contains('intersect-no-queue')) {
+ if (entry.isIntersecting) {
+ target.removeAttribute('no-intersect');
+ if (target.classList.contains('intersect-once')) {
+ this.observer.unobserve(target);
+ }
+ } else {
+ target.setAttribute('no-intersect', '');
+ }
+ return;
+ }
+
+ if (intersectionRatio >= threshold) {
+ if (!target.hasAttribute('data-animated')) {
+ target.removeAttribute('no-intersect');
+ target.setAttribute('data-animated', 'true');
+
+ const delay = this.animationCounter * this.delayBetweenAnimations;
+ this.animationCounter++;
+
+ target.style.transitionDelay = `${delay}ms`;
+ target.style.animationDelay = `${delay}ms`;
+
+ if (target.classList.contains('intersect-once')) {
+ this.observer.unobserve(target);
+ }
+ }
+ } else {
+ target.setAttribute('no-intersect', '');
+ target.removeAttribute('data-animated');
+ target.style.transitionDelay = '';
+ target.style.animationDelay = '';
+
+ this.animationCounter = 0;
+ }
+ });
+ });
+ };
+
+ this.observer = new IntersectionObserver(callback.bind(this), { threshold: [0, 0.25, 0.5, 0.99] });
+
+ elements.forEach((el) => {
+ this.observer.observe(el);
+ });
+ },
+ };
+
+ Observer.start();
+
+ document.addEventListener('astro:after-swap', () => {
+ Observer.start();
+ });
+</script>
diff --git a/src/components/common/CommonMeta.astro b/src/components/common/CommonMeta.astro
new file mode 100644
index 0000000..aab6dd4
--- /dev/null
+++ b/src/components/common/CommonMeta.astro
@@ -0,0 +1,8 @@
+---
+import { getAsset } from '~/utils/permalinks';
+---
+
+<meta charset="UTF-8" />
+<meta name="viewport" content="width=device-width, initial-scale=1.0" />
+
+<link rel="sitemap" href={getAsset('/sitemap-index.xml')} />
diff --git a/src/components/common/Image.astro b/src/components/common/Image.astro
new file mode 100644
index 0000000..d113b68
--- /dev/null
+++ b/src/components/common/Image.astro
@@ -0,0 +1,61 @@
+---
+import type { HTMLAttributes } from 'astro/types';
+import { findImage } from '~/utils/images';
+import {
+ getImagesOptimized,
+ astroAssetsOptimizer,
+ unpicOptimizer,
+ isUnpicCompatible,
+ type ImageProps,
+} from '~/utils/images-optimization';
+
+type Props = ImageProps;
+type ImageType = {
+ src: string;
+ attributes: HTMLAttributes<'img'>;
+};
+
+const props = Astro.props;
+
+if (props.alt === undefined || props.alt === null) {
+ throw new Error();
+}
+
+if (typeof props.width === 'string') {
+ props.width = parseInt(props.width);
+}
+
+if (typeof props.height === 'string') {
+ props.height = parseInt(props.height);
+}
+
+if (!props.loading) {
+ props.loading = 'lazy';
+}
+
+if (!props.decoding) {
+ props.decoding = 'async';
+}
+
+const _image = await findImage(props.src);
+
+let image: ImageType | undefined = undefined;
+
+if (
+ typeof _image === 'string' &&
+ (_image.startsWith('http://') || _image.startsWith('https://')) &&
+ isUnpicCompatible(_image)
+) {
+ image = await getImagesOptimized(_image, props, unpicOptimizer);
+} else if (_image) {
+ image = await getImagesOptimized(_image, props, astroAssetsOptimizer);
+}
+---
+
+{
+ !image ? (
+ <Fragment />
+ ) : (
+ <img src={image.src} crossorigin="anonymous" referrerpolicy="no-referrer" {...image.attributes} />
+ )
+}
diff --git a/src/components/common/Metadata.astro b/src/components/common/Metadata.astro
new file mode 100644
index 0000000..a4c573e
--- /dev/null
+++ b/src/components/common/Metadata.astro
@@ -0,0 +1,68 @@
+---
+import merge from 'lodash.merge';
+import { AstroSeo } from '@astrolib/seo';
+
+import type { Props as AstroSeoProps } from '@astrolib/seo';
+
+import { SITE, METADATA, I18N } from 'astrowind:config';
+import type { MetaData } from '~/types';
+import { getCanonical } from '~/utils/permalinks';
+
+import { adaptOpenGraphImages } from '~/utils/images';
+
+export interface Props extends MetaData {
+ dontUseTitleTemplate?: boolean;
+}
+
+const {
+ title,
+ ignoreTitleTemplate = false,
+ canonical = String(getCanonical(String(Astro.url.pathname))),
+ robots = {},
+ description,
+ openGraph = {},
+ twitter = {},
+} = Astro.props;
+
+const seoProps: AstroSeoProps = merge(
+ {
+ title: '',
+ titleTemplate: '%s',
+ canonical: canonical,
+ noindex: true,
+ nofollow: true,
+ description: undefined,
+ openGraph: {
+ url: canonical,
+ site_name: SITE?.name,
+ images: [],
+ locale: I18N?.language || 'en',
+ type: 'website',
+ },
+ twitter: {
+ cardType: openGraph?.images?.length ? 'summary_large_image' : 'summary',
+ },
+ },
+ {
+ title: METADATA?.title?.default,
+ titleTemplate: METADATA?.title?.template,
+ noindex: typeof METADATA?.robots?.index !== 'undefined' ? !METADATA.robots.index : undefined,
+ nofollow: typeof METADATA?.robots?.follow !== 'undefined' ? !METADATA.robots.follow : undefined,
+ description: METADATA?.description,
+ openGraph: METADATA?.openGraph,
+ twitter: METADATA?.twitter,
+ },
+ {
+ title: title,
+ titleTemplate: ignoreTitleTemplate ? '%s' : undefined,
+ canonical: canonical,
+ noindex: typeof robots?.index !== 'undefined' ? !robots.index : undefined,
+ nofollow: typeof robots?.follow !== 'undefined' ? !robots.follow : undefined,
+ description: description,
+ openGraph: { url: canonical, ...openGraph },
+ twitter: twitter,
+ }
+);
+---
+
+<AstroSeo {...{ ...seoProps, openGraph: await adaptOpenGraphImages(seoProps?.openGraph, Astro.site) }} />
diff --git a/src/components/common/SiteVerification.astro b/src/components/common/SiteVerification.astro
new file mode 100644
index 0000000..000baad
--- /dev/null
+++ b/src/components/common/SiteVerification.astro
@@ -0,0 +1,5 @@
+---
+import { SITE } from 'astrowind:config';
+---
+
+{SITE.googleSiteVerificationId && <meta name="google-site-verification" content={SITE.googleSiteVerificationId} />}
diff --git a/src/components/common/SocialShare.astro b/src/components/common/SocialShare.astro
new file mode 100644
index 0000000..d035e8f
--- /dev/null
+++ b/src/components/common/SocialShare.astro
@@ -0,0 +1,65 @@
+---
+import { Icon } from 'astro-icon/components';
+
+export interface Props {
+ text: string;
+ url: string | URL;
+ class?: string;
+}
+
+const { text, url, class: className = 'inline-block' } = Astro.props;
+---
+
+<div class={className}>
+ <span class="align-super font-bold text-slate-500 dark:text-slate-400">Share:</span>
+ <button
+ class="ml-2 rtl:ml-0 rtl:mr-2"
+ title="Twitter Share"
+ data-aw-social-share="twitter"
+ data-aw-url={url}
+ data-aw-text={text}
+ ><Icon
+ name="tabler:brand-x"
+ class="w-6 h-6 text-gray-400 dark:text-slate-500 hover:text-black dark:hover:text-slate-300"
+ />
+ </button>
+ <button class="ml-2 rtl:ml-0 rtl:mr-2" title="Facebook Share" data-aw-social-share="facebook" data-aw-url={url}
+ ><Icon
+ name="tabler:brand-facebook"
+ class="w-6 h-6 text-gray-400 dark:text-slate-500 hover:text-black dark:hover:text-slate-300"
+ />
+ </button>
+ <button
+ class="ml-2 rtl:ml-0 rtl:mr-2"
+ title="Linkedin Share"
+ data-aw-social-share="linkedin"
+ data-aw-url={url}
+ data-aw-text={text}
+ ><Icon
+ name="tabler:brand-linkedin"
+ class="w-6 h-6 text-gray-400 dark:text-slate-500 hover:text-black dark:hover:text-slate-300"
+ />
+ </button>
+ <button
+ class="ml-2 rtl:ml-0 rtl:mr-2"
+ title="Whatsapp Share"
+ data-aw-social-share="whatsapp"
+ data-aw-url={url}
+ data-aw-text={text}
+ ><Icon
+ name="tabler:brand-whatsapp"
+ class="w-6 h-6 text-gray-400 dark:text-slate-500 hover:text-black dark:hover:text-slate-300"
+ />
+ </button>
+ <button
+ class="ml-2 rtl:ml-0 rtl:mr-2"
+ title="Email Share"
+ data-aw-social-share="mail"
+ data-aw-url={url}
+ data-aw-text={text}
+ ><Icon
+ name="tabler:mail"
+ class="w-6 h-6 text-gray-400 dark:text-slate-500 hover:text-black dark:hover:text-slate-300"
+ />
+ </button>
+</div>
diff --git a/src/components/common/SplitbeeAnalytics.astro b/src/components/common/SplitbeeAnalytics.astro
new file mode 100644
index 0000000..66651db
--- /dev/null
+++ b/src/components/common/SplitbeeAnalytics.astro
@@ -0,0 +1,6 @@
+---
+const { doNotTrack = true, noCookieMode = false, url = 'https://cdn.splitbee.io/sb.js' } = Astro.props;
+---
+
+<!-- Splitbee Analytics -->
+<script is:inline data-respect-dnt={doNotTrack} data-no-cookie={noCookieMode} async src={url}></script>
diff --git a/src/components/common/ToggleMenu.astro b/src/components/common/ToggleMenu.astro
new file mode 100644
index 0000000..2d19b16
--- /dev/null
+++ b/src/components/common/ToggleMenu.astro
@@ -0,0 +1,29 @@
+---
+export interface Props {
+ label?: string;
+ class?: string;
+}
+
+const {
+ label = 'Toggle Menu',
+ class: className = 'flex flex-col h-12 w-12 rounded justify-center items-center cursor-pointer group',
+} = Astro.props;
+---
+
+<button type="button" class={className} aria-label={label} data-aw-toggle-menu>
+ <span class="sr-only">{label}</span>
+ <slot>
+ <span
+ aria-hidden="true"
+ class="h-0.5 w-6 my-1 rounded-full bg-black dark:bg-white transition ease transform duration-200 opacity-80 group-[.expanded]:rotate-45 group-[.expanded]:translate-y-2.5"
+ ></span>
+ <span
+ aria-hidden="true"
+ class="h-0.5 w-6 my-1 rounded-full bg-black dark:bg-white transition ease transform duration-200 opacity-80 group-[.expanded]:opacity-0"
+ ></span>
+ <span
+ aria-hidden="true"
+ class="h-0.5 w-6 my-1 rounded-full bg-black dark:bg-white transition ease transform duration-200 opacity-80 group-[.expanded]:-rotate-45 group-[.expanded]:-translate-y-2.5"
+ ></span>
+ </slot>
+</button>
diff --git a/src/components/common/ToggleTheme.astro b/src/components/common/ToggleTheme.astro
new file mode 100644
index 0000000..8f3aafb
--- /dev/null
+++ b/src/components/common/ToggleTheme.astro
@@ -0,0 +1,28 @@
+---
+import { Icon } from 'astro-icon/components';
+
+import { UI } from 'astrowind:config';
+
+export interface Props {
+ label?: string;
+ class?: string;
+ iconClass?: string;
+ iconName?: string;
+}
+
+const {
+ label = 'Toggle between Dark and Light mode',
+ class:
+ className = 'text-muted dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-700 rounded-lg text-sm p-2.5 inline-flex items-center',
+ iconClass = 'w-6 h-6',
+ iconName = 'tabler:sun',
+} = Astro.props;
+---
+
+{
+ !(UI.theme && UI.theme.endsWith(':only')) && (
+ <button type="button" class={className} aria-label={label} data-aw-toggle-color-scheme>
+ <Icon name={iconName} class={iconClass} />
+ </button>
+ )
+}
diff --git a/src/components/ui/Background.astro b/src/components/ui/Background.astro
new file mode 100644
index 0000000..f220487
--- /dev/null
+++ b/src/components/ui/Background.astro
@@ -0,0 +1,11 @@
+---
+export interface Props {
+ isDark?: boolean;
+}
+
+const { isDark = false } = Astro.props;
+---
+
+<div class:list={['absolute inset-0', { 'bg-section dark:bg-dark': isDark }]}>
+ <slot />
+</div>
diff --git a/src/components/ui/Button.astro b/src/components/ui/Button.astro
new file mode 100644
index 0000000..d3c2398
--- /dev/null
+++ b/src/components/ui/Button.astro
@@ -0,0 +1,40 @@
+---
+import { Icon } from 'astro-icon/components';
+import { twMerge } from 'tailwind-merge';
+import type { CallToAction as Props } from '~/types';
+
+const {
+ variant = 'secondary',
+ target,
+ text = Astro.slots.render('default'),
+ icon = '',
+ class: className = '',
+ type,
+ ...rest
+} = Astro.props;
+
+const variants = {
+ primary: 'btn-primary',
+ secondary: 'btn-secondary',
+ tertiary: 'btn btn-tertiary',
+ link: 'cursor-pointer hover:text-primary',
+};
+---
+
+{
+ type === 'button' || type === 'submit' || type === 'reset' ? (
+ <button type={type} class={twMerge(variants[variant] || '', className)} {...rest}>
+ <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>
+ ) : (
+ <a
+ class={twMerge(variants[variant] || '', className)}
+ {...(target ? { target: target, rel: 'noopener noreferrer' } : {})}
+ {...rest}
+ >
+ <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" />}
+ </a>
+ )
+}
diff --git a/src/components/ui/DListItem.astro b/src/components/ui/DListItem.astro
new file mode 100644
index 0000000..36d4072
--- /dev/null
+++ b/src/components/ui/DListItem.astro
@@ -0,0 +1,22 @@
+---
+// component: DListItem
+//
+// Mimics the html 'dl' (description list)
+//
+// The 'dt' item is the item 'term' and is inserted into an 'h6' tag.
+// Caller needs to style the 'h6' tag appropriately.
+//
+// You can put pretty much any content you want between the open and
+// closing tags - it's simply contained in an enclosing div with a
+// margin left. No need for 'dd' items.
+//
+const { dt } = Astro.props;
+interface Props {
+ dt: string;
+}
+
+const content: string = await Astro.slots.render('default');
+---
+
+<h6 set:html={dt} />
+<div class="dd ml-8" set:html={content} />
diff --git a/src/components/ui/Form.astro b/src/components/ui/Form.astro
new file mode 100644
index 0000000..276b39f
--- /dev/null
+++ b/src/components/ui/Form.astro
@@ -0,0 +1,87 @@
+---
+import type { Form as Props } from '~/types';
+import Button from '~/components/ui/Button.astro';
+
+const { inputs, textarea, disclaimer, button = 'Contact us', description = '' } = Astro.props;
+---
+
+<form>
+ {
+ inputs &&
+ inputs.map(
+ ({ type = 'text', name, label = '', autocomplete = 'on', placeholder = '' }) =>
+ name && (
+ <div class="mb-6">
+ {label && (
+ <label for={name} class="block text-sm font-medium">
+ {label}
+ </label>
+ )}
+ <input
+ type={type}
+ name={name}
+ id={name}
+ autocomplete={autocomplete}
+ 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"
+ />
+ </div>
+ )
+ )
+ }
+
+ {
+ textarea && (
+ <div>
+ <label for="textarea" class="block text-sm font-medium">
+ {textarea.label}
+ </label>
+ <textarea
+ id="textarea"
+ name={textarea.name ? textarea.name : 'message'}
+ rows={textarea.rows ? textarea.rows : 4}
+ 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"
+ />
+ </div>
+ )
+ }
+
+ {
+ disclaimer && (
+ <div class="mt-3 flex items-start">
+ <div class="flex mt-0.5">
+ <input
+ id="disclaimer"
+ name="disclaimer"
+ 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"
+ />
+ </div>
+ <div class="ml-3">
+ <label for="disclaimer" class="cursor-pointer select-none text-sm text-gray-600 dark:text-gray-400">
+ {disclaimer.label}
+ </label>
+ </div>
+ </div>
+ )
+ }
+
+ {
+ button && (
+ <div class="mt-10 grid">
+ <Button variant="primary" type="submit">
+ {button}
+ </Button>
+ </div>
+ )
+ }
+
+ {
+ description && (
+ <div class="mt-3 text-center">
+ <p class="text-sm text-gray-600 dark:text-gray-400">{description}</p>
+ </div>
+ )
+ }
+</form>
diff --git a/src/components/ui/Headline.astro b/src/components/ui/Headline.astro
new file mode 100644
index 0000000..6b906b0
--- /dev/null
+++ b/src/components/ui/Headline.astro
@@ -0,0 +1,35 @@
+---
+import type { Headline as Props } from '~/types';
+import { twMerge } from 'tailwind-merge';
+
+const {
+ title = await Astro.slots.render('title'),
+ subtitle = await Astro.slots.render('subtitle'),
+ tagline,
+ classes = {},
+} = Astro.props;
+
+const {
+ container: containerClass = 'max-w-3xl',
+ title: titleClass = 'text-3xl md:text-4xl ',
+ subtitle: subtitleClass = 'text-xl',
+} = classes;
+---
+
+{
+ (title || subtitle || tagline) && (
+ <div class={twMerge('mb-8 md:mx-auto md:mb-12 text-center', containerClass)}>
+ {tagline && (
+ <p class="text-base text-secondary dark:text-primary font-bold tracking-wide uppercase" set:html={tagline} />
+ )}
+ {title && (
+ <h2
+ class={twMerge('font-bold leading-tighter tracking-tighter font-heading text-heading text-3xl', titleClass)}
+ set:html={title}
+ />
+ )}
+
+ {subtitle && <p class={twMerge('mt-4 text-muted', subtitleClass)} set:html={subtitle} />}
+ </div>
+ )
+}
diff --git a/src/components/ui/ItemGrid.astro b/src/components/ui/ItemGrid.astro
new file mode 100644
index 0000000..79931b9
--- /dev/null
+++ b/src/components/ui/ItemGrid.astro
@@ -0,0 +1,65 @@
+---
+import type { ItemGrid as Props } from '~/types';
+import { twMerge } from 'tailwind-merge';
+import Button from './Button.astro';
+import { Icon } from 'astro-icon/components';
+
+const { items = [], columns, defaultIcon = '', classes = {} } = Astro.props;
+
+const {
+ container: containerClass = '',
+ panel: panelClass = '',
+ title: titleClass = '',
+ description: descriptionClass = '',
+ icon: defaultIconClass = 'text-primary',
+ action: actionClass = '',
+} = classes;
+---
+
+{
+ items && items.length > 0 && (
+ <div
+ class={twMerge(
+ `grid mx-auto gap-8 md:gap-y-12 ${
+ columns === 4
+ ? 'lg:grid-cols-4 md:grid-cols-3 sm:grid-cols-2'
+ : columns === 3
+ ? 'lg:grid-cols-3 sm:grid-cols-2'
+ : columns === 2
+ ? 'sm:grid-cols-2 '
+ : ''
+ }`,
+ containerClass
+ )}
+ >
+ {items.map(({ title, description, icon, callToAction, classes: itemClasses = {} }) => (
+ <div class="intersect-once motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade">
+ <div class={twMerge('flex flex-row max-w-md', panelClass, itemClasses?.panel)}>
+ <div class="flex justify-center">
+ {(icon || defaultIcon) && (
+ <Icon
+ name={icon || defaultIcon}
+ class={twMerge('w-7 h-7 mr-2 rtl:mr-0 rtl:ml-2', defaultIconClass, itemClasses?.icon)}
+ />
+ )}
+ </div>
+ <div class="mt-0.5">
+ {title && <h3 class={twMerge('text-xl font-bold', titleClass, itemClasses?.title)}>{title}</h3>}
+ {description && (
+ <p
+ class={twMerge(`${title ? 'mt-3' : ''} text-muted`, descriptionClass, itemClasses?.description)}
+ set:html={description}
+ />
+ )}
+ {callToAction && (
+ <div class={twMerge(`${title || description ? 'mt-3' : ''}`, actionClass, itemClasses?.actionClass)}>
+ <Button variant="link" {...callToAction} />
+ </div>
+ )}
+ </div>
+ </div>
+ </div>
+ ))}
+ </div>
+ )
+}
diff --git a/src/components/ui/ItemGrid2.astro b/src/components/ui/ItemGrid2.astro
new file mode 100644
index 0000000..81faadf
--- /dev/null
+++ b/src/components/ui/ItemGrid2.astro
@@ -0,0 +1,59 @@
+---
+import type { ItemGrid as Props } from '~/types';
+import { Icon } from 'astro-icon/components';
+import { twMerge } from 'tailwind-merge';
+import Button from './Button.astro';
+
+const { items = [], columns, defaultIcon = '', classes = {} } = Astro.props;
+
+const {
+ container: containerClass = '',
+ // container: containerClass = "sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3",
+ panel: panelClass = '',
+ title: titleClass = '',
+ description: descriptionClass = '',
+ icon: defaultIconClass = 'text-primary',
+} = classes;
+---
+
+{
+ items && items.length > 0 && (
+ <div
+ class={twMerge(
+ `grid gap-8 gap-x-12 sm:gap-y-8 ${
+ columns === 4
+ ? 'lg:grid-cols-4 md:grid-cols-3 sm:grid-cols-2'
+ : columns === 3
+ ? 'lg:grid-cols-3 sm:grid-cols-2'
+ : columns === 2
+ ? 'sm:grid-cols-2 '
+ : ''
+ }`,
+ containerClass
+ )}
+ >
+ {items.map(({ title, description, icon, callToAction, classes: itemClasses = {} }) => (
+ <div
+ class={twMerge(
+ 'relative flex flex-col intersect-once intersect-quarter intercept-no-queue motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade',
+ panelClass,
+ itemClasses?.panel
+ )}
+ >
+ {(icon || defaultIcon) && (
+ <Icon name={icon || defaultIcon} class={twMerge('mb-2 w-10 h-10', defaultIconClass, itemClasses?.icon)} />
+ )}
+ <div class={twMerge('text-xl font-bold', titleClass, itemClasses?.title)}>{title}</div>
+ {description && (
+ <p class={twMerge('text-muted mt-2', descriptionClass, itemClasses?.description)} set:html={description} />
+ )}
+ {callToAction && (
+ <div class="mt-2">
+ <Button {...callToAction} />
+ </div>
+ )}
+ </div>
+ ))}
+ </div>
+ )
+}
diff --git a/src/components/ui/Timeline.astro b/src/components/ui/Timeline.astro
new file mode 100644
index 0000000..b25c9de
--- /dev/null
+++ b/src/components/ui/Timeline.astro
@@ -0,0 +1,60 @@
+---
+import { Icon } from 'astro-icon/components';
+import { twMerge } from 'tailwind-merge';
+import type { Item } from '~/types';
+
+export interface Props {
+ items?: Array<Item>;
+ defaultIcon?: string;
+ classes?: Record<string, string>;
+}
+
+const { items = [], classes = {}, defaultIcon } = Astro.props as Props;
+
+const {
+ container: containerClass = '',
+ panel: panelClass = '',
+ title: titleClass = '',
+ description: descriptionClass = '',
+ icon: defaultIconClass = 'text-primary dark:text-slate-200 border-primary dark:border-secondary',
+} = classes;
+---
+
+{
+ items && items.length > 0 && (
+ <div class={containerClass}>
+ {items.map(({ title, description, icon, classes: itemClasses = {} }, index = 0) => (
+ <div
+ class={twMerge(
+ 'flex intersect-once intersect-quarter motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade',
+ panelClass,
+ itemClasses?.panel
+ )}
+ >
+ <div class="flex flex-col items-center mr-4 rtl:mr-0 rtl:ml-4">
+ <div>
+ <div class="flex items-center justify-center">
+ {(icon || defaultIcon) && (
+ <Icon
+ name={icon || defaultIcon}
+ class={twMerge('w-10 h-10 p-2 rounded-full border-2', defaultIconClass, itemClasses?.icon)}
+ />
+ )}
+ </div>
+ </div>
+ {index !== items.length - 1 && <div class="w-px h-full bg-black/10 dark:bg-slate-400/50" />}
+ </div>
+ <div class={`pt-1 ${index !== items.length - 1 ? 'pb-8' : ''}`}>
+ {title && <p class={twMerge('text-xl font-bold', titleClass, itemClasses?.title)} set:html={title} />}
+ {description && (
+ <p
+ class={twMerge('text-muted mt-2', descriptionClass, itemClasses?.description)}
+ set:html={description}
+ />
+ )}
+ </div>
+ </div>
+ ))}
+ </div>
+ )
+}
diff --git a/src/components/ui/WidgetWrapper.astro b/src/components/ui/WidgetWrapper.astro
new file mode 100644
index 0000000..c42c751
--- /dev/null
+++ b/src/components/ui/WidgetWrapper.astro
@@ -0,0 +1,34 @@
+---
+import type { HTMLTag } from 'astro/types';
+import type { Widget } from '~/types';
+import { twMerge } from 'tailwind-merge';
+import Background from './Background.astro';
+
+export interface Props extends Widget {
+ containerClass?: string;
+ ['as']?: HTMLTag;
+}
+
+const { id, isDark = false, containerClass = '', bg, as = 'section' } = Astro.props;
+
+const WrapperTag = as;
+---
+
+<WrapperTag class="relative not-prose scroll-mt-[72px]" {...id ? { id } : {}}>
+ <div class="absolute inset-0 pointer-events-none -z-[1]" aria-hidden="true">
+ <slot name="bg">
+ {bg ? <Fragment set:html={bg} /> : <Background isDark={isDark} />}
+ </slot>
+ </div>
+ <div
+ class:list={[
+ twMerge(
+ 'relative mx-auto max-w-7xl px-4 md:px-6 py-12 md:py-16 lg:py-20 text-default intersect-once intersect-quarter intercept-no-queue motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade',
+ containerClass
+ ),
+ { dark: isDark },
+ ]}
+ >
+ <slot />
+ </div>
+</WrapperTag>
diff --git a/src/components/widgets/Announcement.astro b/src/components/widgets/Announcement.astro
new file mode 100644
index 0000000..8e4bf78
--- /dev/null
+++ b/src/components/widgets/Announcement.astro
@@ -0,0 +1,16 @@
+---
+
+---
+
+<div
+ class="dark text-muted text-sm bg-black dark:bg-transparent dark:border-b dark:border-slate-800 dark:text-slate-400 hidden md:flex gap-1 overflow-hidden px-3 py-2 relative text-ellipsis whitespace-nowrap"
+>
+ <span
+ class="dark:bg-slate-700 bg-white/40 dark:text-slate-300 font-semibold px-1 py-0.5 text-xs mr-0.5 rtl:mr-0 rtl:ml-0.5 inline-block"
+ >NEW</span
+ >
+ <a href="#contact" class="text-muted hover:underline dark:text-slate-400 font-medium"
+ >Skontaktuj się z nami - Darmowa wycena! »</a
+ >
+
+</div>
diff --git a/src/components/widgets/BlogHighlightedPosts.astro b/src/components/widgets/BlogHighlightedPosts.astro
new file mode 100644
index 0000000..75f35a9
--- /dev/null
+++ b/src/components/widgets/BlogHighlightedPosts.astro
@@ -0,0 +1,64 @@
+---
+import { APP_BLOG } from 'astrowind:config';
+
+import Grid from '~/components/blog/Grid.astro';
+
+import { getBlogPermalink } from '~/utils/permalinks';
+import { findPostsByIds } from '~/utils/blog';
+import WidgetWrapper from '~/components/ui/WidgetWrapper.astro';
+import type { Widget } from '~/types';
+
+export interface Props extends Widget {
+ title?: string;
+ linkText?: string;
+ linkUrl?: string | URL;
+ information?: string;
+ postIds: string[];
+}
+
+const {
+ title = await Astro.slots.render('title'),
+ linkText = 'View all posts',
+ linkUrl = getBlogPermalink(),
+ information = await Astro.slots.render('information'),
+ postIds = [],
+
+ id,
+ isDark = false,
+ classes = {},
+ bg = await Astro.slots.render('bg'),
+} = Astro.props;
+
+const posts = APP_BLOG.isEnabled ? await findPostsByIds(postIds) : [];
+---
+
+{
+ APP_BLOG.isEnabled ? (
+ <WidgetWrapper id={id} isDark={isDark} containerClass={classes?.container as string} bg={bg}>
+ <div class="flex flex-col lg:justify-between lg:flex-row mb-8">
+ {title && (
+ <div class="md:max-w-sm">
+ <h2
+ class="text-3xl font-bold tracking-tight sm:text-4xl sm:leading-none group font-heading mb-2"
+ set:html={title}
+ />
+ {APP_BLOG.list.isEnabled && linkText && linkUrl && (
+ <a
+ class="text-muted dark:text-slate-400 hover:text-primary transition ease-in duration-200 block mb-6 lg:mb-0"
+ href={linkUrl}
+ >
+ {linkText} »
+ </a>
+ )}
+ </div>
+ )}
+
+ {information && <p class="text-muted dark:text-slate-400 lg:text-sm lg:max-w-md" set:html={information} />}
+ </div>
+
+ <Grid posts={posts} />
+ </WidgetWrapper>
+ ) : (
+ <Fragment />
+ )
+}
diff --git a/src/components/widgets/BlogLatestPosts.astro b/src/components/widgets/BlogLatestPosts.astro
new file mode 100644
index 0000000..28f66d4
--- /dev/null
+++ b/src/components/widgets/BlogLatestPosts.astro
@@ -0,0 +1,63 @@
+---
+import { APP_BLOG } from 'astrowind:config';
+
+import Grid from '~/components/blog/Grid.astro';
+
+import { getBlogPermalink } from '~/utils/permalinks';
+import { findLatestPosts } from '~/utils/blog';
+import WidgetWrapper from '~/components/ui/WidgetWrapper.astro';
+import type { Widget } from '~/types';
+import Button from '~/components/ui/Button.astro';
+
+export interface Props extends Widget {
+ title?: string;
+ linkText?: string;
+ linkUrl?: string | URL;
+ information?: string;
+ count?: number;
+}
+
+const {
+ title = await Astro.slots.render('title'),
+ linkText = 'View all posts',
+ linkUrl = getBlogPermalink(),
+ information = await Astro.slots.render('information'),
+ count = 4,
+
+ id,
+ isDark = false,
+ classes = {},
+ bg = await Astro.slots.render('bg'),
+} = Astro.props;
+
+const posts = APP_BLOG.isEnabled ? await findLatestPosts({ count }) : [];
+---
+
+{
+ APP_BLOG.isEnabled ? (
+ <WidgetWrapper id={id} isDark={isDark} containerClass={classes?.container as string} bg={bg}>
+ <div class="flex flex-col lg:justify-between lg:flex-row mb-8">
+ {title && (
+ <div class="md:max-w-sm">
+ <h2
+ class="text-3xl font-bold tracking-tight sm:text-4xl sm:leading-none group font-heading mb-2"
+ set:html={title}
+ />
+ {APP_BLOG.list.isEnabled && linkText && linkUrl && (
+ <Button variant="link" href={linkUrl}>
+ {' '}
+ {linkText} »
+ </Button>
+ )}
+ </div>
+ )}
+
+ {information && <p class="text-muted dark:text-slate-400 lg:text-sm lg:max-w-md" set:html={information} />}
+ </div>
+
+ <Grid posts={posts} />
+ </WidgetWrapper>
+ ) : (
+ <Fragment />
+ )
+}
diff --git a/src/components/widgets/Brands.astro b/src/components/widgets/Brands.astro
new file mode 100644
index 0000000..7e42ae1
--- /dev/null
+++ b/src/components/widgets/Brands.astro
@@ -0,0 +1,38 @@
+---
+import { Icon } from 'astro-icon/components';
+import type { Brands as Props } from '~/types';
+
+import Image from '~/components/common/Image.astro';
+import Headline from '~/components/ui/Headline.astro';
+import WidgetWrapper from '~/components/ui/WidgetWrapper.astro';
+const {
+ title = '',
+ subtitle = '',
+ tagline = '',
+ icons = [],
+ images = [],
+ id,
+ isDark = false,
+ classes = {},
+ bg = await Astro.slots.render('bg'),
+} = Astro.props;
+---
+
+<WidgetWrapper id={id} isDark={isDark} containerClass={`max-w-6xl mx-auto ${classes?.container ?? ''}`} bg={bg}>
+ <Headline title={title} subtitle={subtitle} tagline={tagline} />
+
+ <div class="flex flex-wrap justify-center gap-x-6 sm:gap-x-12 lg:gap-x-24">
+ {icons && icons.map((icon) => <Icon name={icon} class="py-3 lg:py-5 w-12 h-auto mx-auto sm:mx-0 text-gray-500" />)}
+ {
+ images &&
+ images.map(
+ (image) =>
+ image.src && (
+ <div class="flex justify-center col-span-1 my-2 lg:my-4 py-1 px-3 rounded-md dark:bg-gray-200">
+ <Image src={image.src} alt={image.alt || ''} class="max-h-12" width={120} height={48} layout="fixed" />
+ </div>
+ )
+ )
+ }
+ </div>
+</WidgetWrapper>
diff --git a/src/components/widgets/CallToAction.astro b/src/components/widgets/CallToAction.astro
new file mode 100644
index 0000000..f51aa91
--- /dev/null
+++ b/src/components/widgets/CallToAction.astro
@@ -0,0 +1,58 @@
+---
+import WidgetWrapper from '~/components/ui/WidgetWrapper.astro';
+import type { CallToAction, Widget } from '~/types';
+import Headline from '~/components/ui/Headline.astro';
+import Button from '~/components/ui/Button.astro';
+
+interface Props extends Widget {
+ title?: string;
+ subtitle?: string;
+ tagline?: string;
+ callToAction?: CallToAction;
+ actions?: string | CallToAction[];
+}
+
+const {
+ title = await Astro.slots.render('title'),
+ subtitle = await Astro.slots.render('subtitle'),
+ tagline = await Astro.slots.render('tagline'),
+ actions = await Astro.slots.render('actions'),
+
+ id,
+ isDark = false,
+ classes = {},
+ bg = await Astro.slots.render('bg'),
+} = Astro.props;
+---
+
+<WidgetWrapper id={id} isDark={isDark} containerClass={`max-w-6xl mx-auto ${classes?.container ?? ''}`} bg={bg}>
+ <div
+ class="max-w-3xl mx-auto text-center p-6 rounded-md shadow-xl dark:shadow-none dark:border dark:border-slate-600"
+ >
+ <Headline
+ title={title}
+ subtitle={subtitle}
+ tagline={tagline}
+ 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',
+ }}
+ />
+ {
+ actions && (
+ <div class="max-w-xs sm:max-w-md m-auto flex flex-nowrap flex-col sm:flex-row sm:justify-center gap-4 mt-6">
+ {Array.isArray(actions) ? (
+ actions.map((action) => (
+ <div class="flex w-full sm:w-auto">
+ <Button {...(action || {})} class="w-full sm:mb-0" />
+ </div>
+ ))
+ ) : (
+ <Fragment set:html={actions} />
+ )}
+ </div>
+ )
+ }
+ </div>
+</WidgetWrapper>
diff --git a/src/components/widgets/CallToActionImage.astro b/src/components/widgets/CallToActionImage.astro
new file mode 100644
index 0000000..df13145
--- /dev/null
+++ b/src/components/widgets/CallToActionImage.astro
@@ -0,0 +1,73 @@
+---
+import WidgetWrapper from '~/components/ui/WidgetWrapper.astro';
+import type { Widget } from '~/types';
+import Headline from '~/components/ui/Headline.astro';
+import Image from '~/components/common/Image.astro';
+
+interface Props extends Widget {
+ title?: string;
+ subtitle?: string;
+ tagline?: string;
+ image?: {
+ src: string;
+ alt: string;
+ href?: string;
+ target?: string;
+ };
+}
+
+const {
+ title = await Astro.slots.render('title'),
+ subtitle = await Astro.slots.render('subtitle'),
+ tagline = await Astro.slots.render('tagline'),
+ image,
+
+ id,
+ isDark = false,
+ classes = {},
+ bg = await Astro.slots.render('bg'),
+} = Astro.props;
+---
+
+<WidgetWrapper id={id} isDark={isDark} containerClass={`max-w-6xl mx-auto ${classes?.container ?? ''}`} bg={bg}>
+ <div
+ class="max-w-3xl mx-auto text-center p-6 rounded-md shadow-xl dark:shadow-none dark:border dark:border-slate-600"
+ >
+ <Headline
+ title={title}
+ subtitle={subtitle}
+ tagline={tagline}
+ 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',
+ }}
+ />
+ {
+ image && (
+ <div class="flex justify-center mt-6 px-4">
+ {image.href ? (
+ <a
+ href={image.href}
+ target={image.target || '_self'}
+ rel={image.target === '_blank' ? 'noopener noreferrer' : ''}
+ class="inline-block hover:opacity-80 transition-opacity duration-200"
+ >
+ <Image
+ src={image.src}
+ alt={image.alt}
+ class="max-h-20 h-auto w-auto max-w-full object-contain"
+ />
+ </a>
+ ) : (
+ <Image
+ src={image.src}
+ alt={image.alt}
+ class="max-h-20 h-auto w-auto max-w-full object-contain"
+ />
+ )}
+ </div>
+ )
+ }
+ </div>
+</WidgetWrapper> \ No newline at end of file
diff --git a/src/components/widgets/Contact.astro b/src/components/widgets/Contact.astro
new file mode 100644
index 0000000..122f4b0
--- /dev/null
+++ b/src/components/widgets/Contact.astro
@@ -0,0 +1,40 @@
+---
+import FormContainer from '~/components/ui/Form.astro';
+import Headline from '~/components/ui/Headline.astro';
+import WidgetWrapper from '~/components/ui/WidgetWrapper.astro';
+import type { Contact as Props } from '~/types';
+
+const {
+ title = await Astro.slots.render('title'),
+ subtitle = await Astro.slots.render('subtitle'),
+ tagline = await Astro.slots.render('tagline'),
+ inputs,
+ textarea,
+ disclaimer,
+ button,
+ description,
+
+ id,
+ isDark = false,
+ classes = {},
+ bg = await Astro.slots.render('bg'),
+} = Astro.props;
+---
+
+<WidgetWrapper id={id} isDark={isDark} containerClass={`max-w-7xl mx-auto ${classes?.container ?? ''}`} bg={bg}>
+ <Headline title={title} subtitle={subtitle} tagline={tagline} />
+
+ {
+ inputs && (
+ <div class="flex flex-col max-w-xl mx-auto rounded-lg backdrop-blur border border-gray-200 dark:border-gray-700 bg-white dark:bg-slate-900 shadow p-4 sm:p-6 lg:p-8 w-full">
+ <FormContainer
+ inputs={inputs}
+ textarea={textarea}
+ disclaimer={disclaimer}
+ button={button}
+ description={description}
+ />
+ </div>
+ )
+ }
+</WidgetWrapper>
diff --git a/src/components/widgets/Content.astro b/src/components/widgets/Content.astro
new file mode 100644
index 0000000..694a198
--- /dev/null
+++ b/src/components/widgets/Content.astro
@@ -0,0 +1,94 @@
+---
+import type { Content as Props } from '~/types';
+import Headline from '~/components/ui/Headline.astro';
+import WidgetWrapper from '~/components/ui/WidgetWrapper.astro';
+import Image from '~/components/common/Image.astro';
+import Button from '~/components/ui/Button.astro';
+import ItemGrid from '~/components/ui/ItemGrid.astro';
+
+const {
+ title = await Astro.slots.render('title'),
+ subtitle = await Astro.slots.render('subtitle'),
+ tagline,
+ content = await Astro.slots.render('content'),
+ callToAction,
+ items = [],
+ columns,
+ image = await Astro.slots.render('image'),
+ isReversed = false,
+ isAfterContent = false,
+
+ id,
+ isDark = false,
+ classes = {},
+ bg = await Astro.slots.render('bg'),
+} = Astro.props;
+---
+
+<WidgetWrapper
+ id={id}
+ isDark={isDark}
+ containerClass={`max-w-7xl mx-auto ${isAfterContent ? 'pt-0 md:pt-0 lg:pt-0' : ''} ${classes?.container ?? ''}`}
+ bg={bg}
+>
+ <Headline
+ title={title}
+ subtitle={subtitle}
+ tagline={tagline}
+ 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',
+ }}
+ />
+ <div class="mx-auto max-w-7xl p-4 md:px-8">
+ <div class={`md:flex ${isReversed ? 'md:flex-row-reverse' : ''} md:gap-16`}>
+ <div class="md:basis-1/2 self-center">
+ {content && <div class="mb-12 text-lg dark:text-slate-400" set:html={content} />}
+
+ {
+ callToAction && (
+ <div class="mt-[-40px] mb-8 text-primary">
+ <Button variant="link" {...callToAction} />
+ </div>
+ )
+ }
+
+ <ItemGrid
+ items={items}
+ columns={columns}
+ defaultIcon="tabler:check"
+ classes={{
+ container: `gap-y-4 md:gap-y-8`,
+ panel: 'max-w-none',
+ title: 'text-lg font-medium leading-6 dark:text-white ml-2 rtl:ml-0 rtl:mr-2',
+ description: 'text-muted dark:text-slate-400 ml-2 rtl:ml-0 rtl:mr-2',
+ icon: 'flex h-7 w-7 items-center justify-center rounded-full bg-green-600 dark:bg-green-700 text-gray-50 p-1',
+ action: 'text-lg font-medium leading-6 dark:text-white ml-2 rtl:ml-0 rtl:mr-2',
+ }}
+ />
+ </div>
+ <div aria-hidden="true" class="mt-10 md:mt-0 md:basis-1/2">
+ {
+ image && (
+ <div class="relative m-auto max-w-4xl">
+ {typeof image === 'string' ? (
+ <Fragment set:html={image} />
+ ) : (
+ <Image
+ class="mx-auto w-full rounded-lg bg-gray-500 shadow-lg"
+ width={500}
+ height={500}
+ widths={[400, 768]}
+ sizes="(max-width: 768px) 100vw, 432px"
+ layout="responsive"
+ {...image}
+ />
+ )}
+ </div>
+ )
+ }
+ </div>
+ </div>
+ </div>
+</WidgetWrapper>
diff --git a/src/components/widgets/FAQs.astro b/src/components/widgets/FAQs.astro
new file mode 100644
index 0000000..cba9762
--- /dev/null
+++ b/src/components/widgets/FAQs.astro
@@ -0,0 +1,33 @@
+---
+import Headline from '~/components/ui/Headline.astro';
+import ItemGrid from '~/components/ui/ItemGrid.astro';
+import WidgetWrapper from '~/components/ui/WidgetWrapper.astro';
+import type { Faqs as Props } from '~/types';
+
+const {
+ title = '',
+ subtitle = '',
+ tagline = '',
+ items = [],
+ columns = 2,
+
+ id,
+ isDark = false,
+ classes = {},
+ bg = await Astro.slots.render('bg'),
+} = Astro.props;
+---
+
+<WidgetWrapper id={id} isDark={isDark} containerClass={`max-w-7xl mx-auto ${classes?.container ?? ''}`} bg={bg}>
+ <Headline title={title} subtitle={subtitle} tagline={tagline} />
+ <ItemGrid
+ items={items}
+ columns={columns}
+ defaultIcon="tabler:chevron-right"
+ classes={{
+ container: `${columns === 1 ? 'max-w-4xl' : ''} gap-y-8 md:gap-y-12`,
+ panel: 'max-w-none',
+ icon: 'flex-shrink-0 mt-1 w-6 h-6 text-primary',
+ }}
+ />
+</WidgetWrapper>
diff --git a/src/components/widgets/Features.astro b/src/components/widgets/Features.astro
new file mode 100644
index 0000000..8f42b62
--- /dev/null
+++ b/src/components/widgets/Features.astro
@@ -0,0 +1,36 @@
+---
+import WidgetWrapper from '~/components/ui/WidgetWrapper.astro';
+import ItemGrid from '~/components/ui/ItemGrid.astro';
+import Headline from '~/components/ui/Headline.astro';
+import type { Features as Props } from '~/types';
+
+const {
+ title = await Astro.slots.render('title'),
+ subtitle = await Astro.slots.render('subtitle'),
+ tagline = await Astro.slots.render('tagline'),
+ items = [],
+ columns = 2,
+
+ defaultIcon,
+
+ id,
+ isDark = false,
+ classes = {},
+ bg = await Astro.slots.render('bg'),
+} = Astro.props;
+---
+
+<WidgetWrapper id={id} isDark={isDark} containerClass={`max-w-5xl ${classes?.container ?? ''}`} bg={bg}>
+ <Headline title={title} subtitle={subtitle} tagline={tagline} classes={classes?.headline as Record<string, string>} />
+ <ItemGrid
+ items={items}
+ columns={columns}
+ defaultIcon={defaultIcon}
+ classes={{
+ container: '',
+ title: 'md:text-[1.3rem]',
+ icon: 'text-white bg-primary rounded-full w-10 h-10 p-2 md:w-12 md:h-12 md:p-3 mr-4 rtl:ml-4 rtl:mr-0',
+ ...((classes?.items as Record<string, never>) ?? {}),
+ }}
+ />
+</WidgetWrapper>
diff --git a/src/components/widgets/Features2.astro b/src/components/widgets/Features2.astro
new file mode 100644
index 0000000..282337e
--- /dev/null
+++ b/src/components/widgets/Features2.astro
@@ -0,0 +1,38 @@
+---
+import WidgetWrapper from '~/components/ui/WidgetWrapper.astro';
+import Headline from '~/components/ui/Headline.astro';
+import ItemGrid2 from '~/components/ui/ItemGrid2.astro';
+import type { Features as Props } from '~/types';
+
+const {
+ title = await Astro.slots.render('title'),
+ subtitle = await Astro.slots.render('subtitle'),
+ tagline = await Astro.slots.render('tagline'),
+ items = [],
+ columns = 3,
+ defaultIcon,
+
+ id,
+ isDark = false,
+ classes = {},
+ bg = await Astro.slots.render('bg'),
+} = Astro.props;
+---
+
+<WidgetWrapper id={id} isDark={isDark} containerClass={`max-w-7xl mx-auto ${classes?.container ?? ''}`} bg={bg}>
+ <Headline title={title} subtitle={subtitle} tagline={tagline} classes={classes?.headline as Record<string, string>} />
+ <ItemGrid2
+ items={items}
+ columns={columns}
+ defaultIcon={defaultIcon}
+ classes={{
+ container: 'gap-4 md:gap-6',
+ panel:
+ 'rounded-lg shadow-[0_4px_30px_rgba(0,0,0,0.1)] dark:shadow-[0_4px_30px_rgba(0,0,0,0.1)] backdrop-blur border border-[#ffffff29] bg-white dark:bg-slate-900 p-6',
+ // panel:
+ // "text-center bg-page items-center md:text-left rtl:md:text-right md:items-start p-6 p-6 rounded-md shadow-xl dark:shadow-none dark:border dark:border-slate-800",
+ icon: 'w-12 h-12 mb-6 text-primary',
+ ...((classes?.items as Record<string, never>) ?? {}),
+ }}
+ />
+</WidgetWrapper>
diff --git a/src/components/widgets/Features3.astro b/src/components/widgets/Features3.astro
new file mode 100644
index 0000000..62ab475
--- /dev/null
+++ b/src/components/widgets/Features3.astro
@@ -0,0 +1,70 @@
+---
+import Headline from '~/components/ui/Headline.astro';
+import ItemGrid from '~/components/ui/ItemGrid.astro';
+import WidgetWrapper from '~/components/ui/WidgetWrapper.astro';
+import Image from '~/components/common/Image.astro';
+import type { Features as Props } from '~/types';
+
+const {
+ title = await Astro.slots.render('title'),
+ subtitle = await Astro.slots.render('subtitle'),
+ tagline = await Astro.slots.render('tagline'),
+ image,
+ items = [],
+ columns,
+ defaultIcon,
+ isBeforeContent,
+ isAfterContent,
+
+ id,
+ isDark = false,
+ classes = {},
+ bg = await Astro.slots.render('bg'),
+} = Astro.props;
+---
+
+<WidgetWrapper
+ id={id}
+ isDark={isDark}
+ containerClass={`${isBeforeContent ? 'md:pb-8 lg:pb-12' : ''} ${isAfterContent ? 'pt-0 md:pt-0 lg:pt-0' : ''} ${
+ classes?.container ?? ''
+ }`}
+ bg={bg}
+>
+ <Headline title={title} subtitle={subtitle} tagline={tagline} classes={classes?.headline as Record<string, string>} />
+
+ <div aria-hidden="true" class="aspect-w-16 aspect-h-7">
+ {
+ image && (
+ <div class="w-full h-80 object-cover rounded-xl mx-auto bg-gray-500 shadow-lg">
+ {typeof image === 'string' ? (
+ <Fragment set:html={image} />
+ ) : (
+ <Image
+ class="w-full h-80 object-cover rounded-xl mx-auto bg-gray-500 shadow-lg"
+ width="auto"
+ height={320}
+ widths={[400, 768]}
+ layout="fullWidth"
+ {...image}
+ />
+ )}
+ </div>
+ )
+ }
+ </div>
+
+ <ItemGrid
+ items={items}
+ columns={columns}
+ defaultIcon={defaultIcon}
+ classes={{
+ container: 'mt-12',
+ panel: 'max-w-full sm:max-w-md',
+ title: 'text-lg font-semibold',
+ description: 'mt-0.5',
+ icon: 'flex-shrink-0 mt-1 text-primary w-6 h-6',
+ ...((classes?.items as object) ?? {}),
+ }}
+ />
+</WidgetWrapper>
diff --git a/src/components/widgets/Footer.astro b/src/components/widgets/Footer.astro
new file mode 100644
index 0000000..70cac54
--- /dev/null
+++ b/src/components/widgets/Footer.astro
@@ -0,0 +1,61 @@
+---
+import { Icon } from 'astro-icon/components';
+import { SITE } from 'astrowind:config';
+import { getHomePermalink } from '~/utils/permalinks';
+
+interface Link {
+ text?: string;
+ href?: string;
+ ariaLabel?: string;
+ icon?: string;
+}
+
+interface Links {
+ title?: string;
+ links: Array<Link>;
+}
+
+export interface Props {
+ links: Array<Links>;
+ secondaryLinks: Array<Link>;
+ socialLinks: Array<Link>;
+ footNote?: string;
+ theme?: string;
+}
+
+const { socialLinks = [], secondaryLinks = [], links = [], footNote = '', theme = 'light' } = Astro.props;
+---
+
+<footer class:list={[{ dark: theme === 'dark' }, 'relative border-t border-gray-200 dark:border-slate-800 not-prose']}>
+ <div class="dark:bg-dark absolute inset-0 pointer-events-none" aria-hidden="true"></div>
+ <div
+ class="relative max-w-7xl mx-auto px-4 sm:px-6 dark:text-slate-300 intersect-once intersect-quarter intercept-no-queue motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade"
+ >
+ <div class="md:flex md:items-center md:justify-between py-6 md:py-8">
+ {
+ socialLinks?.length ? (
+ <ul class="flex mb-4 md:order-1 -ml-2 md:ml-4 md:mb-0 rtl:ml-0 rtl:-mr-2 rtl:md:ml-0 rtl:md:mr-4">
+ {socialLinks.map(({ ariaLabel, href, text, icon }) => (
+ <li>
+ <a
+ class="text-muted dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-700 rounded-lg text-sm p-2.5 inline-flex items-center"
+ aria-label={ariaLabel}
+ href={href}
+ >
+ {icon && <Icon name={icon} class="w-5 h-5" />}
+ <Fragment set:html={text} />
+ </a>
+ </li>
+ ))}
+ </ul>
+ ) : (
+ ''
+ )
+ }
+
+ <div class="text-sm mr-4 dark:text-muted">
+ <Fragment set:html={footNote} />
+ </div>
+ </div>
+ </div>
+</footer>
diff --git a/src/components/widgets/Header.astro b/src/components/widgets/Header.astro
new file mode 100644
index 0000000..0064a30
--- /dev/null
+++ b/src/components/widgets/Header.astro
@@ -0,0 +1,14 @@
+---
+import { Icon } from 'astro-icon/components';
+---
+
+<header class="bg-black text-white py-2 px-4">
+ <div class="max-w-7xl mx-auto flex items-center justify-center">
+ <div class="flex items-center space-x-2">
+ <Icon name="tabler:phone" class="w-4 h-4" />
+ <a href="tel:+48790209770" class="text-sm font-medium hover:text-gray-300 transition-colors">
+ +48 790-209-770
+ </a>
+ </div>
+ </div>
+</header>
diff --git a/src/components/widgets/Hero.astro b/src/components/widgets/Hero.astro
new file mode 100644
index 0000000..cbd1154
--- /dev/null
+++ b/src/components/widgets/Hero.astro
@@ -0,0 +1,99 @@
+---
+import Image from '~/components/common/Image.astro';
+import Button from '~/components/ui/Button.astro';
+
+import type { Hero as Props } from '~/types';
+
+const {
+ title = await Astro.slots.render('title'),
+ subtitle = await Astro.slots.render('subtitle'),
+ tagline,
+
+ content = await Astro.slots.render('content'),
+ actions = await Astro.slots.render('actions'),
+ image = await Astro.slots.render('image'),
+
+ id,
+ bg = await Astro.slots.render('bg'),
+} = Astro.props;
+---
+
+<section class="relative md:-mt-[76px] not-prose" {...id ? { id } : {}}>
+ <div class="absolute inset-0 pointer-events-none" aria-hidden="true">
+ <slot name="bg">
+ {bg ? <Fragment set:html={bg} /> : null}
+ </slot>
+ </div>
+ <div class="relative max-w-7xl mx-auto px-4 sm:px-6">
+ <div class="pt-0 md:pt-[76px] pointer-events-none"></div>
+ <div class="py-12 md:py-20">
+ <div class="text-center pb-10 md:pb-16 max-w-5xl mx-auto">
+ {
+ tagline && (
+ <p
+ class="text-base text-secondary dark:text-primary font-bold tracking-wide uppercase intersect-once intersect-quarter motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade"
+ set:html={tagline}
+ />
+ )
+ }
+ {
+ title && (
+ <h1
+ class="text-5xl md:text-6xl font-bold leading-tighter tracking-tighter mb-4 font-heading dark:text-gray-200 intersect-once intersect-quarter motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade"
+ set:html={title}
+ />
+ )
+ }
+ <div class="max-w-3xl mx-auto">
+ {
+ 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"
+ set:html={subtitle}
+ />
+ )
+ }
+ {
+ actions && (
+ <div class="max-w-xs sm:max-w-md m-auto flex flex-nowrap flex-col sm:flex-row sm:justify-center gap-4 intersect-once intersect-quarter motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade">
+ {Array.isArray(actions) ? (
+ actions.map((action) => (
+ <div class="flex w-full sm:w-auto">
+ <Button {...(action || {})} class="w-full sm:mb-0" />
+ </div>
+ ))
+ ) : (
+ <Fragment set:html={actions} />
+ )}
+ </div>
+ )
+ }
+ </div>
+ {content && <Fragment set:html={content} />}
+ </div>
+ <div
+ class="intersect-once intercept-no-queue intersect-quarter motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade"
+ >
+ {
+ image && (
+ <div class="relative m-auto max-w-5xl">
+ {typeof image === 'string' ? (
+ <Fragment set:html={image} />
+ ) : (
+ <Image
+ class="mx-auto rounded-md w-full"
+ widths={[400, 768, 1024, 2040]}
+ sizes="(max-width: 767px) 400px, (max-width: 1023px) 768px, (max-width: 2039px) 1024px, 2040px"
+ loading="eager"
+ width={1024}
+ height={576}
+ {...image}
+ />
+ )}
+ </div>
+ )
+ }
+ </div>
+ </div>
+ </div>
+</section>
diff --git a/src/components/widgets/Hero2.astro b/src/components/widgets/Hero2.astro
new file mode 100644
index 0000000..e92870d
--- /dev/null
+++ b/src/components/widgets/Hero2.astro
@@ -0,0 +1,99 @@
+---
+import Image from '~/components/common/Image.astro';
+import Button from '~/components/ui/Button.astro';
+
+import type { Hero as Props } from '~/types';
+
+const {
+ title = await Astro.slots.render('title'),
+ subtitle = await Astro.slots.render('subtitle'),
+ tagline,
+
+ content = await Astro.slots.render('content'),
+ actions = await Astro.slots.render('actions'),
+ image = await Astro.slots.render('image'),
+
+ id,
+ bg = await Astro.slots.render('bg'),
+} = Astro.props;
+---
+
+<section class="relative md:-mt-[76px] not-prose" {...id ? { id } : {}}>
+ <div class="absolute inset-0 pointer-events-none" aria-hidden="true">
+ <slot name="bg">
+ {bg ? <Fragment set:html={bg} /> : null}
+ </slot>
+ </div>
+ <div class="relative max-w-7xl mx-auto px-4 sm:px-6">
+ <div class="pt-0 md:pt-[76px] pointer-events-none"></div>
+ <div class="py-12 md:py-20 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">
+ {
+ tagline && (
+ <p
+ class="text-base text-secondary dark:text-primary font-bold tracking-wide uppercase intersect-once motion-safe:md:intersect:animate-fade motion-safe:md:opacity-0 intersect-quarter"
+ set:html={tagline}
+ />
+ )
+ }
+ {
+ title && (
+ <h1
+ class="text-5xl md:text-6xl font-bold leading-tighter tracking-tighter mb-4 font-heading dark:text-gray-200 intersect-once motion-safe:md:intersect:animate-fade motion-safe:md:opacity-0 intersect-quarter"
+ set:html={title}
+ />
+ )
+ }
+ <div class="max-w-3xl mx-auto lg:max-w-none">
+ {
+ subtitle && (
+ <p
+ class="text-xl text-muted mb-6 dark:text-slate-300 intersect-once motion-safe:md:intersect:animate-fade motion-safe:md:opacity-0 intersect-quarter"
+ set:html={subtitle}
+ />
+ )
+ }
+
+ {
+ actions && (
+ <div class="max-w-xs sm:max-w-md m-auto flex flex-nowrap flex-col sm:flex-row sm:justify-center gap-4 lg:justify-start lg:m-0 lg:max-w-7xl intersect-once motion-safe:md:intersect:animate-fade motion-safe:md:opacity-0 intersect-quarter">
+ {Array.isArray(actions) ? (
+ actions.map((action) => (
+ <div class="flex w-full sm:w-auto">
+ <Button {...(action || {})} class="w-full sm:mb-0" />
+ </div>
+ ))
+ ) : (
+ <Fragment set:html={actions} />
+ )}
+ </div>
+ )
+ }
+ </div>
+ {content && <Fragment set:html={content} />}
+ </div>
+ <div class="basis-1/2">
+ {
+ image && (
+ <div class="relative m-auto max-w-5xl intersect-once intercept-no-queue motion-safe:md:intersect:animate-fade motion-safe:md:opacity-0 intersect-quarter">
+ {typeof image === 'string' ? (
+ <Fragment set:html={image} />
+ ) : (
+ <Image
+ class="mx-auto rounded-md w-full"
+ widths={[400, 768, 1024, 2040]}
+ sizes="(max-width: 767px) 400px, (max-width: 1023px) 768px, (max-width: 2039px) 1024px, 2040px"
+ loading="eager"
+ width={1200}
+ height={800}
+ aspectRatio="3:2"
+ {...image}
+ />
+ )}
+ </div>
+ )
+ }
+ </div>
+ </div>
+ </div>
+</section>
diff --git a/src/components/widgets/HeroText.astro b/src/components/widgets/HeroText.astro
new file mode 100644
index 0000000..be2a1b6
--- /dev/null
+++ b/src/components/widgets/HeroText.astro
@@ -0,0 +1,86 @@
+---
+import type { CallToAction } from '~/types';
+import Button from '~/components/ui/Button.astro';
+
+export interface Props {
+ title?: string;
+ subtitle?: string;
+ tagline?: string;
+ content?: string;
+ callToAction?: string | CallToAction;
+ callToAction2?: string | CallToAction;
+}
+
+const {
+ title = await Astro.slots.render('title'),
+ subtitle = await Astro.slots.render('subtitle'),
+ tagline,
+ content = await Astro.slots.render('content'),
+ callToAction = await Astro.slots.render('callToAction'),
+ callToAction2 = await Astro.slots.render('callToAction2'),
+} = Astro.props;
+---
+
+<section class="relative md:-mt-[76px] not-prose">
+ <div class="absolute inset-0 pointer-events-none" aria-hidden="true"></div>
+ <div class="relative max-w-7xl mx-auto px-4 sm:px-6">
+ <div class="pt-0 md:pt-[76px] pointer-events-none"></div>
+ <div class="py-12 md:py-20 pb-8 md:pb-8">
+ <div class="text-center max-w-5xl mx-auto">
+ {
+ tagline && (
+ <p
+ class="text-base text-secondary dark:text-primary font-bold tracking-wide uppercase intersect-once intersect-quarter motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade"
+ set:html={tagline}
+ />
+ )
+ }
+ {
+ title && (
+ <h1
+ class="text-5xl md:text-6xl font-bold leading-tighter tracking-tighter mb-4 font-heading dark:text-gray-200 intersect-once intersect-quarter motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade"
+ set:html={title}
+ />
+ )
+ }
+ <div class="max-w-3xl mx-auto">
+ {
+ 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"
+ set:html={subtitle}
+ />
+ )
+ }
+ <div
+ class="max-w-xs sm:max-w-md m-auto flex flex-nowrap flex-col sm:flex-row sm:justify-center gap-4 intersect-once intersect-quarter motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade"
+ >
+ {
+ callToAction && (
+ <div class="flex w-full sm:w-auto">
+ {typeof callToAction === 'string' ? (
+ <Fragment set:html={callToAction} />
+ ) : (
+ <Button variant="primary" {...callToAction} />
+ )}
+ </div>
+ )
+ }
+ {
+ callToAction2 && (
+ <div class="flex w-full sm:w-auto">
+ {typeof callToAction2 === 'string' ? (
+ <Fragment set:html={callToAction2} />
+ ) : (
+ <Button {...callToAction2} />
+ )}
+ </div>
+ )
+ }
+ </div>
+ </div>
+ {content && <Fragment set:html={content} />}
+ </div>
+ </div>
+ </div>
+</section>
diff --git a/src/components/widgets/Note.astro b/src/components/widgets/Note.astro
new file mode 100644
index 0000000..3f43881
--- /dev/null
+++ b/src/components/widgets/Note.astro
@@ -0,0 +1,23 @@
+---
+import { Icon } from 'astro-icon/components';
+
+export interface Props {
+ icon?: string;
+ title?: string;
+ description?: string;
+}
+
+const {
+ icon = 'tabler:info-square',
+ title = await Astro.slots.render('title'),
+ description = await Astro.slots.render('description'),
+} = Astro.props;
+---
+
+<section class="bg-section dark:bg-slate-800 not-prose">
+ <div class="max-w-6xl mx-auto px-4 sm:px-6 py-4 text-md text-center font-medium">
+ <Icon name={icon} class="w-5 h-5 inline-block align-text-bottom font-bold" />
+ <span class="font-bold" set:html={title} />
+ <Fragment set:html={description} />
+ </div>
+</section>
diff --git a/src/components/widgets/Pricing.astro b/src/components/widgets/Pricing.astro
new file mode 100644
index 0000000..3f20b74
--- /dev/null
+++ b/src/components/widgets/Pricing.astro
@@ -0,0 +1,83 @@
+---
+import { Icon } from 'astro-icon/components';
+import Button from '~/components/ui/Button.astro';
+import Headline from '~/components/ui/Headline.astro';
+import WidgetWrapper from '~/components/ui/WidgetWrapper.astro';
+import type { Pricing as Props } from '~/types';
+
+const {
+ title = '',
+ subtitle = '',
+ tagline = '',
+ prices = [],
+
+ id,
+ isDark = false,
+ classes = {},
+ bg = await Astro.slots.render('bg'),
+} = Astro.props;
+---
+
+<WidgetWrapper id={id} isDark={isDark} containerClass={`max-w-7xl mx-auto ${classes?.container ?? ''}`} bg={bg}>
+ <Headline title={title} subtitle={subtitle} tagline={tagline} />
+ <div class="flex items-stretch justify-center">
+ <div class="grid grid-cols-3 gap-4 dark:text-white sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3">
+ {
+ prices &&
+ prices.map(({ title, subtitle, price, period, items, callToAction, hasRibbon = false, ribbonTitle }) => (
+ <div class="col-span-3 mx-auto flex w-full sm:col-span-1 md:col-span-1 lg:col-span-1 xl:col-span-1 intersect-once motion-safe:md:intersect:animate-fade motion-safe:md:opacity-0 intersect-quarter">
+ {price && period && (
+ <div class="rounded-lg backdrop-blur border border-gray-200 dark:border-gray-700 bg-white dark:bg-slate-900 shadow px-6 py-8 flex w-full max-w-sm flex-col justify-between text-center">
+ {hasRibbon && ribbonTitle && (
+ <div class="absolute right-[-5px] 2xl:right-[-8px] rtl:right-auto rtl:left-[-8px] rtl:2xl:left-[-10px] top-[-5px] 2xl:top-[-10px] z-[1] h-[100px] w-[100px] overflow-hidden text-right">
+ <span class="absolute top-[19px] right-[-21px] rtl:right-auto rtl:left-[-21px] block w-full rotate-45 rtl:-rotate-45 bg-green-700 text-center text-[10px] font-bold uppercase leading-5 text-white shadow-[0_3px_10px_-5px_rgba(0,0,0,0.3)] before:absolute before:left-0 before:top-full before:z-[-1] before:border-[3px] before:border-r-transparent before:border-b-transparent before:border-l-green-800 before:border-t-green-800 before:content-[''] after:absolute after:right-0 after:top-full after:z-[-1] after:border-[3px] after:border-l-transparent after:border-b-transparent after:border-r-green-800 after:border-t-green-800 after:content-['']">
+ {ribbonTitle}
+ </span>
+ </div>
+ )}
+ <div class="px-2 py-0">
+ {title && (
+ <h3 class="text-center text-xl font-semibold uppercase leading-6 tracking-wider mb-2">{title}</h3>
+ )}
+ {subtitle && <p class="font-light sm:text-lg text-gray-600 dark:text-slate-400">{subtitle}</p>}
+ <div class="my-8">
+ <div class="flex items-center justify-center text-center mb-1">
+ <span class="text-5xl">$</span>
+ <span class="text-6xl font-extrabold">{price}</span>
+ </div>
+ <span class="text-base leading-6 lowercase text-gray-600 dark:text-slate-400">{period}</span>
+ </div>
+ {items && (
+ <ul class="my-8 md:my-10 space-y-2 text-left">
+ {items.map(
+ ({ description, icon }) =>
+ description && (
+ <li class="mb-1.5 flex items-start space-x-3 leading-7">
+ <div class="rounded-full bg-primary mt-1">
+ <Icon name={icon ? icon : 'tabler:check'} class="w-5 h-5 font-bold p-1 text-white" />
+ </div>
+ <span>{description}</span>
+ </li>
+ )
+ )}
+ </ul>
+ )}
+ </div>
+ {callToAction && (
+ <div class={`flex justify-center`}>
+ {typeof callToAction === 'string' ? (
+ <Fragment set:html={callToAction} />
+ ) : (
+ callToAction &&
+ callToAction.href && <Button {...(hasRibbon ? { variant: 'primary' } : {})} {...callToAction} />
+ )}
+ </div>
+ )}
+ </div>
+ )}
+ </div>
+ ))
+ }
+ </div>
+ </div>
+</WidgetWrapper>
diff --git a/src/components/widgets/Stats.astro b/src/components/widgets/Stats.astro
new file mode 100644
index 0000000..bf76ea0
--- /dev/null
+++ b/src/components/widgets/Stats.astro
@@ -0,0 +1,46 @@
+---
+import type { Stats as Props } from '~/types';
+import WidgetWrapper from '~/components/ui/WidgetWrapper.astro';
+import Headline from '~/components/ui/Headline.astro';
+import { Icon } from 'astro-icon/components';
+
+const {
+ title = await Astro.slots.render('title'),
+ subtitle = await Astro.slots.render('subtitle'),
+ tagline,
+ stats = [],
+
+ id,
+ isDark = false,
+ classes = {},
+ bg = await Astro.slots.render('bg'),
+} = Astro.props;
+---
+
+<WidgetWrapper id={id} isDark={isDark} containerClass={`max-w-6xl mx-auto ${classes?.container ?? ''}`} bg={bg}>
+ <Headline title={title} subtitle={subtitle} tagline={tagline} />
+ <div class="flex flex-wrap justify-center -m-4 text-center">
+ {
+ stats &&
+ stats.map(({ amount, title, icon }) => (
+ <div class="p-4 md:w-1/4 sm:w-1/2 w-full min-w-[220px] text-center md:border-r md:last:border-none dark:md:border-slate-500 intersect-once motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade intersect-quarter">
+ {icon && (
+ <div class="flex items-center justify-center mx-auto mb-4 text-primary">
+ <Icon name={icon} class="w-10 h-10" />
+ </div>
+ )}
+ {amount && (
+ <div class="font-heading text-primary text-[2.6rem] font-bold dark:text-white lg:text-5xl xl:text-6xl">
+ {amount}
+ </div>
+ )}
+ {title && (
+ <div class="text-sm font-medium uppercase tracking-widest text-gray-800 dark:text-slate-400 lg:text-base">
+ {title}
+ </div>
+ )}
+ </div>
+ ))
+ }
+ </div>
+</WidgetWrapper>
diff --git a/src/components/widgets/Steps.astro b/src/components/widgets/Steps.astro
new file mode 100644
index 0000000..3c65bf6
--- /dev/null
+++ b/src/components/widgets/Steps.astro
@@ -0,0 +1,59 @@
+---
+import WidgetWrapper from '~/components/ui/WidgetWrapper.astro';
+import Timeline from '~/components/ui/Timeline.astro';
+import Headline from '~/components/ui/Headline.astro';
+import Image from '~/components/common/Image.astro';
+import type { Steps as Props } from '~/types';
+
+const {
+ title = await Astro.slots.render('title'),
+ subtitle = await Astro.slots.render('subtitle'),
+ tagline = await Astro.slots.render('tagline'),
+ items = [],
+ image = await Astro.slots.render('image'),
+ isReversed = false,
+
+ id,
+ isDark = false,
+ classes = {},
+ bg = await Astro.slots.render('bg'),
+} = Astro.props;
+---
+
+<WidgetWrapper id={id} isDark={isDark} containerClass={`max-w-5xl ${classes?.container ?? ''}`} bg={bg}>
+ <div class:list={['flex flex-col gap-8 md:gap-12', { 'md:flex-row-reverse': isReversed }, { 'md:flex-row': image }]}>
+ <div class:list={['md:py-4 md:self-center', { 'md:basis-1/2': image }, { 'w-full': !image }]}>
+ <Headline
+ title={title}
+ subtitle={subtitle}
+ tagline={tagline}
+ classes={{
+ container: 'text-left rtl:text-right',
+ title: 'text-3xl lg:text-4xl',
+ ...((classes?.headline as object) ?? {}),
+ }}
+ />
+ <Timeline items={items} classes={classes?.items as Record<string, never>} />
+ </div>
+ {
+ image && (
+ <div class="relative md:basis-1/2">
+ {typeof image === 'string' ? (
+ <Fragment set:html={image} />
+ ) : (
+ <Image
+ class="inset-0 object-cover object-top w-full rounded-md shadow-lg md:absolute md:h-full bg-gray-400 dark:bg-slate-700"
+ widths={[400, 768]}
+ sizes="(max-width: 768px) 100vw, 432px"
+ width={432}
+ height={768}
+ layout="cover"
+ src={image?.src}
+ alt={image?.alt || ''}
+ />
+ )}
+ </div>
+ )
+ }
+ </div>
+</WidgetWrapper>
diff --git a/src/components/widgets/Steps2.astro b/src/components/widgets/Steps2.astro
new file mode 100644
index 0000000..0891663
--- /dev/null
+++ b/src/components/widgets/Steps2.astro
@@ -0,0 +1,79 @@
+---
+import { Icon } from 'astro-icon/components';
+import WidgetWrapper from '~/components/ui/WidgetWrapper.astro';
+import Headline from '~/components/ui/Headline.astro';
+import Button from '~/components/ui/Button.astro';
+import type { Steps as Props } from '~/types';
+
+const {
+ title = await Astro.slots.render('title'),
+ subtitle = await Astro.slots.render('subtitle'),
+ tagline,
+ callToAction = await Astro.slots.render('callToAction'),
+ items = [],
+ isReversed = false,
+
+ id,
+ isDark = false,
+ classes = {},
+ bg = await Astro.slots.render('bg'),
+} = Astro.props;
+
+// Function to make email addresses clickable
+function makeEmailsClickable(text: string | undefined): string {
+ if (!text) return '';
+ const emailRegex = /([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/g;
+ return text.replace(emailRegex, '<a href="mailto:$1" class="text-primary hover:text-secondary transition-colors">$1</a>');
+}
+---
+
+<WidgetWrapper id={id} isDark={isDark} containerClass={`max-w-6xl mx-auto ${classes?.container ?? ''}`} bg={bg}>
+ <div class={`flex flex-col gap-8 md:gap-12 md:flex-row ${isReversed ? 'md:flex-row-reverse' : ''}`}>
+ <div class={`w-full lg:w-1/2 gap-8 md:gap-12 ${isReversed ? 'lg:ml-16 md:ml-8 ml-0' : 'lg:mr-16 md:mr-8 mr-0'}`}>
+ <Headline
+ title={title}
+ subtitle={subtitle}
+ tagline={tagline}
+ classes={{
+ container: 'text-center md:text-left rtl:md:text-right mb-4 md:mb-8',
+ title: 'mb-4 text-3xl lg:text-4xl font-bold font-heading',
+ subtitle: 'mb-8 text-xl text-muted dark:text-slate-400',
+ // ...((classes?.headline as {}) ?? {}),
+ }}
+ />
+
+ <div class="w-full text-center md:text-left rtl:md:text-right">
+ {
+ typeof callToAction === 'string' ? (
+ <Fragment set:html={callToAction} />
+ ) : (
+ callToAction &&
+ callToAction.text &&
+ callToAction.href && <Button variant="primary" {...callToAction} class="mb-12 w-auto" />
+ )
+ }
+ </div>
+ </div>
+ <div class="w-full lg:w-1/2 px-0">
+ <ul class="space-y-10">
+ {
+ items && items.length
+ ? items.map(({ title: title2, description, icon }, index) => (
+ <li class="flex md:-mx-4">
+ <div class="pr-4 sm:pl-4 rtl:pr-0 rtl:pl-4 rtl:sm:pl-0 rtl:sm:pr-4">
+ <span class="flex w-16 h-16 mx-auto items-center justify-center text-2xl font-bold rounded-full bg-gray-100 text-primary">
+ {icon ? <Icon name={icon} class="w-6 h-6 icon-bold" /> : index + 1}
+ </span>
+ </div>
+ <div class="pl-4 rtl:pl-0 rtl:pr-4">
+ <h3 class="mb-4 text-xl font-semibold font-heading" set:html={title2} />
+ <p class="text-muted dark:text-gray-400" set:html={description ? makeEmailsClickable(description) : ''} />
+ </div>
+ </li>
+ ))
+ : ''
+ }
+ </ul>
+ </div>
+ </div>
+</WidgetWrapper>
diff --git a/src/components/widgets/Testimonials.astro b/src/components/widgets/Testimonials.astro
new file mode 100644
index 0000000..11db7b5
--- /dev/null
+++ b/src/components/widgets/Testimonials.astro
@@ -0,0 +1,75 @@
+---
+import Headline from '~/components/ui/Headline.astro';
+import WidgetWrapper from '~/components/ui/WidgetWrapper.astro';
+import Button from '~/components/ui/Button.astro';
+import Image from '~/components/common/Image.astro';
+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;
+---
+
+<WidgetWrapper id={id} isDark={isDark} containerClass={`max-w-6xl mx-auto ${classes?.container ?? ''}`} bg={bg}>
+ <Headline title={title} subtitle={subtitle} tagline={tagline} />
+
+ <div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-6">
+ {
+ testimonials &&
+ testimonials.map(({ title, testimonial, name, job, image }) => (
+ <div class="flex h-auto intersect-once motion-safe:md:intersect:animate-fade motion-safe:md:opacity-0 intersect-quarter">
+ <div class="flex flex-col p-4 md:p-6 rounded-md shadow-xl dark:shadow-none dark:border dark:border-slate-600">
+ {title && <h2 class="text-lg font-medium leading-6 pb-4">{title}</h2>}
+ {testimonial && (
+ <blockquote class="flex-auto">
+ <p class="text-muted">" {testimonial} "</p>
+ </blockquote>
+ )}
+
+ <hr class="border-slate-200 dark:border-slate-600 my-4" />
+
+ <div class="flex items-center">
+ {image && (
+ <div class="h-10 w-10 rounded-full border border-slate-200 dark:border-slate-600">
+ {typeof image === 'string' ? (
+ <Fragment set:html={image} />
+ ) : (
+ <Image
+ class="h-10 w-10 rounded-full border border-slate-200 dark:border-slate-600 min-w-full min-h-full"
+ width={40}
+ height={40}
+ widths={[400, 768]}
+ layout="fixed"
+ {...image}
+ />
+ )}
+ </div>
+ )}
+
+ <div class="grow ml-3 rtl:ml-0 rtl:mr-3">
+ {name && <p class="text-base font-semibold">{name}</p>}
+ {job && <p class="text-xs text-muted">{job}</p>}
+ </div>
+ </div>
+ </div>
+ </div>
+ ))
+ }
+ </div>
+ {
+ callToAction && (
+ <div class="flex justify-center mx-auto w-fit mt-8 md:mt-12 font-medium">
+ <Button {...callToAction} />
+ </div>
+ )
+ }
+</WidgetWrapper>