Added a new logo loop + optimized the component setup (DRY)
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m26s

This commit is contained in:
2026-01-29 13:48:37 -07:00
parent 3e89a109ec
commit 9c95362800
14 changed files with 921 additions and 281 deletions

View File

@@ -1,7 +1,7 @@
{ {
"name": "atashdotdev", "name": "atashdotdev",
"type": "module", "type": "module",
"version": "1.2.0", "version": "1.3.0",
"scripts": { "scripts": {
"dev": "astro dev", "dev": "astro dev",
"build": "astro build", "build": "astro build",
@@ -15,7 +15,7 @@
"@fontsource-variable/roboto-slab": "^5.2.8", "@fontsource-variable/roboto-slab": "^5.2.8",
"@heroicons/vue": "^2.2.0", "@heroicons/vue": "^2.2.0",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
"astro": "^5.16.15", "astro": "^5.17.1",
"astro-icon": "^1.1.5", "astro-icon": "^1.1.5",
"nodemailer": "^7.0.12", "nodemailer": "^7.0.12",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",

22
pnpm-lock.yaml generated
View File

@@ -10,10 +10,10 @@ importers:
dependencies: dependencies:
'@astrojs/node': '@astrojs/node':
specifier: ^9.5.2 specifier: ^9.5.2
version: 9.5.2(astro@5.16.15(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.56.0)(typescript@5.7.3)(yaml@2.7.0)) version: 9.5.2(astro@5.17.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.56.0)(typescript@5.7.3)(yaml@2.7.0))
'@astrojs/vue': '@astrojs/vue':
specifier: ^5.1.4 specifier: ^5.1.4
version: 5.1.4(@types/node@25.0.10)(astro@5.16.15(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.56.0)(typescript@5.7.3)(yaml@2.7.0))(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.56.0)(vue@3.5.27(typescript@5.7.3))(yaml@2.7.0) version: 5.1.4(@types/node@25.0.10)(astro@5.17.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.56.0)(typescript@5.7.3)(yaml@2.7.0))(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.56.0)(vue@3.5.27(typescript@5.7.3))(yaml@2.7.0)
'@fontsource-variable/inter': '@fontsource-variable/inter':
specifier: ^5.2.8 specifier: ^5.2.8
version: 5.2.8 version: 5.2.8
@@ -27,8 +27,8 @@ importers:
specifier: ^4.1.18 specifier: ^4.1.18
version: 4.1.18(vite@6.4.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.7.0)) version: 4.1.18(vite@6.4.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.7.0))
astro: astro:
specifier: ^5.16.15 specifier: ^5.17.1
version: 5.16.15(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.56.0)(typescript@5.7.3)(yaml@2.7.0) version: 5.17.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.56.0)(typescript@5.7.3)(yaml@2.7.0)
astro-icon: astro-icon:
specifier: ^1.1.5 specifier: ^1.1.5
version: 1.1.5 version: 1.1.5
@@ -983,8 +983,8 @@ packages:
astro-icon@1.1.5: astro-icon@1.1.5:
resolution: {integrity: sha512-CJYS5nWOw9jz4RpGWmzNQY7D0y2ZZacH7atL2K9DeJXJVaz7/5WrxeyIxO8KASk1jCM96Q4LjRx/F3R+InjJrw==} resolution: {integrity: sha512-CJYS5nWOw9jz4RpGWmzNQY7D0y2ZZacH7atL2K9DeJXJVaz7/5WrxeyIxO8KASk1jCM96Q4LjRx/F3R+InjJrw==}
astro@5.16.15: astro@5.17.1:
resolution: {integrity: sha512-+X1Z0NTi2pa5a0Te6h77Dgc44fYj63j1yx6+39Nvg05lExajxSq7b1Uj/gtY45zoum8fD0+h0nak+DnHighs3A==} resolution: {integrity: sha512-oD3tlxTaVWGq/Wfbqk6gxzVRz98xa/rYlpe+gU2jXJMSD01k6sEDL01ZlT8mVSYB/rMgnvIOfiQQ3BbLdN237A==}
engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0'} engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0'}
hasBin: true hasBin: true
@@ -2509,10 +2509,10 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@astrojs/node@9.5.2(astro@5.16.15(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.56.0)(typescript@5.7.3)(yaml@2.7.0))': '@astrojs/node@9.5.2(astro@5.17.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.56.0)(typescript@5.7.3)(yaml@2.7.0))':
dependencies: dependencies:
'@astrojs/internal-helpers': 0.7.5 '@astrojs/internal-helpers': 0.7.5
astro: 5.16.15(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.56.0)(typescript@5.7.3)(yaml@2.7.0) astro: 5.17.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.56.0)(typescript@5.7.3)(yaml@2.7.0)
send: 1.2.1 send: 1.2.1
server-destroy: 1.0.1 server-destroy: 1.0.1
transitivePeerDependencies: transitivePeerDependencies:
@@ -2534,12 +2534,12 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@astrojs/vue@5.1.4(@types/node@25.0.10)(astro@5.16.15(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.56.0)(typescript@5.7.3)(yaml@2.7.0))(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.56.0)(vue@3.5.27(typescript@5.7.3))(yaml@2.7.0)': '@astrojs/vue@5.1.4(@types/node@25.0.10)(astro@5.17.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.56.0)(typescript@5.7.3)(yaml@2.7.0))(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.56.0)(vue@3.5.27(typescript@5.7.3))(yaml@2.7.0)':
dependencies: dependencies:
'@vitejs/plugin-vue': 5.2.4(vite@6.4.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.7.0))(vue@3.5.27(typescript@5.7.3)) '@vitejs/plugin-vue': 5.2.4(vite@6.4.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.7.0))(vue@3.5.27(typescript@5.7.3))
'@vitejs/plugin-vue-jsx': 4.2.0(vite@6.4.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.7.0))(vue@3.5.27(typescript@5.7.3)) '@vitejs/plugin-vue-jsx': 4.2.0(vite@6.4.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.7.0))(vue@3.5.27(typescript@5.7.3))
'@vue/compiler-sfc': 3.5.27 '@vue/compiler-sfc': 3.5.27
astro: 5.16.15(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.56.0)(typescript@5.7.3)(yaml@2.7.0) astro: 5.17.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.56.0)(typescript@5.7.3)(yaml@2.7.0)
vite: 6.4.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.7.0) vite: 6.4.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.7.0)
vite-plugin-vue-devtools: 7.7.9(rollup@4.56.0)(vite@6.4.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.7.0))(vue@3.5.27(typescript@5.7.3)) vite-plugin-vue-devtools: 7.7.9(rollup@4.56.0)(vite@6.4.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.7.0))(vue@3.5.27(typescript@5.7.3))
vue: 3.5.27(typescript@5.7.3) vue: 3.5.27(typescript@5.7.3)
@@ -3387,7 +3387,7 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
astro@5.16.15(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.56.0)(typescript@5.7.3)(yaml@2.7.0): astro@5.17.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.56.0)(typescript@5.7.3)(yaml@2.7.0):
dependencies: dependencies:
'@astrojs/compiler': 2.13.0 '@astrojs/compiler': 2.13.0
'@astrojs/internal-helpers': 0.7.5 '@astrojs/internal-helpers': 0.7.5

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

