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

22
pnpm-lock.yaml generated
View File

@@ -10,10 +10,10 @@ importers:
dependencies:
'@astrojs/node':
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':
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':
specifier: ^5.2.8
version: 5.2.8
@@ -27,8 +27,8 @@ importers:
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))
astro:
specifier: ^5.16.15
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)
specifier: ^5.17.1
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:
specifier: ^1.1.5
version: 1.1.5
@@ -983,8 +983,8 @@ packages:
astro-icon@1.1.5:
resolution: {integrity: sha512-CJYS5nWOw9jz4RpGWmzNQY7D0y2ZZacH7atL2K9DeJXJVaz7/5WrxeyIxO8KASk1jCM96Q4LjRx/F3R+InjJrw==}
astro@5.16.15:
resolution: {integrity: sha512-+X1Z0NTi2pa5a0Te6h77Dgc44fYj63j1yx6+39Nvg05lExajxSq7b1Uj/gtY45zoum8fD0+h0nak+DnHighs3A==}
astro@5.17.1:
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'}
hasBin: true
@@ -2509,10 +2509,10 @@ snapshots:
transitivePeerDependencies:
- 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:
'@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
server-destroy: 1.0.1
transitivePeerDependencies:
@@ -2534,12 +2534,12 @@ snapshots:
transitivePeerDependencies:
- 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:
'@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))
'@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-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)
@@ -3387,7 +3387,7 @@ snapshots:
transitivePeerDependencies:
- 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:
'@astrojs/compiler': 2.13.0
'@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 { siteConfig } from "../../config/site";
import Section from "../Section.astro";
const features = [
{
@@ -16,34 +17,32 @@ const features = [
...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">
<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.whyUs.title}
</h2>
</div>
<Section id="about" title={siteConfig.whyUs.title} background="bg-base-200">
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
{
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">
features.map((feature) => {
const styles =
variantStyles[feature.variant] || variantStyles.primary;
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">
<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
${feature.variant === "primary" ? "bg-primary/10" : ""}
${feature.variant === "secondary" ? "bg-secondary/10" : ""}
${feature.variant === "accent" ? "bg-accent/10" : ""}
`}
class:list={[
"w-16 h-16 rounded-2xl flex items-center justify-center mx-auto mb-6 group-hover:scale-110 transition-transform duration-300",
styles.bg,
]}
>
<Icon
name={feature.icon}
class={`w-8 h-8
${feature.variant === "primary" ? "text-primary" : ""}
${feature.variant === "secondary" ? "text-secondary" : ""}
${feature.variant === "accent" ? "text-accent" : ""}
`}
class:list={["w-8 h-8", styles.text]}
/>
</div>
<h3 class="text-xl font-bold text-base-content mb-3">
@@ -53,7 +52,8 @@ const features = [
{feature.content}
</p>
</div>
))
);
})
}
</div>
@@ -75,5 +75,4 @@ const features = [
}
</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">
import { ref } from 'vue';
import { ref } from "vue";
import {
XCircleIcon,
CheckCircleIcon,
PaperAirplaneIcon,
EnvelopeIcon,
} from '@heroicons/vue/24/outline';
} from "@heroicons/vue/24/outline";
import { siteConfig } from "../../config/site";
const firstName = ref("");
@@ -42,7 +42,9 @@ ${message.value}`,
const data = await response.json();
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";
@@ -53,7 +55,7 @@ ${message.value}`,
service.value = "";
budget.value = "";
message.value = "";
setTimeout(() => status.value = "idle", 3000);
setTimeout(() => (status.value = "idle"), 3000);
} catch (error) {
status.value = "error";
errorMessage.value =
@@ -64,21 +66,15 @@ ${message.value}`,
</script>
<template>
<section id="contact" class="py-20 lg:py-28 bg-base-200">
<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-body p-8 lg:p-10">
<form class="space-y-6" @submit.prevent="handleSubmit">
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<fieldset class="fieldset">
<legend class="fieldset-legend text-sm font-semibold text-base-content">
<legend
class="fieldset-legend text-sm font-semibold text-base-content"
>
{{ siteConfig.contact.form.firstName }}
</legend>
<input
@@ -88,11 +84,16 @@ ${message.value}`,
required
v-model="firstName"
:disabled="status === 'sending'"
:placeholder="siteConfig.contact.form.placeholders.firstName"
:placeholder="
siteConfig.contact.form.placeholders
.firstName
"
/>
</fieldset>
<fieldset class="fieldset">
<legend class="fieldset-legend text-sm font-semibold text-base-content">
<legend
class="fieldset-legend text-sm font-semibold text-base-content"
>
{{ siteConfig.contact.form.lastName }}
</legend>
<input
@@ -102,13 +103,18 @@ ${message.value}`,
required
v-model="lastName"
:disabled="status === 'sending'"
:placeholder="siteConfig.contact.form.placeholders.lastName"
:placeholder="
siteConfig.contact.form.placeholders
.lastName
"
/>
</fieldset>
</div>
<fieldset class="fieldset">
<legend class="fieldset-legend text-sm font-semibold text-base-content">
<legend
class="fieldset-legend text-sm font-semibold text-base-content"
>
{{ siteConfig.contact.form.email }}
</legend>
<input
@@ -118,12 +124,16 @@ ${message.value}`,
required
v-model="email"
:disabled="status === 'sending'"
:placeholder="siteConfig.contact.form.placeholders.email"
:placeholder="
siteConfig.contact.form.placeholders.email
"
/>
</fieldset>
<fieldset class="fieldset">
<legend class="fieldset-legend text-sm font-semibold text-base-content">
<legend
class="fieldset-legend text-sm font-semibold text-base-content"
>
{{ siteConfig.contact.form.company }}
</legend>
<input
@@ -132,13 +142,17 @@ ${message.value}`,
class="input input-bordered w-full bg-base-100 focus:border-primary focus:outline-primary"
v-model="company"
:disabled="status === 'sending'"
:placeholder="siteConfig.contact.form.placeholders.company"
:placeholder="
siteConfig.contact.form.placeholders.company
"
/>
</fieldset>
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<fieldset class="fieldset">
<legend class="fieldset-legend text-sm font-semibold text-base-content">
<legend
class="fieldset-legend text-sm font-semibold text-base-content"
>
{{ siteConfig.contact.form.service }}
</legend>
<select
@@ -148,14 +162,26 @@ ${message.value}`,
v-model="service"
:disabled="status === 'sending'"
>
<option value="">{{ siteConfig.contact.form.selectPlaceholders.service }}</option>
<option v-for="s in siteConfig.contact.form.services" :key="s.value" :value="s.value">
<option value="">
{{
siteConfig.contact.form
.selectPlaceholders.service
}}
</option>
<option
v-for="s in siteConfig.contact.form
.services"
:key="s.value"
:value="s.value"
>
{{ s.label }}
</option>
</select>
</fieldset>
<fieldset class="fieldset">
<legend class="fieldset-legend text-sm font-semibold text-base-content">
<legend
class="fieldset-legend text-sm font-semibold text-base-content"
>
{{ siteConfig.contact.form.budget }}
</legend>
<select
@@ -165,8 +191,17 @@ ${message.value}`,
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">
<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>
@@ -174,30 +209,46 @@ ${message.value}`,
</div>
<fieldset class="fieldset">
<legend class="fieldset-legend text-sm font-semibold text-base-content">
<legend
class="fieldset-legend text-sm font-semibold text-base-content"
>
{{ siteConfig.contact.form.message }}
</legend>
<textarea
id="project-details"
name="message"
class="textarea textarea-bordered h-36 w-full resize-none bg-base-100 focus:border-primary focus:outline-primary"
:placeholder="siteConfig.contact.form.placeholders.message"
:placeholder="
siteConfig.contact.form.placeholders.message
"
required
v-model="message"
:disabled="status === 'sending'"
/>
</fieldset>
<div v-if="status === 'error'" role="alert" class="alert alert-error">
<div
v-if="status === 'error'"
role="alert"
class="alert alert-error"
>
<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">
<CheckCircleIcon class="stroke-current shrink-0 h-5 w-5" />
<span class="text-sm">{{ siteConfig.contact.form.success }}</span>
<div
v-if="status === 'success'"
role="alert"
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
@@ -206,7 +257,9 @@ ${message.value}`,
:disabled="status === 'sending'"
>
<template v-if="status === 'sending'">
<span class="loading loading-spinner loading-sm"></span>
<span
class="loading loading-spinner loading-sm"
></span>
{{ siteConfig.contact.form.sending }}
</template>
<template v-else>
@@ -219,7 +272,9 @@ ${message.value}`,
</div>
<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">
{{ siteConfig.contact.direct.text }}
</p>
<a
:href="`mailto:${siteConfig.contact.direct.email}`"
class="link font-medium inline-flex items-center gap-2 text-base-content hover:text-primary"
@@ -229,6 +284,4 @@ ${message.value}`,
</a>
</div>
</div>
</div>
</section>
</template>

View File

@@ -1,6 +1,8 @@
---
import { siteConfig } from "../../config/site";
import { Icon } from "astro-icon/components";
import Section from "../Section.astro";
import RotatingText from "../RotatingText.astro";
import StatusIndicator from "../StatusIndicator.vue";
const rotatingText = (siteConfig.hero as any).rotatingText as
@@ -8,13 +10,17 @@ const rotatingText = (siteConfig.hero as any).rotatingText as
| undefined;
---
<section class="relative overflow-hidden bg-neutral">
<Section
fullWidth={true}
background="bg-neutral"
class="relative overflow-hidden"
>
<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%)]"
>
</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">
<StatusIndicator client:load />
@@ -28,19 +34,7 @@ const rotatingText = (siteConfig.hero as any).rotatingText as
<>
{part}
{index < array.length - 1 && rotatingText && (
<span class="block w-full my-2">
<span class="text-rotate">
<span class="justify-items-center">
{rotatingText.map((item) => (
<span
class={item.className}
>
{item.text}
</span>
))}
</span>
</span>
</span>
<RotatingText items={rotatingText} />
)}
</>
))
@@ -96,4 +90,4 @@ const rotatingText = (siteConfig.hero as any).rotatingText as
</div>
</div>
</div>
</section>
</Section>

View File

@@ -1,29 +1,35 @@
---
import { siteConfig } from "../../config/site";
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">
<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.services.title}
</h2>
</div>
<Section
id="services"
background="bg-base-100"
title={siteConfig.services.title}
>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 lg:gap-8">
{
siteConfig.services.cards.map((card, index) => (
siteConfig.services.cards.map((card) => {
const styles =
variantStyles[card.variant] || variantStyles.primary;
return (
<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="flex items-start gap-5">
<div
class={`
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" : ""}
${card.variant === "secondary" ? "bg-secondary/10 text-secondary" : ""}
${card.variant === "accent" ? "bg-accent/10 text-accent" : ""}
`}
class:list={[
"shrink-0 w-14 h-14 rounded-xl flex items-center justify-center transition-transform duration-300 group-hover:scale-110",
styles,
]}
>
<Icon name={card.icon} class="w-6 h-6" />
</div>
@@ -38,8 +44,8 @@ import { Icon } from "astro-icon/components";
</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: {
title: "Contact Us",
cta: {

View File

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