Added a new logo loop + optimized the component setup (DRY)
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m26s
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m26s
This commit is contained in:
@@ -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
22
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
BIN
public/clients/HutchMortgages.webp
Normal file
BIN
public/clients/HutchMortgages.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
BIN
public/clients/RoyerMortgages.webp
Normal file
BIN
public/clients/RoyerMortgages.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
443
src/components/LogoLoop.vue
Normal file
443
src/components/LogoLoop.vue
Normal 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>
|
||||
22
src/components/RotatingText.astro
Normal file
22
src/components/RotatingText.astro
Normal 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>
|
||||
66
src/components/Section.astro
Normal file
66
src/components/Section.astro
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
28
src/components/sections/ClientList.astro
Normal file
28
src/components/sections/ClientList.astro
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user