443
src/components/LogoLoop.vue Normal file
View File

@@ -0,0 +1,443 @@
<!-- Credit for this to https://vue-bits.dev/animations/logo-loop -->
<template>
<div
ref="containerRef"
:class="rootClasses"
:style="containerStyle"
role="region"
:aria-label="ariaLabel"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
>
<template v-if="fadeOut">
<div
aria-hidden="true"
:class="[
'pointer-events-none absolute inset-y-0 left-0 z-1',
'w-[clamp(24px,8%,120px)]',
'bg-[linear-gradient(to_right,var(--logoloop-fadeColor,var(--logoloop-fadeColorAuto))_0%,rgba(0,0,0,0)_100%)]',
]"
/>
<div
aria-hidden="true"
:class="[
'pointer-events-none absolute inset-y-0 right-0 z-1',
'w-[clamp(24px,8%,120px)]',
'bg-[linear-gradient(to_left,var(--logoloop-fadeColor,var(--logoloop-fadeColorAuto))_0%,rgba(0,0,0,0)_100%)]',
]"
/>
</template>
<div
ref="trackRef"
:class="[
'flex w-max will-change-transform select-none',
'motion-reduce:transform-none',
]"
>
<ul
v-for="copyIndex in copyCount"
:key="`copy-${copyIndex - 1}`"
class="flex items-end"
role="list"
:aria-hidden="copyIndex > 1"
:ref="
(el) => {
if (copyIndex === 1) seqRef = el as HTMLUListElement;
}
"
>
<li
v-for="(item, itemIndex) in logos"
:key="`${copyIndex - 1}-${itemIndex}`"
:class="[
'flex-none mr-(--logoloop-gap) text-(length:--logoloop-logoHeight) leading-none',
scaleOnHover && 'overflow-visible group/item',
]"
role="listitem"
>
<a
v-if="item.href"
:class="[
'inline-flex items-center no-underline rounded',
'transition-opacity duration-200 ease-linear',
'hover:opacity-80',
'focus-visible:outline focus-visible:outline-current focus-visible:outline-offset-2',
]"
:href="item.href"
:aria-label="getItemAriaLabel(item) || 'logo link'"
target="_blank"
rel="noreferrer noopener"
>
<LogoContent
:item="item"
:scale-on-hover="scaleOnHover"
/>
</a>
<LogoContent
v-else
:item="item"
:scale-on-hover="scaleOnHover"
/>
</li>
</ul>
</div>
</div>
</template>
<script setup lang="ts">
import {
computed,
nextTick,
onMounted,
onUnmounted,
ref,
useTemplateRef,
watch,
} from "vue";
export type LogoItem = {
name: string;
logo?: string;
href?: string;
};
export interface LogoLoopProps {
logos: readonly LogoItem[];
speed?: number;
direction?: "left" | "right";
width?: number | string;
logoHeight?: number;
gap?: number;
pauseOnHover?: boolean;
fadeOut?: boolean;
fadeOutColor?: string;
scaleOnHover?: boolean;
ariaLabel?: string;
className?: string;
style?: string;
}
const ANIMATION_CONFIG = {
SMOOTH_TAU: 0.25,
MIN_COPIES: 2,
COPY_HEADROOM: 2,
} as const;
const props = withDefaults(defineProps<LogoLoopProps>(), {
speed: 120,
direction: "left",
width: "100%",
logoHeight: 60,
gap: 32,
pauseOnHover: true,
fadeOut: false,
scaleOnHover: false,
ariaLabel: "Partner logos",
});
const containerRef = useTemplateRef("containerRef");
const trackRef = useTemplateRef("trackRef");
const seqRef = ref<HTMLUListElement | null>(null);
const seqWidth = ref<number>(0);
const copyCount = ref<number>(ANIMATION_CONFIG.MIN_COPIES);
const isHovered = ref<boolean>(false);
let rafRef: number | null = null;
let lastTimestampRef: number | null = null;
const offsetRef = ref(0);
const velocityRef = ref(0);
const targetVelocity = computed(() => {
const magnitude = Math.abs(props.speed);
const directionMultiplier = props.direction === "left" ? 1 : -1;
const speedMultiplier = props.speed < 0 ? -1 : 1;
return magnitude * directionMultiplier * speedMultiplier;
});
const cssVariables = computed(() => ({
"--logoloop-gap": `${props.gap}px`,
"--logoloop-logoHeight": `${props.logoHeight}px`,
...(props.fadeOutColor && { "--logoloop-fadeColor": props.fadeOutColor }),
}));
const rootClasses = computed(() => {
const classes = [
"relative overflow-x-hidden group",
"[--logoloop-gap:32px]",
"[--logoloop-logoHeight:28px]",
"[--logoloop-fadeColorAuto:var(--color-base-100)]",
];
if (props.scaleOnHover) {
classes.push("py-[calc(var(--logoloop-logoHeight)*0.1)]");
}
if (props.className) {
classes.push(props.className);
}
return classes;
});
const containerStyle = computed(() => ({
width: typeof props.width === "number" ? `${props.width}px` : props.width,
...cssVariables.value,
...(typeof props.style === "object" && props.style !== null
? props.style
: {}),
}));
const getItemAriaLabel = (item: LogoItem): string => {
return item.name;
};
const handleMouseEnter = () => {
if (props.pauseOnHover) {
isHovered.value = true;
}
};
const handleMouseLeave = () => {
if (props.pauseOnHover) {
isHovered.value = false;
}
};
const updateDimensions = async () => {
await nextTick();
const containerWidth = containerRef.value?.clientWidth ?? 0;
const sequenceWidth = seqRef.value?.getBoundingClientRect?.()?.width ?? 0;
if (sequenceWidth > 0) {
seqWidth.value = Math.ceil(sequenceWidth);
const copiesNeeded =
Math.ceil(containerWidth / sequenceWidth) +
ANIMATION_CONFIG.COPY_HEADROOM;
copyCount.value = Math.max(ANIMATION_CONFIG.MIN_COPIES, copiesNeeded);
cleanupAnimation?.();
cleanupAnimation = startAnimationLoop();
}
};
let resizeObserver: ResizeObserver | null = null;
const setupResizeObserver = () => {
if (!window.ResizeObserver) {
const handleResize = () => updateDimensions();
window.addEventListener("resize", handleResize);
updateDimensions();
return () => window.removeEventListener("resize", handleResize);
}
resizeObserver = new ResizeObserver(updateDimensions);
if (containerRef.value) {
resizeObserver.observe(containerRef.value);
}
if (seqRef.value) {
resizeObserver.observe(seqRef.value);
}
updateDimensions();
return () => {
resizeObserver?.disconnect();
resizeObserver = null;
};
};
const setupImageLoader = () => {
const images = seqRef.value?.querySelectorAll("img") ?? [];
if (images.length === 0) {
updateDimensions();
return;
}
let remainingImages = images.length;
const handleImageLoad = () => {
remainingImages -= 1;
if (remainingImages === 0) {
updateDimensions();
}
};
images.forEach((img) => {
const htmlImg = img as HTMLImageElement;
if (htmlImg.complete) {
handleImageLoad();
} else {
htmlImg.addEventListener("load", handleImageLoad, { once: true });
htmlImg.addEventListener("error", handleImageLoad, { once: true });
}
});
return () => {
images.forEach((img) => {
img.removeEventListener("load", handleImageLoad);
img.removeEventListener("error", handleImageLoad);
});
};
};
const startAnimationLoop = () => {
const track = trackRef.value;
if (!track) return;
const prefersReduced =
typeof window !== "undefined" &&
window.matchMedia &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (seqWidth.value > 0) {
offsetRef.value =
((offsetRef.value % seqWidth.value) + seqWidth.value) %
seqWidth.value;
track.style.transform = `translate3d(${-offsetRef.value}px, 0, 0)`;
}
if (prefersReduced) {
track.style.transform = "translate3d(0, 0, 0)";
return () => {
lastTimestampRef = null;
};
}
const animate = (timestamp: number) => {
if (lastTimestampRef === null) {
lastTimestampRef = timestamp;
}
const deltaTime = Math.max(0, timestamp - lastTimestampRef) / 1000;
lastTimestampRef = timestamp;
const target =
props.pauseOnHover && isHovered.value ? 0 : targetVelocity.value;
const easingFactor =
1 - Math.exp(-deltaTime / ANIMATION_CONFIG.SMOOTH_TAU);
velocityRef.value += (target - velocityRef.value) * easingFactor;
if (seqWidth.value > 0) {
let nextOffset = offsetRef.value + velocityRef.value * deltaTime;
nextOffset =
((nextOffset % seqWidth.value) + seqWidth.value) %
seqWidth.value;
offsetRef.value = nextOffset;
const translateX = -offsetRef.value;
track.style.transform = `translate3d(${translateX}px, 0, 0)`;
}
rafRef = requestAnimationFrame(animate);
};
rafRef = requestAnimationFrame(animate);
return () => {
if (rafRef !== null) {
cancelAnimationFrame(rafRef);
rafRef = null;
}
lastTimestampRef = null;
};
};
let cleanupResize: (() => void) | undefined;
let cleanupImages: (() => void) | undefined;
let cleanupAnimation: (() => void) | undefined;
const cleanup = () => {
cleanupResize?.();
cleanupImages?.();
cleanupAnimation?.();
};
onMounted(async () => {
await nextTick();
setTimeout(() => {
cleanupResize = setupResizeObserver();
cleanupImages = setupImageLoader();
}, 10);
});
onUnmounted(() => {
cleanup();
});
watch(
[() => props.logos, () => props.gap, () => props.logoHeight],
async () => {
await nextTick();
cleanupImages?.();
cleanupImages = setupImageLoader();
},
{ deep: true },
);
</script>
<script lang="ts">
import { defineComponent, h } from "vue";
const LogoContent = defineComponent({
name: "LogoContent",
props: {
item: {
type: Object as () => LogoItem,
required: true,
},
scaleOnHover: {
type: Boolean,
default: false,
},
},
setup(props) {
return () => {
const containerClasses = [
"flex flex-col items-center justify-end gap-3",
"motion-reduce:transition-none",
];
if (props.scaleOnHover) {
containerClasses.push(
"transition-transform duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] group-hover/item:scale-110",
);
}
const children = [];
if (props.item.logo) {
children.push(
h("img", {
class: [
"h-[var(--logoloop-logoHeight)] w-auto object-contain",
"[-webkit-user-drag:none] pointer-events-none",
"[image-rendering:-webkit-optimize-contrast]",
],
src: props.item.logo,
alt: props.item.name,
loading: "lazy",
decoding: "async",
draggable: false,
}),
);
}
children.push(
h("span", {
class: [
"font-bold text-xl opacity-80 whitespace-nowrap text-base-content",
],
textContent: props.item.name,
}),
);
return h("div", { class: containerClasses }, children);
};
},
});
export { LogoContent };
</script>

View File

@@ -0,0 +1,22 @@
---
interface Props {
items: {
text: string;
className: string;
}[];
}
const { items } = Astro.props;
---
<span class="block w-full my-2">
<span class="text-rotate">
<span class="justify-items-center">
{
items.map((item) => (
<span class={item.className}>{item.text}</span>
))
}
</span>
</span>
</span>

View File

@@ -0,0 +1,66 @@
---
import type { HTMLAttributes } from "astro/types";
interface Props extends HTMLAttributes<"section"> {
title?: string;
description?: string;
containerClass?: string;
fullWidth?: boolean;
background?: string;
}
const {
title,
description,
class: className,
containerClass,
fullWidth = false,
background,
...props
} = Astro.props;
---
<section class:list={["py-20 lg:py-28", background, className]} {...props}>
{
/* Header for Full Width Layouts: We render the header in its own container before the full-width slot */
}
{
fullWidth && title && (
<div class="max-w-7xl mx-auto px-6 mb-16 text-center">
<h2 class="text-3xl lg:text-4xl font-bold text-base-content mb-4">
{title}
</h2>
{description && (
<p class="text-base-content/60 max-w-2xl mx-auto">
{description}
</p>
)}
<slot name="header-content" />
</div>
)
}
{
fullWidth ? (
<slot />
) : (
<div class:list={["max-w-7xl mx-auto px-6", containerClass]}>
{/* Header for Contained Layouts: The header is part of the main container */}
{title && (
<div class="text-center mb-16">
<h2 class="text-3xl lg:text-4xl font-bold text-base-content mb-4">
{title}
</h2>
{description && (
<p class="text-base-content/60 max-w-2xl mx-auto">
{description}
</p>
)}
<slot name="header-content" />
</div>
)}
<slot />
</div>
)
}
</section>

View File

@@ -1,6 +1,7 @@
--- ---
import { Icon } from "astro-icon/components"; import { Icon } from "astro-icon/components";
import { siteConfig } from "../../config/site"; import { siteConfig } from "../../config/site";
import Section from "../Section.astro";
const features = [ const features = [
{ {
@@ -16,34 +17,32 @@ const features = [
...siteConfig.whyUs.cards[2], ...siteConfig.whyUs.cards[2],
}, },
]; ];
const variantStyles: Record<string, { bg: string; text: string }> = {
primary: { bg: "bg-primary/10", text: "text-primary" },
secondary: { bg: "bg-secondary/10", text: "text-secondary" },
accent: { bg: "bg-accent/10", text: "text-accent" },
};
--- ---
<section id="about" class="py-20 lg:py-28"> <Section id="about" title={siteConfig.whyUs.title} background="bg-base-200">
<div class="max-w-7xl mx-auto px-6"> <div class="grid grid-cols-1 md:grid-cols-3 gap-8">
<div class="text-center mb-16"> {
<h2 class="text-3xl lg:text-4xl font-bold text-base-content mb-4"> features.map((feature) => {
{siteConfig.whyUs.title} const styles =
</h2> variantStyles[feature.variant] || variantStyles.primary;
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8"> return (
{ <div class="group text-center p-8 rounded-2xl bg-base-200 border border-base-300/50 hover:border-primary/30 shadow-sm hover:shadow-xl transition-all duration-300 hover:-translate-y-1">
features.map((feature) => (
<div class="group text-center p-8 rounded-2xl bg-base-100 border border-base-300/50 hover:border-primary/30 hover:shadow-xl transition-all duration-300">
<div <div
class={`w-16 h-16 rounded-2xl flex items-center justify-center mx-auto mb-6 group-hover:scale-110 transition-transform duration-300 class:list={[
${feature.variant === "primary" ? "bg-primary/10" : ""} "w-16 h-16 rounded-2xl flex items-center justify-center mx-auto mb-6 group-hover:scale-110 transition-transform duration-300",
${feature.variant === "secondary" ? "bg-secondary/10" : ""} styles.bg,
${feature.variant === "accent" ? "bg-accent/10" : ""} ]}
`}
> >
<Icon <Icon
name={feature.icon} name={feature.icon}
class={`w-8 h-8 class:list={["w-8 h-8", styles.text]}
${feature.variant === "primary" ? "text-primary" : ""}
${feature.variant === "secondary" ? "text-secondary" : ""}
${feature.variant === "accent" ? "text-accent" : ""}
`}
/> />
</div> </div>
<h3 class="text-xl font-bold text-base-content mb-3"> <h3 class="text-xl font-bold text-base-content mb-3">
@@ -53,27 +52,27 @@ const features = [
{feature.content} {feature.content}
</p> </p>
</div> </div>
);
})
}
</div>
<div
class="mt-20 p-8 lg:p-12 rounded-2xl bg-linear-to-br from-neutral to-neutral/90"
>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-8">
{
siteConfig.whyUs.stats.map((stat) => (
<div class="text-center">
<div class="text-3xl lg:text-4xl font-bold text-neutral-content mb-2">
{stat.value}
</div>
<div class="text-neutral-content/60 text-sm font-medium">
{stat.label}
</div>
</div>
)) ))
} }
</div> </div>
<div
class="mt-20 p-8 lg:p-12 rounded-2xl bg-linear-to-br from-neutral to-neutral/90"
>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-8">
{
siteConfig.whyUs.stats.map((stat) => (
<div class="text-center">
<div class="text-3xl lg:text-4xl font-bold text-neutral-content mb-2">
{stat.value}
</div>
<div class="text-neutral-content/60 text-sm font-medium">
{stat.label}
</div>
</div>
))
}
</div>
</div>
</div> </div>
</section> </Section>

View File

@@ -0,0 +1,28 @@
---
import LogoLoop from "../LogoLoop.vue";
import Section from "../Section.astro";
import { siteConfig } from "../../config/site";
---
<Section
id="client-list"
background="bg-base-100"
class="overflow-hidden"
title="People We've Worked With"
fullWidth={true}
>
<div class="relative">
<LogoLoop
client:visible
logos={siteConfig.clients}
speed={40}
direction="left"
logoHeight={128}
gap={160}
pauseOnHover={true}
scaleOnHover={true}
fadeOut={true}
ariaLabel="Scrolling list of client logos"
/>
</div>
</Section>

View File

@@ -1,11 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { ref } from "vue";
import { import {
XCircleIcon, XCircleIcon,
CheckCircleIcon, CheckCircleIcon,
PaperAirplaneIcon, PaperAirplaneIcon,
EnvelopeIcon, EnvelopeIcon,
} from '@heroicons/vue/24/outline'; } from "@heroicons/vue/24/outline";
import { siteConfig } from "../../config/site"; import { siteConfig } from "../../config/site";
const firstName = ref(""); const firstName = ref("");
@@ -19,16 +19,16 @@ const status = ref<"idle" | "sending" | "success" | "error">("idle");
const errorMessage = ref(""); const errorMessage = ref("");
const handleSubmit = async (e: Event) => { const handleSubmit = async (e: Event) => {
status.value = "sending"; status.value = "sending";
errorMessage.value = ""; errorMessage.value = "";
try { try {
const response = await fetch("/api/contact", { const response = await fetch("/api/contact", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
subject: `New Contact Form Message from ${firstName.value} ${lastName.value}`, subject: `New Contact Form Message from ${firstName.value} ${lastName.value}`,
message: `From: ${firstName.value} ${lastName.value} message: `From: ${firstName.value} ${lastName.value}
Email: ${email.value} Email: ${email.value}
Company: ${company.value || "Not specified"} Company: ${company.value || "Not specified"}
Service Needed: ${service.value || "Not specified"} Service Needed: ${service.value || "Not specified"}
@@ -36,199 +36,252 @@ Budget: ${budget.value || "Not specified"}
Project Details: Project Details:
${message.value}`, ${message.value}`,
}), }),
}); });
const data = await response.json(); const data = await response.json();
if (!response.ok) { if (!response.ok) {
throw new Error(data.error || data.message || "Failed to send message"); throw new Error(
data.error || data.message || "Failed to send message",
);
}
status.value = "success";
firstName.value = "";
lastName.value = "";
email.value = "";
company.value = "";
service.value = "";
budget.value = "";
message.value = "";
setTimeout(() => (status.value = "idle"), 3000);
} catch (error) {
status.value = "error";
errorMessage.value =
error instanceof Error ? error.message : "Failed to send message";
console.error("Submission error:", error);
} }
status.value = "success";
firstName.value = "";
lastName.value = "";
email.value = "";
company.value = "";
service.value = "";
budget.value = "";
message.value = "";
setTimeout(() => status.value = "idle", 3000);
} catch (error) {
status.value = "error";
errorMessage.value =
error instanceof Error ? error.message : "Failed to send message";
console.error("Submission error:", error);
}
}; };
</script> </script>
<template> <template>
<section id="contact" class="py-20 lg:py-28 bg-base-200"> <div class="max-w-2xl mx-auto">
<div class="max-w-7xl mx-auto px-6">
<div class="text-center mb-16">
<h2 class="text-3xl lg:text-4xl font-bold text-base-content mb-4">
{{ siteConfig.contact.mainTitle }}
</h2>
</div>
<div class="max-w-2xl mx-auto">
<div class="card bg-base-100 border border-base-300/50 shadow-xl"> <div class="card bg-base-100 border border-base-300/50 shadow-xl">
<div class="card-body p-8 lg:p-10"> <div class="card-body p-8 lg:p-10">
<form class="space-y-6" @submit.prevent="handleSubmit"> <form class="space-y-6" @submit.prevent="handleSubmit">
<div class="grid grid-cols-1 md:grid-cols-2 gap-5"> <div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<fieldset class="fieldset"> <fieldset class="fieldset">
<legend class="fieldset-legend text-sm font-semibold text-base-content"> <legend
{{ siteConfig.contact.form.firstName }} class="fieldset-legend text-sm font-semibold text-base-content"
</legend> >
<input {{ siteConfig.contact.form.firstName }}
type="text" </legend>
name="firstName" <input
class="input input-bordered w-full bg-base-100 focus:border-primary focus:outline-primary" type="text"
required name="firstName"
v-model="firstName" class="input input-bordered w-full bg-base-100 focus:border-primary focus:outline-primary"
:disabled="status === 'sending'" required
:placeholder="siteConfig.contact.form.placeholders.firstName" v-model="firstName"
/> :disabled="status === 'sending'"
</fieldset> :placeholder="
<fieldset class="fieldset"> siteConfig.contact.form.placeholders
<legend class="fieldset-legend text-sm font-semibold text-base-content"> .firstName
{{ siteConfig.contact.form.lastName }} "
</legend> />
<input </fieldset>
type="text" <fieldset class="fieldset">
name="lastName" <legend
class="input input-bordered w-full bg-base-100 focus:border-primary focus:outline-primary" class="fieldset-legend text-sm font-semibold text-base-content"
required >
v-model="lastName" {{ siteConfig.contact.form.lastName }}
:disabled="status === 'sending'" </legend>
:placeholder="siteConfig.contact.form.placeholders.lastName" <input
/> type="text"
</fieldset> name="lastName"
</div> class="input input-bordered w-full bg-base-100 focus:border-primary focus:outline-primary"
required
v-model="lastName"
:disabled="status === 'sending'"
:placeholder="
siteConfig.contact.form.placeholders
.lastName
"
/>
</fieldset>
</div>
<fieldset class="fieldset"> <fieldset class="fieldset">
<legend class="fieldset-legend text-sm font-semibold text-base-content"> <legend
{{ siteConfig.contact.form.email }} class="fieldset-legend text-sm font-semibold text-base-content"
</legend> >
<input {{ siteConfig.contact.form.email }}
type="email" </legend>
name="email" <input
class="input input-bordered w-full bg-base-100 focus:border-primary focus:outline-primary" type="email"
required name="email"
v-model="email" class="input input-bordered w-full bg-base-100 focus:border-primary focus:outline-primary"
:disabled="status === 'sending'" required
:placeholder="siteConfig.contact.form.placeholders.email" v-model="email"
/> :disabled="status === 'sending'"
</fieldset> :placeholder="
siteConfig.contact.form.placeholders.email
"
/>
</fieldset>
<fieldset class="fieldset"> <fieldset class="fieldset">
<legend class="fieldset-legend text-sm font-semibold text-base-content"> <legend
{{ siteConfig.contact.form.company }} class="fieldset-legend text-sm font-semibold text-base-content"
</legend> >
<input {{ siteConfig.contact.form.company }}
type="text" </legend>
name="company" <input
class="input input-bordered w-full bg-base-100 focus:border-primary focus:outline-primary" type="text"
v-model="company" name="company"
:disabled="status === 'sending'" class="input input-bordered w-full bg-base-100 focus:border-primary focus:outline-primary"
:placeholder="siteConfig.contact.form.placeholders.company" v-model="company"
/> :disabled="status === 'sending'"
</fieldset> :placeholder="
siteConfig.contact.form.placeholders.company
"
/>
</fieldset>
<div class="grid grid-cols-1 md:grid-cols-2 gap-5"> <div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<fieldset class="fieldset"> <fieldset class="fieldset">
<legend class="fieldset-legend text-sm font-semibold text-base-content"> <legend
{{ siteConfig.contact.form.service }} class="fieldset-legend text-sm font-semibold text-base-content"
</legend> >
<select {{ siteConfig.contact.form.service }}
name="service" </legend>
aria-label="Service Needed" <select
class="select select-bordered w-full bg-base-100 focus:border-primary focus:outline-primary" name="service"
v-model="service" aria-label="Service Needed"
:disabled="status === 'sending'" class="select select-bordered w-full bg-base-100 focus:border-primary focus:outline-primary"
> v-model="service"
<option value="">{{ siteConfig.contact.form.selectPlaceholders.service }}</option> :disabled="status === 'sending'"
<option v-for="s in siteConfig.contact.form.services" :key="s.value" :value="s.value"> >
{{ s.label }} <option value="">
</option> {{
</select> siteConfig.contact.form
</fieldset> .selectPlaceholders.service
<fieldset class="fieldset"> }}
<legend class="fieldset-legend text-sm font-semibold text-base-content"> </option>
{{ siteConfig.contact.form.budget }} <option
</legend> v-for="s in siteConfig.contact.form
<select .services"
name="budget" :key="s.value"
aria-label="Project Budget" :value="s.value"
class="select select-bordered w-full bg-base-100 focus:border-primary focus:outline-primary" >
v-model="budget" {{ s.label }}
:disabled="status === 'sending'" </option>
> </select>
<option value="">{{ siteConfig.contact.form.selectPlaceholders.budget }}</option> </fieldset>
<option v-for="b in siteConfig.contact.form.budgets" :key="b.value" :value="b.value"> <fieldset class="fieldset">
{{ b.label }} <legend
</option> class="fieldset-legend text-sm font-semibold text-base-content"
</select> >
</fieldset> {{ siteConfig.contact.form.budget }}
</div> </legend>
<select
name="budget"
aria-label="Project Budget"
class="select select-bordered w-full bg-base-100 focus:border-primary focus:outline-primary"
v-model="budget"
:disabled="status === 'sending'"
>
<option value="">
{{
siteConfig.contact.form
.selectPlaceholders.budget
}}
</option>
<option
v-for="b in siteConfig.contact.form.budgets"
:key="b.value"
:value="b.value"
>
{{ b.label }}
</option>
</select>
</fieldset>
</div>
<fieldset class="fieldset"> <fieldset class="fieldset">
<legend class="fieldset-legend text-sm font-semibold text-base-content"> <legend
{{ siteConfig.contact.form.message }} class="fieldset-legend text-sm font-semibold text-base-content"
</legend> >
<textarea {{ siteConfig.contact.form.message }}
id="project-details" </legend>
name="message" <textarea
class="textarea textarea-bordered h-36 w-full resize-none bg-base-100 focus:border-primary focus:outline-primary" id="project-details"
:placeholder="siteConfig.contact.form.placeholders.message" name="message"
required class="textarea textarea-bordered h-36 w-full resize-none bg-base-100 focus:border-primary focus:outline-primary"
v-model="message" :placeholder="
:disabled="status === 'sending'" siteConfig.contact.form.placeholders.message
/> "
</fieldset> required
v-model="message"
:disabled="status === 'sending'"
/>
</fieldset>
<div v-if="status === 'error'" role="alert" class="alert alert-error"> <div
<XCircleIcon class="stroke-current shrink-0 h-5 w-5" /> v-if="status === 'error'"
<span class="text-sm"> role="alert"
{{ errorMessage || siteConfig.contact.form.error }} class="alert alert-error"
</span> >
</div> <XCircleIcon class="stroke-current shrink-0 h-5 w-5" />
<span class="text-sm">
{{ errorMessage || siteConfig.contact.form.error }}
</span>
</div>
<div v-if="status === 'success'" role="alert" class="alert alert-success"> <div
<CheckCircleIcon class="stroke-current shrink-0 h-5 w-5" /> v-if="status === 'success'"
<span class="text-sm">{{ siteConfig.contact.form.success }}</span> role="alert"
</div> class="alert alert-success"
>
<CheckCircleIcon
class="stroke-current shrink-0 h-5 w-5"
/>
<span class="text-sm">{{
siteConfig.contact.form.success
}}</span>
</div>
<button <button
type="submit" type="submit"
class="btn btn-primary btn-lg w-full shadow-lg shadow-primary/25 hover:shadow-xl hover:shadow-primary/30 transition-all duration-300" class="btn btn-primary btn-lg w-full shadow-lg shadow-primary/25 hover:shadow-xl hover:shadow-primary/30 transition-all duration-300"
:disabled="status === 'sending'" :disabled="status === 'sending'"
> >
<template v-if="status === 'sending'"> <template v-if="status === 'sending'">
<span class="loading loading-spinner loading-sm"></span> <span
{{ siteConfig.contact.form.sending }} class="loading loading-spinner loading-sm"
</template> ></span>
<template v-else> {{ siteConfig.contact.form.sending }}
<PaperAirplaneIcon class="w-5 h-5" /> </template>
{{ siteConfig.contact.form.submit }} <template v-else>
</template> <PaperAirplaneIcon class="w-5 h-5" />
</button> {{ siteConfig.contact.form.submit }}
</form> </template>
</div> </button>
</form>
</div>
</div> </div>
<div class="mt-10 text-center"> <div class="mt-10 text-center">
<p class="text-base-content/60 mb-4">{{ siteConfig.contact.direct.text }}</p> <p class="text-base-content/60 mb-4">
<a {{ siteConfig.contact.direct.text }}
:href="`mailto:${siteConfig.contact.direct.email}`" </p>
class="link font-medium inline-flex items-center gap-2 text-base-content hover:text-primary" <a
> :href="`mailto:${siteConfig.contact.direct.email}`"
<EnvelopeIcon class="w-5 h-5" /> class="link font-medium inline-flex items-center gap-2 text-base-content hover:text-primary"
{{ siteConfig.contact.direct.email }} >
</a> <EnvelopeIcon class="w-5 h-5" />
{{ siteConfig.contact.direct.email }}
</a>
</div> </div>
</div>
</div> </div>
</section>
</template> </template>

View File

@@ -1,6 +1,8 @@
--- ---
import { siteConfig } from "../../config/site"; import { siteConfig } from "../../config/site";
import { Icon } from "astro-icon/components"; import { Icon } from "astro-icon/components";
import Section from "../Section.astro";
import RotatingText from "../RotatingText.astro";
import StatusIndicator from "../StatusIndicator.vue"; import StatusIndicator from "../StatusIndicator.vue";
const rotatingText = (siteConfig.hero as any).rotatingText as const rotatingText = (siteConfig.hero as any).rotatingText as
@@ -8,13 +10,17 @@ const rotatingText = (siteConfig.hero as any).rotatingText as
| undefined; | undefined;
--- ---
<section class="relative overflow-hidden bg-neutral"> <Section
fullWidth={true}
background="bg-neutral"
class="relative overflow-hidden"
>
<div <div
class="absolute inset-0 bg-[linear-gradient(to_right,#ffffff15_1px,transparent_1px),linear-gradient(to_bottom,#ffffff15_1px,transparent_1px)] bg-size-[4rem_4rem] mask-[radial-gradient(ellipse_at_center,black_20%,transparent_70%)]" class="absolute inset-0 bg-[linear-gradient(to_right,#ffffff15_1px,transparent_1px),linear-gradient(to_bottom,#ffffff15_1px,transparent_1px)] bg-size-[4rem_4rem] mask-[radial-gradient(ellipse_at_center,black_20%,transparent_70%)]"
> >
</div> </div>
<div class="relative max-w-7xl mx-auto px-6 py-20 lg:py-32"> <div class="relative max-w-7xl mx-auto px-6">
<div class="text-center max-w-4xl mx-auto"> <div class="text-center max-w-4xl mx-auto">
<StatusIndicator client:load /> <StatusIndicator client:load />
@@ -28,19 +34,7 @@ const rotatingText = (siteConfig.hero as any).rotatingText as
<> <>
{part} {part}
{index < array.length - 1 && rotatingText && ( {index < array.length - 1 && rotatingText && (
<span class="block w-full my-2"> <RotatingText items={rotatingText} />
<span class="text-rotate">
<span class="justify-items-center">
{rotatingText.map((item) => (
<span
class={item.className}
>
{item.text}
</span>
))}
</span>
</span>
</span>
)} )}
</> </>
)) ))
@@ -96,4 +90,4 @@ const rotatingText = (siteConfig.hero as any).rotatingText as
</div> </div>
</div> </div>
</div> </div>
</section> </Section>

View File

@@ -1,29 +1,35 @@
--- ---
import { siteConfig } from "../../config/site"; import { siteConfig } from "../../config/site";
import { Icon } from "astro-icon/components"; import { Icon } from "astro-icon/components";
import Section from "../Section.astro";
const variantStyles: Record<string, string> = {
primary: "bg-primary/10 text-primary",
secondary: "bg-secondary/10 text-secondary",
accent: "bg-accent/10 text-accent",
};
--- ---
<section id="services" class="py-20 lg:py-28 bg-base-200"> <Section
<div class="max-w-7xl mx-auto px-6"> id="services"
<div class="text-center mb-16"> background="bg-base-100"
<h2 class="text-3xl lg:text-4xl font-bold text-base-content mb-4"> title={siteConfig.services.title}
{siteConfig.services.title} >
</h2> <div class="grid grid-cols-1 md:grid-cols-2 gap-6 lg:gap-8">
</div> {
siteConfig.services.cards.map((card) => {
const styles =
variantStyles[card.variant] || variantStyles.primary;
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 lg:gap-8"> return (
{
siteConfig.services.cards.map((card, index) => (
<div class="group card bg-base-100 border border-base-300/50 hover:border-primary/30 shadow-sm hover:shadow-xl transition-all duration-300 hover:-translate-y-1"> <div class="group card bg-base-100 border border-base-300/50 hover:border-primary/30 shadow-sm hover:shadow-xl transition-all duration-300 hover:-translate-y-1">
<div class="card-body p-8"> <div class="card-body p-8">
<div class="flex items-start gap-5"> <div class="flex items-start gap-5">
<div <div
class={` class:list={[
shrink-0 w-14 h-14 rounded-xl flex items-center justify-center transition-transform duration-300 group-hover:scale-110 "shrink-0 w-14 h-14 rounded-xl flex items-center justify-center transition-transform duration-300 group-hover:scale-110",
${card.variant === "primary" ? "bg-primary/10 text-primary" : ""} styles,
${card.variant === "secondary" ? "bg-secondary/10 text-secondary" : ""} ]}
${card.variant === "accent" ? "bg-accent/10 text-accent" : ""}
`}
> >
<Icon name={card.icon} class="w-6 h-6" /> <Icon name={card.icon} class="w-6 h-6" />
</div> </div>
@@ -38,8 +44,8 @@ import { Icon } from "astro-icon/components";
</div> </div>
</div> </div>
</div> </div>
)) );
} })
</div> }
</div> </div>
</section> </Section>

View File

@@ -123,6 +123,26 @@ export const siteConfig = {
], ],
}, },
clients: [
{
name: "SAIKYO Softworks",
},
{
name: "Royer Mortgages",
logo: "/clients/RoyerMortgages.webp",
},
{
name: "Marew Consulting",
},
{
name: "Hutch Mortgages",
logo: "/clients/HutchMortgages.webp",
},
{
name: "Rush Home Mortgages",
},
],
contact: { contact: {
title: "Contact Us", title: "Contact Us",
cta: { cta: {

View File

@@ -5,8 +5,10 @@ import Layout from "../layouts/Layout.astro";
import { siteConfig } from "../config/site"; import { siteConfig } from "../config/site";
import HeroSection from "../components/sections/HeroSection.astro"; import HeroSection from "../components/sections/HeroSection.astro";
import ServicesSection from "../components/sections/ServicesSection.astro"; import ServicesSection from "../components/sections/ServicesSection.astro";
import ClientList from "../components/sections/ClientList.astro";
import AboutSection from "../components/sections/AboutSection.astro"; import AboutSection from "../components/sections/AboutSection.astro";
import ContactSection from "../components/sections/ContactSection.vue"; import ContactSection from "../components/sections/ContactSection.vue";
import Section from "../components/Section.astro";
const pageMetaInfo = { const pageMetaInfo = {
title: siteConfig.name, title: siteConfig.name,
@@ -18,5 +20,12 @@ const pageMetaInfo = {
<HeroSection /> <HeroSection />
<ServicesSection /> <ServicesSection />
<AboutSection /> <AboutSection />
<ContactSection client:visible /> <ClientList />
<Section
id="contact"
title={siteConfig.contact.mainTitle}
background="bg-base-200"
>
<ContactSection client:visible />
</Section>
</Layout> </Layout>