Compare commits
83 Commits
7c31531722
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
e2571880ce
|
|||
|
0eafcb9a67
|
|||
|
9a2e7f65cb
|
|||
|
e5110ddd75
|
|||
|
3b2abe7a99
|
|||
|
4cbe911b0c
|
|||
|
75321034aa
|
|||
|
174afb6a10
|
|||
|
483e80db79
|
|||
|
a303b8be00
|
|||
|
bb0b348069
|
|||
|
cf2195b4f3
|
|||
|
6de9c9c83b
|
|||
|
e3787281fd
|
|||
|
255abd508d
|
|||
|
07561a4335
|
|||
|
22d3b9d7df
|
|||
|
c5fc1cedd7
|
|||
|
cbdab153da
|
|||
|
6dea3ac96c
|
|||
|
8c5556eb3c
|
|||
|
50d8f2a9aa
|
|||
|
e9845965d7
|
|||
|
737a4dd7e8
|
|||
|
c31d0b5589
|
|||
|
58902e081a
|
|||
|
62dcec8202
|
|||
|
b15dce4cd4
|
|||
|
25af507805
|
|||
|
8b8b60b302
|
|||
|
59577f0e58
|
|||
|
8bd4ccbafb
|
|||
|
beaf088391
|
|||
|
f8555413e1
|
|||
|
7c24cf61d5
|
|||
|
f70ce24bcb
|
|||
|
a089c9dfc7
|
|||
|
c03128a314
|
|||
|
cf4a4827df
|
|||
|
f7fd011660
|
|||
|
3b2fca97aa
|
|||
|
1ddd73431b
|
|||
|
332794d62c
|
|||
|
d339e66cf0
|
|||
|
7d731c3857
|
|||
|
6a44f1943e
|
|||
|
6cec6ef02f
|
|||
|
9c95362800
|
|||
|
3e89a109ec
|
|||
|
e146ea311d
|
|||
|
6b51d34490
|
|||
|
58ae6d5d0c
|
|||
|
81723ebdfa
|
|||
|
b0e918a93d
|
|||
|
a0f6e5ad30
|
|||
|
33c3f02412
|
|||
|
6cdd6202da
|
|||
|
0d8d4a8d09
|
|||
|
f5a8a2e5b4
|
|||
|
91e1be00f5
|
|||
|
aad65a3a58
|
|||
|
8242260355
|
|||
|
e1313b7184
|
|||
|
edcafd9355
|
|||
|
a7d8510f93
|
|||
|
ecfc163255
|
|||
|
90cd82d320
|
|||
|
0ca5d4096f
|
|||
|
a33c106785
|
|||
|
f65e5d1c0c
|
|||
|
8341938692
|
|||
|
03792ce1d6
|
|||
|
2630071315
|
|||
|
11c4e61e79
|
|||
|
c1387b9cd7
|
|||
|
6ca843a4f8
|
|||
|
0297d0a8ef
|
|||
|
d3a85945ed
|
|||
|
7517833c93
|
|||
|
79c8e6bba8
|
|||
|
279d70e71e
|
|||
|
95bb381033
|
|||
|
840b51eba5
|
10
.env.example
@@ -1,13 +1,15 @@
|
|||||||
# Container Image
|
# Docker Configuration
|
||||||
IMAGE=atashdotdev:latest
|
IMAGE=atashdotdev:latest
|
||||||
|
|
||||||
# Application Port
|
|
||||||
APP_PORT=4321
|
APP_PORT=4321
|
||||||
|
|
||||||
# SMTP Configuration (required for contact form)
|
# Application Configuration
|
||||||
SMTP_HOST=smtp.example.com
|
SMTP_HOST=smtp.example.com
|
||||||
SMTP_PORT=587
|
SMTP_PORT=587
|
||||||
SMTP_USER=your-email@example.com
|
SMTP_USER=your-email@example.com
|
||||||
SMTP_PASSWORD=your-password
|
SMTP_PASSWORD=your-password
|
||||||
FROM_EMAIL=noreply@atash.dev
|
FROM_EMAIL=noreply@atash.dev
|
||||||
TO_EMAIL=contact@atash.dev
|
TO_EMAIL=contact@atash.dev
|
||||||
|
|
||||||
|
# Site Status
|
||||||
|
STATUS_TEXT="Accepting new clients"
|
||||||
|
STATUS_COLOR="green" # green, yellow, red
|
||||||
|
|||||||
38
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
name: Docker Deploy
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ secrets.REPO_HOST }}
|
||||||
|
username: ${{ github.repository_owner }}
|
||||||
|
password: ${{ secrets.DEPLOY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: linux/amd64
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ secrets.REPO_HOST }}/${{ github.repository_owner }}/${{ github.event.repository.name }}:${{ github.sha }}
|
||||||
|
${{ secrets.REPO_HOST }}/${{ github.repository_owner }}/${{ github.event.repository.name }}:latest
|
||||||
|
provenance: false
|
||||||
|
cache-from: type=registry,ref=${{ secrets.REPO_HOST }}/${{ github.repository_owner }}/${{ github.event.repository.name }}:buildcache
|
||||||
|
cache-to: type=registry,ref=${{ secrets.REPO_HOST }}/${{ github.repository_owner }}/${{ github.event.repository.name }}:buildcache,mode=max
|
||||||
42
.github/workflows/deploy.yml
vendored
@@ -1,42 +0,0 @@
|
|||||||
name: Build and Deploy
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
pull_request:
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-and-push:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install Nix
|
|
||||||
uses: cachix/install-nix-action@v26
|
|
||||||
with:
|
|
||||||
nix_path: nixpkgs=channel:nixos-unstable
|
|
||||||
extra_nix_config: |
|
|
||||||
experimental-features = nix-command flakes
|
|
||||||
|
|
||||||
- name: Build container image
|
|
||||||
run: |
|
|
||||||
nix build --impure --print-build-logs
|
|
||||||
docker load < result
|
|
||||||
|
|
||||||
- name: Login to Container Registry
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ${{ secrets.REPO_HOST }}
|
|
||||||
username: ${{ github.repository_owner }}
|
|
||||||
password: ${{ secrets.DEPLOY_TOKEN }}
|
|
||||||
|
|
||||||
- name: Tag and push images
|
|
||||||
run: |
|
|
||||||
docker tag atashdotdev:latest ${{ secrets.REPO_HOST }}/${{ github.repository_owner }}/${{ github.event.repository.name }}:${{ github.sha }}
|
|
||||||
docker tag atashdotdev:latest ${{ secrets.REPO_HOST }}/${{ github.repository_owner }}/${{ github.event.repository.name }}:latest
|
|
||||||
docker push ${{ secrets.REPO_HOST }}/${{ github.repository_owner }}/${{ github.event.repository.name }}:${{ github.sha }}
|
|
||||||
docker push ${{ secrets.REPO_HOST }}/${{ github.repository_owner }}/${{ github.event.repository.name }}:latest
|
|
||||||
3
.gitignore
vendored
@@ -23,3 +23,6 @@ pnpm-debug.log*
|
|||||||
|
|
||||||
# jetbrains setting folder
|
# jetbrains setting folder
|
||||||
.idea/
|
.idea/
|
||||||
|
|
||||||
|
# nix
|
||||||
|
.direnv/
|
||||||
|
|||||||
27
Dockerfile
@@ -1,27 +1,28 @@
|
|||||||
FROM node:lts-alpine AS builder
|
FROM oven/bun:1.3.9-alpine AS base
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN npm i -g pnpm
|
FROM base AS prod-deps
|
||||||
|
COPY package.json bun.lock ./
|
||||||
|
RUN --mount=type=cache,id=bun,target=/root/.bun/install/cache \
|
||||||
|
bun install --production --frozen-lockfile || bun install --production
|
||||||
|
|
||||||
COPY package.json pnpm-lock.yaml ./
|
FROM base AS builder
|
||||||
|
COPY package.json bun.lock ./
|
||||||
RUN pnpm install
|
RUN --mount=type=cache,id=bun,target=/root/.bun/install/cache \
|
||||||
|
bun install --frozen-lockfile || bun install
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN pnpm run build
|
RUN bun run build
|
||||||
|
|
||||||
FROM node:lts-alpine AS runtime
|
FROM base AS runtime
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN npm i -g pnpm
|
|
||||||
|
|
||||||
COPY --from=builder /app/dist ./dist
|
COPY --from=builder /app/dist ./dist
|
||||||
COPY package.json pnpm-lock.yaml ./
|
COPY --from=prod-deps /app/node_modules ./node_modules
|
||||||
|
COPY package.json ./
|
||||||
RUN pnpm install --prod
|
|
||||||
|
|
||||||
ENV HOST=0.0.0.0
|
ENV HOST=0.0.0.0
|
||||||
ENV PORT=4321
|
ENV PORT=4321
|
||||||
EXPOSE 4321
|
EXPOSE 4321
|
||||||
|
|
||||||
CMD ["node", "./dist/server/entry.mjs"]
|
CMD ["bun", "run", "./dist/server/entry.mjs"]
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
# Atash Website
|
# Atash Website
|
||||||
|
|
||||||
This project requires Nix with Flakes enabled.
|
:)
|
||||||
|
|
||||||
Run ```nix develop``` in order to install dependencies. Then, use pnpm as normal.
|
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
// @ts-check
|
// @ts-check
|
||||||
import { defineConfig } from "astro/config";
|
import { defineConfig } from "astro/config";
|
||||||
|
import vue from "@astrojs/vue";
|
||||||
import solidJs from "@astrojs/solid-js";
|
|
||||||
|
|
||||||
import node from "@astrojs/node";
|
import node from "@astrojs/node";
|
||||||
|
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
|
||||||
// https://astro.build/config
|
const isDev = process.env.NODE_ENV === "development";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
integrations: [solidJs()],
|
output: "server",
|
||||||
|
build: {
|
||||||
|
inlineStylesheets: "auto",
|
||||||
|
},
|
||||||
|
integrations: [vue()],
|
||||||
|
|
||||||
adapter: node({
|
adapter: node({
|
||||||
mode: "standalone",
|
mode: "standalone",
|
||||||
|
|||||||
@@ -13,4 +13,6 @@ services:
|
|||||||
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
|
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
|
||||||
FROM_EMAIL: ${FROM_EMAIL:-noreply@atash.dev}
|
FROM_EMAIL: ${FROM_EMAIL:-noreply@atash.dev}
|
||||||
TO_EMAIL: ${TO_EMAIL:-}
|
TO_EMAIL: ${TO_EMAIL:-}
|
||||||
|
STATUS_TEXT: ${STATUS_TEXT:-"Accepting new clients"}
|
||||||
|
STATUS_COLOR: ${STATUS_COLOR:-green}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
46
flake.lock
generated
@@ -1,59 +1,25 @@
|
|||||||
{
|
{
|
||||||
"nodes": {
|
"nodes": {
|
||||||
"flake-utils": {
|
|
||||||
"inputs": {
|
|
||||||
"systems": "systems"
|
|
||||||
},
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1731533236,
|
|
||||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
|
||||||
"owner": "numtide",
|
|
||||||
"repo": "flake-utils",
|
|
||||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "numtide",
|
|
||||||
"repo": "flake-utils",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1753250450,
|
"lastModified": 1766473571,
|
||||||
"narHash": "sha256-i+CQV2rPmP8wHxj0aq4siYyohHwVlsh40kV89f3nw1s=",
|
"narHash": "sha256-5G1NDO2PulBx1RoaA6U1YoUDX0qZslpPxv+n5GX6Qto=",
|
||||||
"owner": "NixOS",
|
"owner": "nixos",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "fc02ee70efb805d3b2865908a13ddd4474557ecf",
|
"rev": "76701a179d3a98b07653e2b0409847499b2a07d3",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
"owner": "NixOS",
|
"owner": "nixos",
|
||||||
"ref": "nixos-unstable",
|
"ref": "nixos-25.11",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"root": {
|
"root": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"flake-utils": "flake-utils",
|
|
||||||
"nixpkgs": "nixpkgs"
|
"nixpkgs": "nixpkgs"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"systems": {
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1681028828,
|
|
||||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
|
||||||
"owner": "nix-systems",
|
|
||||||
"repo": "default",
|
|
||||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "nix-systems",
|
|
||||||
"repo": "default",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"root": "root",
|
"root": "root",
|
||||||
|
|||||||
91
flake.nix
@@ -1,84 +1,35 @@
|
|||||||
{
|
{
|
||||||
description = "Development environment for atashdotdev with Node and pnpm";
|
description = "atashdotdev dev shell";
|
||||||
|
|
||||||
inputs = {
|
inputs = {
|
||||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
nixpkgs.url = "github:nixos/nixpkgs/nixos-25.11";
|
||||||
flake-utils.url = "github:numtide/flake-utils";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
outputs = { self, nixpkgs, flake-utils }:
|
outputs = { self, nixpkgs }:
|
||||||
flake-utils.lib.eachDefaultSystem (system:
|
|
||||||
let
|
let
|
||||||
pkgs = nixpkgs.legacyPackages.${system};
|
allSystems = [
|
||||||
version = (pkgs.lib.importJSON ./package.json).version;
|
"x86_64-linux"
|
||||||
|
"aarch64-linux"
|
||||||
app = pkgs.stdenv.mkDerivation {
|
"x86_64-darwin"
|
||||||
pname = "atashdotdev";
|
"aarch64-darwin"
|
||||||
inherit version;
|
];
|
||||||
src = ./.;
|
|
||||||
|
|
||||||
nativeBuildInputs = with pkgs; [ nodejs_24 nodePackages.pnpm cacert ];
|
|
||||||
|
|
||||||
buildPhase = ''
|
|
||||||
export HOME=$TMPDIR
|
|
||||||
export SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt
|
|
||||||
pnpm install --frozen-lockfile
|
|
||||||
pnpm build
|
|
||||||
'';
|
|
||||||
|
|
||||||
installPhase = ''
|
|
||||||
mkdir -p $out/lib/app
|
|
||||||
cp -r dist package.json pnpm-lock.yaml $out/lib/app/
|
|
||||||
cd $out/lib/app && pnpm install --prod --frozen-lockfile
|
|
||||||
|
|
||||||
mkdir -p $out/bin
|
|
||||||
echo '#!/bin/sh
|
|
||||||
cd $out/lib/app
|
|
||||||
exec ${pkgs.nodejs_24}/bin/node ./dist/server/entry.mjs "$@"' > $out/bin/atashdotdev
|
|
||||||
chmod +x $out/bin/atashdotdev
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
|
forAllSystems = f: nixpkgs.lib.genAttrs allSystems (system: f {
|
||||||
|
pkgs = import nixpkgs { inherit system; };
|
||||||
|
});
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
# Dev shell
|
devShells = forAllSystems ({ pkgs }: {
|
||||||
devShells.default = pkgs.mkShell {
|
default = pkgs.mkShell {
|
||||||
packages = with pkgs; [ nodejs_24 nodePackages.pnpm ];
|
packages = with pkgs; [
|
||||||
shellHook = ''
|
bun
|
||||||
echo "🚀 atashdotdev development environment loaded!"
|
];
|
||||||
echo "Node version: $(node --version)"
|
|
||||||
echo "pnpm version: $(pnpm --version)"
|
|
||||||
|
|
||||||
if [ ! -d "node_modules" ]; then
|
shellHook = ''
|
||||||
echo "📦 Installing pnpm dependencies..."
|
echo "<atashdotdev dev shell>"
|
||||||
pnpm install --frozen-lockfile
|
echo "Bun version: $(bun --version)"
|
||||||
fi
|
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
# nix run - run the dev server
|
|
||||||
apps.default = {
|
|
||||||
type = "app";
|
|
||||||
program = "${pkgs.writeShellScript "dev" ''
|
|
||||||
export PATH="${pkgs.nodejs_24}/bin:${pkgs.nodePackages.pnpm}/bin:$PATH"
|
|
||||||
if [ ! -d "node_modules" ]; then
|
|
||||||
pnpm install --frozen-lockfile
|
|
||||||
fi
|
|
||||||
pnpm dev
|
|
||||||
''}";
|
|
||||||
};
|
|
||||||
|
|
||||||
# nix build - build the container
|
|
||||||
packages.default = pkgs.dockerTools.buildLayeredImage {
|
|
||||||
name = "atashdotdev";
|
|
||||||
tag = "latest";
|
|
||||||
contents = with pkgs; [ app nodejs_24 bash coreutils ];
|
|
||||||
config = {
|
|
||||||
Cmd = [ "${app}/bin/atashdotdev" ];
|
|
||||||
ExposedPorts."4321/tcp" = {};
|
|
||||||
Env = [ "NODE_ENV=production" "HOST=0.0.0.0" "PORT=4321" ];
|
|
||||||
WorkingDir = "${app}/lib/app";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
23
package.json
@@ -1,26 +1,25 @@
|
|||||||
{
|
{
|
||||||
"name": "atashdotdev",
|
"name": "atashdotdev",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "1.1.1",
|
"version": "2.0.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "astro dev",
|
"dev": "astro dev",
|
||||||
"build": "astro build",
|
"build": "astro build",
|
||||||
"preview": "astro preview",
|
"preview": "astro preview",
|
||||||
"nix:run": "nix develop",
|
|
||||||
"astro": "astro"
|
"astro": "astro"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/node": "^9.4.3",
|
"@astrojs/node": "9.5.4",
|
||||||
"@astrojs/solid-js": "^5.1.0",
|
"@astrojs/vue": "5.1.4",
|
||||||
"@tailwindcss/vite": "^4.1.13",
|
"@tailwindcss/vite": "^4.2.1",
|
||||||
"astro": "^5.13.5",
|
"astro": "5.18.0",
|
||||||
"nodemailer": "^7.0.6",
|
"nodemailer": "^8.0.1",
|
||||||
"solid-js": "^1.9.9",
|
"tailwindcss": "^4.2.1",
|
||||||
"tailwindcss": "^4.1.13"
|
"vue": "^3.5.29"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.3.1",
|
"@types/node": "^25.3.3",
|
||||||
"@types/nodemailer": "^7.0.1",
|
"@types/nodemailer": "^7.0.11",
|
||||||
"daisyui": "^5.1.6"
|
"daisyui": "^5.5.19"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
5126
pnpm-lock.yaml
generated
BIN
public/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
public/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 9.0 KiB |
BIN
public/clients/HutchMortgages.webp
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
public/clients/MarewConsulting.webp
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
public/clients/RoyerMortgages.webp
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
public/clients/SaikyoSoftworks.webp
Normal file
|
After Width: | Height: | Size: 291 KiB |
BIN
public/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 617 B |
BIN
public/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
@@ -1,9 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
|
|
||||||
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
|
|
||||||
<style>
|
|
||||||
path { fill: #000; }
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
path { fill: #FFF; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 749 B |
BIN
public/fonts/roboto.woff2
Normal file
BIN
public/logo.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
5
public/logo.svg
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
public/logo.webp
Normal file
|
After Width: | Height: | Size: 12 KiB |
@@ -2,14 +2,31 @@
|
|||||||
import { siteConfig } from "../config/site";
|
import { siteConfig } from "../config/site";
|
||||||
---
|
---
|
||||||
|
|
||||||
<footer class="bg-base-200 py-8" role="contentinfo">
|
<footer class="bg-neutral text-neutral-content" role="contentinfo">
|
||||||
<div class="max-w-7xl mx-auto px-6">
|
<div class="max-w-7xl mx-auto px-6">
|
||||||
<div class="text-center">
|
<div class="footer footer-center py-10">
|
||||||
<p class="text-base-content/80 font-medium">
|
<aside class="space-y-4">
|
||||||
<span class="sr-only">Copyright</span>
|
<div class="flex items-center gap-2 text-2xl font-bold">
|
||||||
© {new Date().getFullYear()} - All rights reserved by {
|
<span>{siteConfig.name}</span>
|
||||||
siteConfig.name
|
</div>
|
||||||
|
<p class="text-neutral-content/70 max-w-md">
|
||||||
|
{siteConfig.description}
|
||||||
|
</p>
|
||||||
|
<nav class="flex gap-6">
|
||||||
|
{
|
||||||
|
siteConfig.header.nav.map(({ text, href }) => (
|
||||||
|
<a href={href} class="link link-hover text-neutral-content/70 hover:text-neutral-content transition-colors">
|
||||||
|
{text}
|
||||||
|
</a>
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
<div class="border-t border-neutral-content/10 py-6">
|
||||||
|
<p class="text-center text-neutral-content/60 text-sm">
|
||||||
|
<span class="sr-only">{siteConfig.footer.copyright}</span>
|
||||||
|
© {new Date().getFullYear()} {siteConfig.name}. {siteConfig.footer.rights}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
314
src/components/FuzzyText.vue
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
<!-- Credit for this to https://vue-bits.dev/text-animations/fuzzy-text -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, onUnmounted, watch, nextTick, useTemplateRef } from "vue";
|
||||||
|
|
||||||
|
interface FuzzyTextProps {
|
||||||
|
text: string;
|
||||||
|
fontSize?: number | string;
|
||||||
|
fontWeight?: string | number;
|
||||||
|
fontFamily?: string;
|
||||||
|
color?: string;
|
||||||
|
enableHover?: boolean;
|
||||||
|
baseIntensity?: number;
|
||||||
|
hoverIntensity?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<FuzzyTextProps>(), {
|
||||||
|
text: "",
|
||||||
|
fontSize: "clamp(2rem, 8vw, 8rem)",
|
||||||
|
fontWeight: 900,
|
||||||
|
fontFamily: "inherit",
|
||||||
|
color: "#fff",
|
||||||
|
enableHover: true,
|
||||||
|
baseIntensity: 0.18,
|
||||||
|
hoverIntensity: 0.5,
|
||||||
|
});
|
||||||
|
|
||||||
|
const canvasRef = useTemplateRef<HTMLCanvasElement>("canvasRef");
|
||||||
|
let animationFrameId: number;
|
||||||
|
let isCancelled = false;
|
||||||
|
let cleanup: (() => void) | null = null;
|
||||||
|
|
||||||
|
const waitForFont = async (
|
||||||
|
fontFamily: string,
|
||||||
|
fontWeight: string | number,
|
||||||
|
fontSize: string,
|
||||||
|
): Promise<boolean> => {
|
||||||
|
if (document.fonts?.check) {
|
||||||
|
const fontString = `${fontWeight} ${fontSize} ${fontFamily}`;
|
||||||
|
|
||||||
|
if (document.fonts.check(fontString)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await document.fonts.load(fontString);
|
||||||
|
return document.fonts.check(fontString);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Font loading failed:", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) {
|
||||||
|
resolve(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.font = `${fontWeight} ${fontSize} ${fontFamily}`;
|
||||||
|
const testWidth = ctx.measureText("M").width;
|
||||||
|
|
||||||
|
let attempts = 0;
|
||||||
|
const checkFont = () => {
|
||||||
|
ctx.font = `${fontWeight} ${fontSize} ${fontFamily}`;
|
||||||
|
const newWidth = ctx.measureText("M").width;
|
||||||
|
|
||||||
|
if (newWidth !== testWidth && newWidth > 0) {
|
||||||
|
resolve(true);
|
||||||
|
} else if (attempts < 20) {
|
||||||
|
attempts++;
|
||||||
|
setTimeout(checkFont, 50);
|
||||||
|
} else {
|
||||||
|
resolve(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setTimeout(checkFont, 10);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const initCanvas = async () => {
|
||||||
|
if (document.fonts?.ready) {
|
||||||
|
await document.fonts.ready;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCancelled) return;
|
||||||
|
|
||||||
|
const canvas = canvasRef.value;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const computedFontFamily =
|
||||||
|
props.fontFamily === "inherit"
|
||||||
|
? window.getComputedStyle(canvas).fontFamily || "sans-serif"
|
||||||
|
: props.fontFamily;
|
||||||
|
|
||||||
|
const fontSizeStr =
|
||||||
|
typeof props.fontSize === "number"
|
||||||
|
? `${props.fontSize}px`
|
||||||
|
: props.fontSize;
|
||||||
|
let numericFontSize: number;
|
||||||
|
|
||||||
|
if (typeof props.fontSize === "number") {
|
||||||
|
numericFontSize = props.fontSize;
|
||||||
|
} else {
|
||||||
|
const temp = document.createElement("span");
|
||||||
|
temp.style.fontSize = props.fontSize;
|
||||||
|
temp.style.fontFamily = computedFontFamily;
|
||||||
|
document.body.appendChild(temp);
|
||||||
|
const computedSize = window.getComputedStyle(temp).fontSize;
|
||||||
|
numericFontSize = parseFloat(computedSize);
|
||||||
|
document.body.removeChild(temp);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fontLoaded = await waitForFont(
|
||||||
|
computedFontFamily,
|
||||||
|
props.fontWeight,
|
||||||
|
fontSizeStr,
|
||||||
|
);
|
||||||
|
if (!fontLoaded) {
|
||||||
|
console.warn(`Font not loaded: ${computedFontFamily}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = props.text;
|
||||||
|
|
||||||
|
const offscreen = document.createElement("canvas");
|
||||||
|
const offCtx = offscreen.getContext("2d");
|
||||||
|
if (!offCtx) return;
|
||||||
|
|
||||||
|
const fontString = `${props.fontWeight} ${fontSizeStr} ${computedFontFamily}`;
|
||||||
|
offCtx.font = fontString;
|
||||||
|
|
||||||
|
const testMetrics = offCtx.measureText("M");
|
||||||
|
if (testMetrics.width === 0) {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!isCancelled) {
|
||||||
|
initCanvas();
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
offCtx.textBaseline = "alphabetic";
|
||||||
|
const metrics = offCtx.measureText(text);
|
||||||
|
|
||||||
|
const actualLeft = metrics.actualBoundingBoxLeft ?? 0;
|
||||||
|
const actualRight = metrics.actualBoundingBoxRight ?? metrics.width;
|
||||||
|
const actualAscent = metrics.actualBoundingBoxAscent ?? numericFontSize;
|
||||||
|
const actualDescent =
|
||||||
|
metrics.actualBoundingBoxDescent ?? numericFontSize * 0.2;
|
||||||
|
|
||||||
|
const textBoundingWidth = Math.ceil(actualLeft + actualRight);
|
||||||
|
const tightHeight = Math.ceil(actualAscent + actualDescent);
|
||||||
|
|
||||||
|
const extraWidthBuffer = 10;
|
||||||
|
const offscreenWidth = textBoundingWidth + extraWidthBuffer;
|
||||||
|
|
||||||
|
offscreen.width = offscreenWidth;
|
||||||
|
offscreen.height = tightHeight;
|
||||||
|
|
||||||
|
const xOffset = extraWidthBuffer / 2;
|
||||||
|
offCtx.font = `${props.fontWeight} ${fontSizeStr} ${computedFontFamily}`;
|
||||||
|
offCtx.textBaseline = "alphabetic";
|
||||||
|
offCtx.fillStyle = props.color;
|
||||||
|
offCtx.fillText(text, xOffset - actualLeft, actualAscent);
|
||||||
|
|
||||||
|
const horizontalMargin = 50;
|
||||||
|
const verticalMargin = 0;
|
||||||
|
canvas.width = offscreenWidth + horizontalMargin * 2;
|
||||||
|
canvas.height = tightHeight + verticalMargin * 2;
|
||||||
|
ctx.translate(horizontalMargin, verticalMargin);
|
||||||
|
|
||||||
|
const interactiveLeft = horizontalMargin + xOffset;
|
||||||
|
const interactiveTop = verticalMargin;
|
||||||
|
const interactiveRight = interactiveLeft + textBoundingWidth;
|
||||||
|
const interactiveBottom = interactiveTop + tightHeight;
|
||||||
|
|
||||||
|
let isHovering = false;
|
||||||
|
const fuzzRange = 30;
|
||||||
|
|
||||||
|
const run = () => {
|
||||||
|
if (isCancelled) return;
|
||||||
|
ctx.clearRect(
|
||||||
|
-fuzzRange,
|
||||||
|
-fuzzRange,
|
||||||
|
offscreenWidth + 2 * fuzzRange,
|
||||||
|
tightHeight + 2 * fuzzRange,
|
||||||
|
);
|
||||||
|
const intensity = isHovering
|
||||||
|
? props.hoverIntensity
|
||||||
|
: props.baseIntensity;
|
||||||
|
for (let j = 0; j < tightHeight; j++) {
|
||||||
|
const dx = Math.floor(
|
||||||
|
intensity * (Math.random() - 0.5) * fuzzRange,
|
||||||
|
);
|
||||||
|
ctx.drawImage(
|
||||||
|
offscreen,
|
||||||
|
0,
|
||||||
|
j,
|
||||||
|
offscreenWidth,
|
||||||
|
1,
|
||||||
|
dx,
|
||||||
|
j,
|
||||||
|
offscreenWidth,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
animationFrameId = window.requestAnimationFrame(run);
|
||||||
|
};
|
||||||
|
|
||||||
|
run();
|
||||||
|
|
||||||
|
const isInsideTextArea = (x: number, y: number) =>
|
||||||
|
x >= interactiveLeft &&
|
||||||
|
x <= interactiveRight &&
|
||||||
|
y >= interactiveTop &&
|
||||||
|
y <= interactiveBottom;
|
||||||
|
|
||||||
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
|
if (!props.enableHover) return;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const x = e.clientX - rect.left;
|
||||||
|
const y = e.clientY - rect.top;
|
||||||
|
isHovering = isInsideTextArea(x, y);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseLeave = () => {
|
||||||
|
isHovering = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchMove = (e: TouchEvent) => {
|
||||||
|
if (!props.enableHover) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const touch = e.touches[0];
|
||||||
|
const x = touch.clientX - rect.left;
|
||||||
|
const y = touch.clientY - rect.top;
|
||||||
|
isHovering = isInsideTextArea(x, y);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchEnd = () => {
|
||||||
|
isHovering = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (props.enableHover) {
|
||||||
|
canvas.addEventListener("mousemove", handleMouseMove);
|
||||||
|
canvas.addEventListener("mouseleave", handleMouseLeave);
|
||||||
|
canvas.addEventListener("touchmove", handleTouchMove, {
|
||||||
|
passive: false,
|
||||||
|
});
|
||||||
|
canvas.addEventListener("touchend", handleTouchEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup = () => {
|
||||||
|
window.cancelAnimationFrame(animationFrameId);
|
||||||
|
if (props.enableHover) {
|
||||||
|
canvas.removeEventListener("mousemove", handleMouseMove);
|
||||||
|
canvas.removeEventListener("mouseleave", handleMouseLeave);
|
||||||
|
canvas.removeEventListener("touchmove", handleTouchMove);
|
||||||
|
canvas.removeEventListener("touchend", handleTouchEnd);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
nextTick(() => {
|
||||||
|
initCanvas();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
isCancelled = true;
|
||||||
|
if (animationFrameId) {
|
||||||
|
window.cancelAnimationFrame(animationFrameId);
|
||||||
|
}
|
||||||
|
if (cleanup) {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[
|
||||||
|
() => props.text,
|
||||||
|
() => props.fontSize,
|
||||||
|
() => props.fontWeight,
|
||||||
|
() => props.fontFamily,
|
||||||
|
() => props.color,
|
||||||
|
() => props.enableHover,
|
||||||
|
() => props.baseIntensity,
|
||||||
|
() => props.hoverIntensity,
|
||||||
|
],
|
||||||
|
() => {
|
||||||
|
isCancelled = true;
|
||||||
|
if (animationFrameId) {
|
||||||
|
window.cancelAnimationFrame(animationFrameId);
|
||||||
|
}
|
||||||
|
if (cleanup) {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
isCancelled = false;
|
||||||
|
nextTick(() => {
|
||||||
|
initCanvas();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<canvas ref="canvasRef" />
|
||||||
|
</template>
|
||||||
@@ -1,35 +1,36 @@
|
|||||||
---
|
---
|
||||||
import { siteConfig } from "../config/site";
|
import { siteConfig } from "../config/site";
|
||||||
|
import Icon from "./Icon.astro";
|
||||||
|
import { Image } from "astro:assets";
|
||||||
---
|
---
|
||||||
|
|
||||||
<header class="navbar bg-base-100 shadow-lg" role="banner">
|
<header
|
||||||
|
class="sticky top-0 z-50 backdrop-blur-lg bg-base-100/80 border-b border-base-200"
|
||||||
|
role="banner"
|
||||||
|
>
|
||||||
|
<div class="navbar max-w-7xl mx-auto px-4 lg:px-6">
|
||||||
<div class="navbar-start">
|
<div class="navbar-start">
|
||||||
<div class="dropdown">
|
<div class="dropdown">
|
||||||
<div tabindex="0" role="button" class="btn btn-ghost lg:hidden">
|
<div
|
||||||
<svg
|
tabindex="0"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
role="button"
|
||||||
class="h-5 w-5"
|
class="btn btn-ghost btn-circle lg:hidden"
|
||||||
fill="none"
|
aria-label="Open menu"
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
>
|
||||||
<path
|
<Icon name="bars-3-bottom-left" class="h-5 w-5" />
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M4 6h16M4 12h8m-8 6h16"></path>
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
<ul
|
<ul
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
class="menu menu-sm dropdown-content bg-base-100 rounded-box z-[1] mt-3 w-52 p-2 shadow"
|
class="menu menu-sm dropdown-content bg-base-100 rounded-xl z-50 mt-3 w-56 p-3 shadow-xl border border-base-200"
|
||||||
role="menu"
|
aria-label="Navigation Button"
|
||||||
>
|
>
|
||||||
{
|
{
|
||||||
siteConfig.header.nav.map(({ text, href }) => (
|
siteConfig.header.nav.map(({ text, href }) => (
|
||||||
<li role="none">
|
<li>
|
||||||
<a href={href} role="menuitem">
|
<a
|
||||||
|
href={href}
|
||||||
|
class="py-3 px-4 rounded-lg font-medium hover:bg-primary/10 hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
{text}
|
{text}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
@@ -39,18 +40,33 @@ import { siteConfig } from "../config/site";
|
|||||||
</div>
|
</div>
|
||||||
<a
|
<a
|
||||||
href={siteConfig.header.logo.href}
|
href={siteConfig.header.logo.href}
|
||||||
class="btn btn-ghost text-xl"
|
class="btn btn-ghost text-xl font-bold tracking-tight hover:bg-transparent hover:text-primary transition-colors gap-2"
|
||||||
aria-label="Home"
|
aria-label="Home"
|
||||||
>
|
>
|
||||||
{siteConfig.header.logo.text}
|
<Image
|
||||||
|
src="/logo.svg"
|
||||||
|
alt=""
|
||||||
|
width="32"
|
||||||
|
height="32"
|
||||||
|
class="h-8 w-8"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span class="hidden sm:inline"
|
||||||
|
>{siteConfig.header.logo.text}</span
|
||||||
|
>
|
||||||
|
<span class="sm:hidden">{siteConfig.header.mobileLogoText}</span
|
||||||
|
>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<nav class="navbar-center hidden lg:flex" aria-label="Main navigation">
|
<nav class="navbar-center hidden lg:flex" aria-label="Main navigation">
|
||||||
<ul class="menu menu-horizontal px-1" role="menubar">
|
<ul class="menu menu-horizontal gap-1">
|
||||||
{
|
{
|
||||||
siteConfig.header.nav.map(({ text, href }) => (
|
siteConfig.header.nav.map(({ text, href }) => (
|
||||||
<li role="none">
|
<li>
|
||||||
<a href={href} role="menuitem">
|
<a
|
||||||
|
href={href}
|
||||||
|
class="font-medium px-4 py-2 rounded-lg hover:bg-primary/10 hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
{text}
|
{text}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
@@ -61,10 +77,11 @@ import { siteConfig } from "../config/site";
|
|||||||
<div class="navbar-end">
|
<div class="navbar-end">
|
||||||
<a
|
<a
|
||||||
href={siteConfig.header.cta.href}
|
href={siteConfig.header.cta.href}
|
||||||
class="btn btn-primary"
|
class="btn btn-primary btn-sm lg:btn-md shadow-md hover:shadow-lg transition-all"
|
||||||
role="button"
|
aria-label={siteConfig.header.cta.text}
|
||||||
>
|
>
|
||||||
{siteConfig.header.cta.text}
|
{siteConfig.header.cta.text}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
27
src/components/Icon.astro
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
import { icons, type IconName } from "../config/icons";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
name: IconName;
|
||||||
|
class?: string;
|
||||||
|
"class:list"?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name, class: className, "class:list": classList } = Astro.props;
|
||||||
|
const svg = icons[name];
|
||||||
|
|
||||||
|
if (!svg) {
|
||||||
|
throw new Error(`Icon "${name}" not found in icon registry`);
|
||||||
|
}
|
||||||
|
---
|
||||||
|
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="1em"
|
||||||
|
height="1em"
|
||||||
|
fill="none"
|
||||||
|
class:list={[className, classList]}
|
||||||
|
aria-hidden="true"
|
||||||
|
set:html={svg}
|
||||||
|
/>
|
||||||
30
src/components/Icon.vue
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { icons, type IconName } from "../config/icons";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
name: IconName;
|
||||||
|
class?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const svg = computed(() => {
|
||||||
|
const content = icons[props.name];
|
||||||
|
if (!content) {
|
||||||
|
console.error(`Icon "${props.name}" not found in icon registry`);
|
||||||
|
}
|
||||||
|
return content;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="1em"
|
||||||
|
height="1em"
|
||||||
|
fill="none"
|
||||||
|
:class="props.class"
|
||||||
|
aria-hidden="true"
|
||||||
|
v-html="svg"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
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>
|
||||||
33
src/components/RotatingText.vue
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<template>
|
||||||
|
<span class="block w-full my-2">
|
||||||
|
<span class="text-rotate">
|
||||||
|
<span class="justify-items-center">
|
||||||
|
<span
|
||||||
|
v-for="(item, index) in items"
|
||||||
|
:key="index"
|
||||||
|
:class="item.className"
|
||||||
|
>
|
||||||
|
{{ item.text }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface RotatingTextItem {
|
||||||
|
text: string;
|
||||||
|
className: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
items: RotatingTextItem[];
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.text-rotate:hover,
|
||||||
|
.text-rotate:hover * {
|
||||||
|
animation-play-state: running !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
62
src/components/Section.astro
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
---
|
||||||
|
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}>
|
||||||
|
{
|
||||||
|
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]}>
|
||||||
|
{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>
|
||||||
69
src/components/StatusIndicator.vue
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onUnmounted } from "vue";
|
||||||
|
|
||||||
|
type StatusColor = "green" | "yellow" | "red";
|
||||||
|
|
||||||
|
interface StatusResponse {
|
||||||
|
text: string;
|
||||||
|
color: StatusColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
const colorClasses: Record<StatusColor, string> = {
|
||||||
|
green: "bg-success",
|
||||||
|
yellow: "bg-warning",
|
||||||
|
red: "bg-error",
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusText = ref("Accepting new clients");
|
||||||
|
const statusColor = ref<StatusColor>("green");
|
||||||
|
const isLoaded = ref(false);
|
||||||
|
|
||||||
|
let controller: AbortController | null = null;
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
controller = new AbortController();
|
||||||
|
|
||||||
|
const fetchStatus = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/status", {
|
||||||
|
signal: controller?.signal,
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
const data: StatusResponse = await response.json();
|
||||||
|
statusText.value = data.text;
|
||||||
|
statusColor.value = data.color;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.name !== "AbortError") {
|
||||||
|
console.error("Failed to fetch status:", error);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isLoaded.value = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchStatus();
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (controller) {
|
||||||
|
controller.abort();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
:class="`inline-flex items-center gap-2 bg-white/10 text-white px-4 py-2 rounded-full text-sm font-medium mb-8 border border-white/10 transition-opacity duration-300 ${isLoaded ? 'opacity-100' : 'opacity-0'}`"
|
||||||
|
>
|
||||||
|
<span class="relative flex h-2 w-2">
|
||||||
|
<span
|
||||||
|
:class="`animate-ping absolute inline-flex h-full w-full rounded-full ${colorClasses[statusColor]} opacity-75`"
|
||||||
|
></span>
|
||||||
|
<span
|
||||||
|
:class="`relative inline-flex rounded-full h-2 w-2 ${colorClasses[statusColor]}`"
|
||||||
|
></span>
|
||||||
|
</span>
|
||||||
|
{{ statusText }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,115 +1,84 @@
|
|||||||
<section id="about" class="py-16 lg:py-24">
|
---
|
||||||
<div class="max-w-7xl mx-auto px-6">
|
import Icon from "../Icon.astro";
|
||||||
<div class="hero bg-base-200 rounded-2xl shadow-lg">
|
import { type IconName } from "../../config/icons";
|
||||||
<div class="hero-content text-center py-12 lg:py-16 px-6">
|
import { siteConfig } from "../../config/site";
|
||||||
<div class="max-w-4xl w-full space-y-8">
|
import Section from "../Section.astro";
|
||||||
<div class="space-y-4">
|
|
||||||
|
const features: {
|
||||||
|
icon: IconName;
|
||||||
|
variant: string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
icon: "bolt",
|
||||||
|
...siteConfig.whyUs.cards[0],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "slopfree",
|
||||||
|
...siteConfig.whyUs.cards[1],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "users",
|
||||||
|
...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" title={siteConfig.whyUs.title} background="bg-base-200">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||||
|
{
|
||||||
|
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
|
<div
|
||||||
class="inline-flex items-center gap-3 bg-secondary/10 text-secondary px-8 py-4 rounded-full text-2xl lg:text-3xl font-bold"
|
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,
|
||||||
|
]}
|
||||||
>
|
>
|
||||||
<svg
|
<Icon
|
||||||
class="w-8 h-8"
|
name={feature.icon}
|
||||||
fill="none"
|
class:list={["w-8 h-8", styles.text]}
|
||||||
stroke="currentColor"
|
/>
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
Why Choose Atash Consulting?
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<h3 class="text-xl font-bold text-base-content mb-3">
|
||||||
<p
|
{feature.title}
|
||||||
class="text-lg text-base-content/80 leading-relaxed max-w-3xl mx-auto"
|
|
||||||
>
|
|
||||||
With over a decade of experience in the software
|
|
||||||
industry, I bring deep technical expertise and a
|
|
||||||
commitment to excellence to every project.
|
|
||||||
</p>
|
|
||||||
<div
|
|
||||||
class="grid grid-cols-1 md:grid-cols-3 gap-8 lg:gap-12 mt-12"
|
|
||||||
>
|
|
||||||
<div class="text-center space-y-4">
|
|
||||||
<div
|
|
||||||
class="w-16 h-16 bg-primary rounded-xl flex items-center justify-center mx-auto shadow-lg"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="w-8 h-8 text-primary-content"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M13 10V3L4 14h7v7l9-11h-7z"></path>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h3 class="text-xl font-bold text-base-content">
|
|
||||||
Fast Delivery
|
|
||||||
</h3>
|
</h3>
|
||||||
<p class="text-base-content/70 leading-relaxed">
|
<p class="text-base-content/60 leading-relaxed">
|
||||||
Quick turnaround without compromising quality
|
{feature.content}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-center space-y-4">
|
);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="w-16 h-16 bg-secondary rounded-xl flex items-center justify-center mx-auto shadow-lg"
|
class="mt-20 p-8 lg:p-12 rounded-2xl bg-linear-to-br from-neutral to-neutral/90"
|
||||||
>
|
>
|
||||||
<svg
|
<div class="grid grid-cols-1 sm:grid-cols-3 gap-8">
|
||||||
class="w-8 h-8 text-secondary-content"
|
{
|
||||||
fill="none"
|
siteConfig.whyUs.stats.map((stat) => (
|
||||||
stroke="currentColor"
|
<div class="text-center">
|
||||||
viewBox="0 0 24 24"
|
<div class="text-3xl lg:text-4xl font-bold text-neutral-content mb-2">
|
||||||
>
|
{stat.value}
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
<h3 class="text-xl font-bold text-base-content">
|
<div class="text-neutral-content/60 text-sm font-medium">
|
||||||
Quality Assured
|
{stat.label}
|
||||||
</h3>
|
|
||||||
<p class="text-base-content/70 leading-relaxed">
|
|
||||||
Rigorous testing and quality control processes
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="text-center space-y-4">
|
|
||||||
<div
|
|
||||||
class="w-16 h-16 bg-accent rounded-xl flex items-center justify-center mx-auto shadow-lg"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="w-8 h-8 text-accent-content"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h3 class="text-xl font-bold text-base-content">
|
|
||||||
Expert Support
|
|
||||||
</h3>
|
|
||||||
<p class="text-base-content/70 leading-relaxed">
|
|
||||||
Ongoing support and maintenance services
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
))
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Section>
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|||||||
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={200}
|
||||||
|
pauseOnHover={false}
|
||||||
|
scaleOnHover={true}
|
||||||
|
fadeOut={true}
|
||||||
|
ariaLabel="Scrolling list of client logos."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
@@ -1,309 +0,0 @@
|
|||||||
import { createSignal, type Component } from "solid-js";
|
|
||||||
import { Show } from "solid-js/web";
|
|
||||||
|
|
||||||
const ContactSection: Component = () => {
|
|
||||||
const [firstName, setFirstName] = createSignal("");
|
|
||||||
const [lastName, setLastName] = createSignal("");
|
|
||||||
const [email, setEmail] = createSignal("");
|
|
||||||
const [company, setCompany] = createSignal("");
|
|
||||||
const [service, setService] = createSignal("");
|
|
||||||
const [budget, setBudget] = createSignal("");
|
|
||||||
const [message, setMessage] = createSignal("");
|
|
||||||
const [status, setStatus] = createSignal<
|
|
||||||
"idle" | "sending" | "success" | "error"
|
|
||||||
>("idle");
|
|
||||||
const [errorMessage, setErrorMessage] = createSignal("");
|
|
||||||
|
|
||||||
const handleSubmit = async (e: Event) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setStatus("sending");
|
|
||||||
setErrorMessage("");
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/contact", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
subject: `New Contact Form Message from ${firstName()} ${lastName()}`,
|
|
||||||
message: `From: ${firstName()} ${lastName()}
|
|
||||||
Email: ${email()}
|
|
||||||
Company: ${company() || "Not specified"}
|
|
||||||
Service Needed: ${service() || "Not specified"}
|
|
||||||
Budget: ${budget() || "Not specified"}
|
|
||||||
|
|
||||||
Project Details:
|
|
||||||
${message()}`,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(data.error || data.message || "Failed to send message");
|
|
||||||
}
|
|
||||||
|
|
||||||
setStatus("success");
|
|
||||||
setFirstName("");
|
|
||||||
setLastName("");
|
|
||||||
setEmail("");
|
|
||||||
setCompany("");
|
|
||||||
setService("");
|
|
||||||
setBudget("");
|
|
||||||
setMessage("");
|
|
||||||
setTimeout(() => setStatus("idle"), 3000);
|
|
||||||
} catch (error) {
|
|
||||||
setStatus("error");
|
|
||||||
setErrorMessage(
|
|
||||||
error instanceof Error ? error.message : "Failed to send message",
|
|
||||||
);
|
|
||||||
console.error("Submission error:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section id="contact" class="py-16 lg:py-24">
|
|
||||||
<div class="max-w-7xl mx-auto px-6">
|
|
||||||
<div class="text-center mb-12 lg:mb-16">
|
|
||||||
<div class="inline-flex items-center gap-3 bg-accent/10 text-accent px-8 py-4 rounded-full text-2xl lg:text-3xl font-bold">
|
|
||||||
<svg
|
|
||||||
class="w-8 h-8"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
Get In Touch
|
|
||||||
</div>
|
|
||||||
<p class="text-lg text-base-content/70 max-w-2xl mx-auto leading-relaxed mt-6">
|
|
||||||
Ready to start your project? Let's discuss how I can help.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="max-w-2xl mx-auto">
|
|
||||||
<div class="card bg-base-100 shadow-xl border border-base-200 rounded-2xl">
|
|
||||||
<div class="card-body p-6 lg:p-8">
|
|
||||||
<form class="space-y-6" onSubmit={handleSubmit}>
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text font-semibold text-base text-base-content">
|
|
||||||
First Name *
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="firstName"
|
|
||||||
class="input input-bordered w-full focus:input-primary"
|
|
||||||
required
|
|
||||||
value={firstName()}
|
|
||||||
onInput={(e) => setFirstName(e.currentTarget.value)}
|
|
||||||
disabled={status() === "sending"}
|
|
||||||
placeholder="Enter your first name"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text font-semibold text-base text-base-content">
|
|
||||||
Last Name *
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="lastName"
|
|
||||||
class="input input-bordered w-full focus:input-primary"
|
|
||||||
required
|
|
||||||
value={lastName()}
|
|
||||||
onInput={(e) => setLastName(e.currentTarget.value)}
|
|
||||||
disabled={status() === "sending"}
|
|
||||||
placeholder="Enter your last name"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text font-semibold text-base text-base-content">
|
|
||||||
Email Address *
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
name="email"
|
|
||||||
class="input input-bordered w-full focus:input-primary"
|
|
||||||
required
|
|
||||||
value={email()}
|
|
||||||
onInput={(e) => setEmail(e.currentTarget.value)}
|
|
||||||
disabled={status() === "sending"}
|
|
||||||
placeholder="your.email@example.com"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text font-semibold text-base text-base-content">
|
|
||||||
Company
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="company"
|
|
||||||
class="input input-bordered w-full focus:input-primary"
|
|
||||||
value={company()}
|
|
||||||
onInput={(e) => setCompany(e.currentTarget.value)}
|
|
||||||
disabled={status() === "sending"}
|
|
||||||
placeholder="Your company name (optional)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text font-semibold text-base text-base-content">
|
|
||||||
Service Needed
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
name="service"
|
|
||||||
class="select select-bordered w-full focus:select-primary"
|
|
||||||
value={service()}
|
|
||||||
onChange={(e) => setService(e.currentTarget.value)}
|
|
||||||
disabled={status() === "sending"}
|
|
||||||
>
|
|
||||||
<option value="">Select a service...</option>
|
|
||||||
<option value="web-development">Web Development</option>
|
|
||||||
<option value="mobile-development">
|
|
||||||
Mobile App Development
|
|
||||||
</option>
|
|
||||||
|
|
||||||
<option value="devops">DevOps</option>
|
|
||||||
<option value="it-support">IT Support Processes</option>
|
|
||||||
<option value="consultation">General Consultation</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text font-semibold text-base text-base-content">
|
|
||||||
Project Budget
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
name="budget"
|
|
||||||
class="select select-bordered w-full focus:select-primary"
|
|
||||||
value={budget()}
|
|
||||||
onChange={(e) => setBudget(e.currentTarget.value)}
|
|
||||||
disabled={status() === "sending"}
|
|
||||||
>
|
|
||||||
<option value="">Select budget range...</option>
|
|
||||||
<option value="under-5k">Under $5,000</option>
|
|
||||||
<option value="5k-15k">$5,000 - $15,000</option>
|
|
||||||
<option value="15k-50k">$15,000 - $50,000</option>
|
|
||||||
<option value="50k-plus">$50,000+</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label" for="project-details">
|
|
||||||
<span class="label-text font-semibold text-base text-base-content">
|
|
||||||
Project Details *
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
id="project-details"
|
|
||||||
name="message"
|
|
||||||
class="textarea textarea-bordered h-32 w-full resize-none focus:textarea-primary"
|
|
||||||
placeholder="Tell me about your project requirements, timeline, and any specific needs..."
|
|
||||||
required
|
|
||||||
value={message()}
|
|
||||||
onInput={(e) => setMessage(e.currentTarget.value)}
|
|
||||||
disabled={status() === "sending"}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Show when={status() === "error"}>
|
|
||||||
<div class="alert alert-error">
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="stroke-current shrink-0 h-6 w-6"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span>
|
|
||||||
{errorMessage() ||
|
|
||||||
"Error sending message. Please try again."}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={status() === "success"}>
|
|
||||||
<div class="alert alert-success">
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="stroke-current shrink-0 h-6 w-6"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span>Message sent successfully!</span>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<div class="form-control pt-4">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="btn btn-primary btn-lg w-full"
|
|
||||||
disabled={status() === "sending"}
|
|
||||||
>
|
|
||||||
{status() === "sending" ? (
|
|
||||||
<>
|
|
||||||
<span class="loading loading-spinner"></span>
|
|
||||||
Sending Message...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<svg
|
|
||||||
class="w-5 h-5 mr-2"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
Send Message
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ContactSection;
|
|
||||||
285
src/components/sections/ContactSection.vue
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue";
|
||||||
|
import Icon from "../Icon.vue";
|
||||||
|
import { siteConfig } from "../../config/site";
|
||||||
|
|
||||||
|
const firstName = ref("");
|
||||||
|
const lastName = ref("");
|
||||||
|
const email = ref("");
|
||||||
|
const company = ref("");
|
||||||
|
const service = ref("");
|
||||||
|
const budget = ref("");
|
||||||
|
const message = ref("");
|
||||||
|
const status = ref<"idle" | "sending" | "success" | "error">("idle");
|
||||||
|
const errorMessage = ref("");
|
||||||
|
let timeoutId: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
|
const handleSubmit = async (e: Event) => {
|
||||||
|
if (timeoutId) clearTimeout(timeoutId);
|
||||||
|
status.value = "sending";
|
||||||
|
errorMessage.value = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/contact", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
subject: `New Contact Form Message from ${firstName.value} ${lastName.value}`,
|
||||||
|
message: `From: ${firstName.value} ${lastName.value}
|
||||||
|
Email: ${email.value}
|
||||||
|
Company: ${company.value || "Not specified"}
|
||||||
|
Service Needed: ${service.value || "Not specified"}
|
||||||
|
Budget: ${budget.value || "Not specified"}
|
||||||
|
|
||||||
|
Project Details:
|
||||||
|
${message.value}`,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
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 = "";
|
||||||
|
timeoutId = setTimeout(() => (status.value = "idle"), 2000);
|
||||||
|
} catch (error) {
|
||||||
|
status.value = "error";
|
||||||
|
errorMessage.value =
|
||||||
|
error instanceof Error ? error.message : "Failed to send message";
|
||||||
|
console.error("Submission error:", error);
|
||||||
|
timeoutId = setTimeout(() => (status.value = "idle"), 2000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
{{ siteConfig.contact.form.firstName }}
|
||||||
|
</legend>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="firstName"
|
||||||
|
name="firstName"
|
||||||
|
:aria-label="siteConfig.contact.form.firstName"
|
||||||
|
class="input input-bordered w-full bg-base-100 focus:border-primary focus:outline-primary"
|
||||||
|
required
|
||||||
|
v-model="firstName"
|
||||||
|
:disabled="status === 'sending'"
|
||||||
|
:placeholder="
|
||||||
|
siteConfig.contact.form.placeholders
|
||||||
|
.firstName
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset class="fieldset">
|
||||||
|
<legend
|
||||||
|
class="fieldset-legend text-sm font-semibold text-base-content"
|
||||||
|
>
|
||||||
|
{{ siteConfig.contact.form.lastName }}
|
||||||
|
</legend>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="lastName"
|
||||||
|
name="lastName"
|
||||||
|
:aria-label="siteConfig.contact.form.lastName"
|
||||||
|
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">
|
||||||
|
<legend
|
||||||
|
class="fieldset-legend text-sm font-semibold text-base-content"
|
||||||
|
>
|
||||||
|
{{ siteConfig.contact.form.email }}
|
||||||
|
</legend>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
:aria-label="siteConfig.contact.form.email"
|
||||||
|
class="input input-bordered w-full bg-base-100 focus:border-primary focus:outline-primary"
|
||||||
|
required
|
||||||
|
v-model="email"
|
||||||
|
:disabled="status === 'sending'"
|
||||||
|
:placeholder="
|
||||||
|
siteConfig.contact.form.placeholders.email
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset class="fieldset">
|
||||||
|
<legend
|
||||||
|
class="fieldset-legend text-sm font-semibold text-base-content"
|
||||||
|
>
|
||||||
|
{{ siteConfig.contact.form.company }}
|
||||||
|
</legend>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="company"
|
||||||
|
name="company"
|
||||||
|
:aria-label="siteConfig.contact.form.company"
|
||||||
|
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
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
{{ siteConfig.contact.form.service }}
|
||||||
|
</legend>
|
||||||
|
<select
|
||||||
|
name="service"
|
||||||
|
aria-label="Service Needed"
|
||||||
|
class="select select-bordered w-full bg-base-100 focus:border-primary focus:outline-primary"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{{ s.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset class="fieldset">
|
||||||
|
<legend
|
||||||
|
class="fieldset-legend text-sm font-semibold text-base-content"
|
||||||
|
>
|
||||||
|
{{ siteConfig.contact.form.budget }}
|
||||||
|
</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">
|
||||||
|
<legend
|
||||||
|
class="fieldset-legend text-sm font-semibold text-base-content"
|
||||||
|
>
|
||||||
|
{{ siteConfig.contact.form.message }}
|
||||||
|
</legend>
|
||||||
|
<textarea
|
||||||
|
id="project-details"
|
||||||
|
name="message"
|
||||||
|
:aria-label="siteConfig.contact.form.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
|
||||||
|
"
|
||||||
|
required
|
||||||
|
v-model="message"
|
||||||
|
:disabled="status === 'sending'"
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-lg w-full shadow-lg transition-all duration-300"
|
||||||
|
:class="[
|
||||||
|
status === 'success'
|
||||||
|
? 'btn-success text-white pointer-events-none'
|
||||||
|
: status === 'error'
|
||||||
|
? 'btn-error text-white pointer-events-none'
|
||||||
|
: 'btn-primary',
|
||||||
|
]"
|
||||||
|
:disabled="status === 'sending'"
|
||||||
|
>
|
||||||
|
<template v-if="status === 'sending'">
|
||||||
|
<span
|
||||||
|
class="loading loading-spinner loading-sm"
|
||||||
|
></span>
|
||||||
|
{{ siteConfig.contact.form.sending }}
|
||||||
|
</template>
|
||||||
|
<template v-else-if="status === 'success'">
|
||||||
|
<Icon name="check-circle" class="w-5 h-5" />
|
||||||
|
{{ siteConfig.contact.form.success }}
|
||||||
|
</template>
|
||||||
|
<template v-else-if="status === 'error'">
|
||||||
|
<Icon name="x-circle" class="w-5 h-5" />
|
||||||
|
{{ errorMessage || siteConfig.contact.form.error }}
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<Icon name="paper-airplane" class="w-5 h-5" />
|
||||||
|
{{ siteConfig.contact.form.submit }}
|
||||||
|
</template>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-10 text-center">
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<Icon name="envelope" class="w-5 h-5" />
|
||||||
|
{{ siteConfig.contact.direct.email }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,84 +1,92 @@
|
|||||||
---
|
---
|
||||||
import { siteConfig } from "../../config/site";
|
import { siteConfig } from "../../config/site";
|
||||||
|
import Icon from "../Icon.astro";
|
||||||
|
import Section from "../Section.astro";
|
||||||
|
import RotatingText from "../RotatingText.vue";
|
||||||
|
import StatusIndicator from "../StatusIndicator.vue";
|
||||||
|
|
||||||
|
const rotatingText = (siteConfig.hero as any).rotatingText as
|
||||||
|
| { text: string; className: string }[]
|
||||||
|
| undefined;
|
||||||
---
|
---
|
||||||
|
|
||||||
<section class="relative overflow-hidden py-12 lg:py-20">
|
<Section
|
||||||
<div class="max-w-7xl mx-auto px-6">
|
fullWidth={true}
|
||||||
|
background="bg-neutral"
|
||||||
|
class="relative overflow-hidden"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
class="hero min-h-[65vh] bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 rounded-2xl shadow-xl relative"
|
class="absolute inset-0 bg-[linear-gradient(to_right,#ffffff20_1px,transparent_1px),linear-gradient(to_bottom,#ffffff20_1px,transparent_1px)] bg-size-[2rem_2rem] mask-[radial-gradient(ellipse_at_center,black_20%,transparent_70%)]"
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="absolute inset-0 bg-gradient-to-br from-primary/20 via-transparent to-secondary/20 rounded-2xl"
|
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
class="absolute inset-0 bg-gradient-to-t from-black/10 via-transparent to-black/5 rounded-2xl"
|
<div class="relative max-w-7xl mx-auto px-6">
|
||||||
>
|
<div class="text-center max-w-4xl mx-auto">
|
||||||
</div>
|
<StatusIndicator client:idle />
|
||||||
<div
|
|
||||||
class="hero-content text-center text-primary-content py-16 lg:py-20 px-8"
|
|
||||||
>
|
|
||||||
<div class="max-w-4xl w-full space-y-8">
|
|
||||||
<div>
|
|
||||||
<h1
|
<h1
|
||||||
class="text-4xl sm:text-6xl lg:text-7xl font-bold mb-4 leading-tight text-white"
|
class="text-4xl sm:text-5xl lg:text-6xl xl:text-7xl font-extrabold text-white leading-tight tracking-tight mb-6"
|
||||||
>
|
>
|
||||||
{siteConfig.name}
|
{
|
||||||
|
rotatingText ? (
|
||||||
|
<>
|
||||||
|
<RotatingText items={rotatingText} client:idle />
|
||||||
|
<span class="block">
|
||||||
|
{siteConfig.hero.mainTitle
|
||||||
|
.replace("{rotating}", "")
|
||||||
|
.trim()}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
siteConfig.hero.mainTitle
|
||||||
|
)
|
||||||
|
}
|
||||||
</h1>
|
</h1>
|
||||||
<div
|
|
||||||
class="w-20 h-1 bg-accent mx-auto mb-6 rounded-full"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p
|
<p
|
||||||
class="text-lg sm:text-xl lg:text-2xl mb-8 opacity-90 max-w-3xl mx-auto leading-relaxed text-white"
|
class="text-lg sm:text-xl text-white max-w-2xl mx-auto mb-10 leading-relaxed"
|
||||||
>
|
>
|
||||||
{siteConfig.hero.description} — Delivering reliable, scalable
|
{siteConfig.description}.
|
||||||
solutions tailored to your business needs.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="flex flex-col sm:flex-row gap-4 justify-center items-center"
|
class="flex flex-col sm:flex-row gap-4 justify-center items-center"
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
href="#contact"
|
href="#contact"
|
||||||
class="btn btn-accent btn-lg text-accent-content hover:bg-accent/90 shadow-lg border-0"
|
class="btn btn-accent btn-lg shadow-lg shadow-accent/25 hover:shadow-xl hover:shadow-accent/30"
|
||||||
>
|
>
|
||||||
<svg
|
<Icon name="bolt" class="w-5 h-5" />
|
||||||
class="w-5 h-5 mr-2"
|
{siteConfig.hero.cta}
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M13 10V3L4 14h7v7l9-11h-7z"></path>
|
|
||||||
</svg>
|
|
||||||
Start Your Project
|
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href="#services"
|
href="#services"
|
||||||
class="btn btn-outline btn-lg text-white border-white hover:bg-white hover:text-primary"
|
class="btn btn-outline btn-lg border-white text-white hover:bg-white hover:text-neutral hover:border-white transition-all duration-300"
|
||||||
>
|
>
|
||||||
<svg
|
<Icon name="chevron-down" class="w-5 h-5" />
|
||||||
class="w-5 h-5 mr-2"
|
{siteConfig.hero.secondaryCta}
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
View Services
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-16 pt-10">
|
||||||
|
<p
|
||||||
|
class="text-white text-sm mb-6 uppercase tracking-wider font-medium"
|
||||||
|
>
|
||||||
|
{siteConfig.hero.trustedText}
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
class="flex flex-wrap justify-center items-center gap-8 lg:gap-12"
|
||||||
|
>
|
||||||
|
{
|
||||||
|
siteConfig.hero.features.map((feature) => (
|
||||||
|
<div class="flex items-center gap-2.5 text-white/80">
|
||||||
|
<Icon name={feature.icon} class="w-5 h-5" />
|
||||||
|
<span class="font-medium">{feature.text}</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</Section>
|
||||||
|
|||||||
@@ -1,133 +1,51 @@
|
|||||||
---
|
---
|
||||||
import { siteConfig } from "../../config/site";
|
import { siteConfig } from "../../config/site";
|
||||||
|
import Icon from "../Icon.astro";
|
||||||
|
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-16 lg:py-24">
|
<Section
|
||||||
<div class="max-w-7xl mx-auto px-6">
|
id="services"
|
||||||
<div class="text-center mb-12 lg:mb-16">
|
background="bg-base-100"
|
||||||
<div
|
title={siteConfig.services.title}
|
||||||
class="inline-flex items-center gap-3 bg-primary/10 text-primary px-8 py-4 rounded-full text-2xl lg:text-3xl font-bold"
|
>
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="w-8 h-8"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M13 10V3L4 14h7v7l9-11h-7z"></path>
|
|
||||||
</svg>
|
|
||||||
Our Services
|
|
||||||
</div>
|
|
||||||
<p
|
|
||||||
class="text-lg text-base-content/70 max-w-2xl mx-auto leading-relaxed mt-6"
|
|
||||||
>
|
|
||||||
Comprehensive software solutions designed to drive your business
|
|
||||||
forward
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 lg:gap-8">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 lg:gap-8">
|
||||||
{
|
{
|
||||||
siteConfig.featureCards.cards.map((card, index) => (
|
siteConfig.services.cards.map((card) => {
|
||||||
<div class="card bg-base-100 shadow-lg hover:shadow-xl transition-all duration-300 border border-base-200 hover:border-primary/30 rounded-xl">
|
const styles =
|
||||||
<div class="card-body p-6 lg:p-8">
|
variantStyles[card.variant] || variantStyles.primary;
|
||||||
<div class="flex items-start gap-4">
|
|
||||||
|
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
|
<div
|
||||||
class={`
|
class:list={[
|
||||||
w-14 h-14 rounded-lg flex items-center justify-center flex-shrink-0
|
"shrink-0 w-14 h-14 rounded-xl flex items-center justify-center transition-transform duration-300 group-hover:scale-110",
|
||||||
${
|
styles,
|
||||||
card.variant === "primary"
|
]}
|
||||||
? "bg-primary text-primary-content"
|
|
||||||
: card.variant === "secondary"
|
|
||||||
? "bg-secondary text-secondary-content"
|
|
||||||
: "bg-accent text-accent-content"
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
>
|
>
|
||||||
{index === 0 && (
|
<Icon name={card.icon} class="w-6 h-6" />
|
||||||
<svg
|
|
||||||
class="w-7 h-7"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
{index === 1 && (
|
|
||||||
<svg
|
|
||||||
class="w-7 h-7"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
{index === 2 && (
|
|
||||||
<svg
|
|
||||||
class="w-7 h-7"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
{index === 3 && (
|
|
||||||
<svg
|
|
||||||
class="w-7 h-7"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M18.364 5.636l-3.536 3.536m0 5.656l3.536 3.536M9.172 9.172L5.636 5.636m3.536 9.192L5.636 18.364M12 2.25a9.75 9.75 0 109.75 9.75A9.75 9.75 0 0012 2.25z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 space-y-3">
|
<div class="flex-1 space-y-3">
|
||||||
<h3 class="text-xl font-bold text-base-content">
|
<h3 class="text-xl font-bold text-base-content group-hover:text-primary transition-colors">
|
||||||
{card.title}
|
{card.title}
|
||||||
</h3>
|
</h3>
|
||||||
<p class="text-base-content/70 leading-relaxed">
|
<p class="text-base-content/60 leading-relaxed">
|
||||||
{card.content}
|
{card.content}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
);
|
||||||
|
})
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Section>
|
||||||
</section>
|
|
||||||
|
|||||||
29
src/config/icons.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
export const icons = {
|
||||||
|
bolt: `<path d="M3.75 13.5L14.25 2.25L12 10.5H20.25L9.75 21.75L12 13.5H3.75Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`,
|
||||||
|
|
||||||
|
"chevron-down": `<path d="M19.5 8.25L12 15.75L4.5 8.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`,
|
||||||
|
|
||||||
|
"bars-3-bottom-left": `<path d="M3.75 6.75H20.25M3.75 12H20.25M3.75 17.25H12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`,
|
||||||
|
|
||||||
|
"computer-desktop": `<path d="M9 17.25V18.2574C9 19.053 8.68393 19.8161 8.12132 20.3787L7.5 21H16.5L15.8787 20.3787C15.3161 19.8161 15 19.053 15 18.2574V17.25M21 5.25V15C21 16.2426 19.9926 17.25 18.75 17.25H5.25C4.00736 17.25 3 16.2426 3 15V5.25M21 5.25C21 4.00736 19.9926 3 18.75 3H5.25C4.00736 3 3 4.00736 3 5.25M21 5.25V12C21 13.2426 19.9926 14.25 18.75 14.25H5.25C4.00736 14.25 3 13.2426 3 12V5.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`,
|
||||||
|
|
||||||
|
"device-phone-mobile": `<path d="M10.5 1.5H8.25C7.00736 1.5 6 2.50736 6 3.75V20.25C6 21.4926 7.00736 22.5 8.25 22.5H15.75C16.9926 22.5 18 21.4926 18 20.25V3.75C18 2.50736 16.9926 1.5 15.75 1.5H13.5M10.5 1.5V3H13.5V1.5M10.5 1.5H13.5M10.5 20.25H13.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`,
|
||||||
|
|
||||||
|
"cog-6-tooth": `<path d="M9.59356 3.94014C9.68397 3.39768 10.1533 3.00009 10.7033 3.00009H13.2972C13.8472 3.00009 14.3165 3.39768 14.4069 3.94014L14.6204 5.22119C14.6828 5.59523 14.9327 5.9068 15.2645 6.09045C15.3387 6.13151 15.412 6.17393 15.4844 6.21766C15.8095 6.41393 16.2048 6.47495 16.5604 6.34175L17.7772 5.88587C18.2922 5.69293 18.8712 5.9006 19.1462 6.37687L20.4432 8.6233C20.7181 9.09957 20.6085 9.70482 20.1839 10.0544L19.1795 10.8812C18.887 11.122 18.742 11.4938 18.7491 11.8726C18.7498 11.915 18.7502 11.9575 18.7502 12.0001C18.7502 12.0427 18.7498 12.0852 18.7491 12.1275C18.742 12.5064 18.887 12.8782 19.1795 13.119L20.1839 13.9458C20.6085 14.2953 20.7181 14.9006 20.4432 15.3769L19.1462 17.6233C18.8712 18.0996 18.2922 18.3072 17.7772 18.1143L16.5604 17.6584C16.2048 17.5252 15.8095 17.5862 15.4844 17.7825C15.412 17.8263 15.3387 17.8687 15.2645 17.9097C14.9327 18.0934 14.6828 18.4049 14.6204 18.779L14.4069 20.06C14.3165 20.6025 13.8472 21.0001 13.2972 21.0001H10.7033C10.1533 21.0001 9.68397 20.6025 9.59356 20.06L9.38005 18.779C9.31771 18.4049 9.06774 18.0934 8.73597 17.9097C8.66179 17.8687 8.58847 17.8263 8.51604 17.7825C8.19101 17.5863 7.79568 17.5252 7.44011 17.6584L6.22325 18.1143C5.70826 18.3072 5.12926 18.0996 4.85429 17.6233L3.55731 15.3769C3.28234 14.9006 3.39199 14.2954 3.81657 13.9458L4.82092 13.119C5.11343 12.8782 5.25843 12.5064 5.25141 12.1276C5.25063 12.0852 5.25023 12.0427 5.25023 12.0001C5.25023 11.9575 5.25063 11.915 5.25141 11.8726C5.25843 11.4938 5.11343 11.122 4.82092 10.8812L3.81657 10.0544C3.39199 9.70484 3.28234 9.09958 3.55731 8.62332L4.85429 6.37688C5.12926 5.90061 5.70825 5.69295 6.22325 5.88588L7.4401 6.34176C7.79566 6.47496 8.19099 6.41394 8.51603 6.21767C8.58846 6.17393 8.66179 6.13151 8.73597 6.09045C9.06774 5.9068 9.31771 5.59523 9.38005 5.22119L9.59356 3.94014Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M15 12C15 13.6569 13.6569 15 12 15C10.3431 15 9 13.6569 9 12C9 10.3432 10.3431 9.00001 12 9.00001C13.6569 9.00001 15 10.3432 15 12Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`,
|
||||||
|
|
||||||
|
lifebuoy: `<path d="M16.7124 4.3299C17.2999 4.69153 17.8548 5.12691 18.364 5.63604C18.8731 6.14517 19.3085 6.70012 19.6701 7.28763M16.7124 4.3299L13.2636 8.46838M16.7124 4.3299C13.8316 2.5567 10.1684 2.5567 7.28763 4.3299M19.6701 7.28763L15.5316 10.7364M19.6701 7.28763C21.4433 10.1684 21.4433 13.8316 19.6701 16.7124M15.5316 10.7364C15.3507 10.2297 15.0574 9.75408 14.6517 9.34835C14.2459 8.94262 13.7703 8.6493 13.2636 8.46838M15.5316 10.7364C15.8228 11.5519 15.8228 12.4481 15.5316 13.2636M13.2636 8.46838C12.4481 8.17721 11.5519 8.17721 10.7364 8.46838M15.5316 13.2636C15.3507 13.7703 15.0574 14.2459 14.6517 14.6517C14.2459 15.0574 13.7703 15.3507 13.2636 15.5316M15.5316 13.2636L19.6701 16.7124M19.6701 16.7124C19.3085 17.2999 18.8731 17.8548 18.364 18.364C17.8548 18.8731 17.2999 19.3085 16.7124 19.6701M16.7124 19.6701L13.2636 15.5316M16.7124 19.6701C13.8316 21.4433 10.1684 21.4433 7.28763 19.6701M13.2636 15.5316C12.4481 15.8228 11.5519 15.8228 10.7364 15.5316M10.7364 15.5316C10.2297 15.3507 9.75408 15.0574 9.34835 14.6517C8.94262 14.2459 8.6493 13.7703 8.46838 13.2636M10.7364 15.5316L7.28763 19.6701M7.28763 19.6701C6.70012 19.3085 6.14517 18.8731 5.63604 18.364C5.12691 17.8548 4.69153 17.2999 4.3299 16.7124M4.3299 16.7124L8.46838 13.2636M4.3299 16.7124C2.5567 13.8316 2.5567 10.1684 4.3299 7.28763M8.46838 13.2636C8.17721 12.4481 8.17721 11.5519 8.46838 10.7364M8.46838 10.7364C8.6493 10.2297 8.94262 9.75408 9.34835 9.34835C9.75408 8.94262 10.2297 8.6493 10.7364 8.46838M8.46838 10.7364L4.3299 7.28763M10.7364 8.46838L7.28763 4.3299M7.28763 4.3299C6.70012 4.69153 6.14517 5.12691 5.63604 5.63604C5.12691 6.14517 4.69153 6.70013 4.3299 7.28763" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`,
|
||||||
|
|
||||||
|
"check-circle": `<path d="M9 12.75L11.25 15L15 9.75M21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`,
|
||||||
|
|
||||||
|
users: `<path d="M15 19.1276C15.8329 19.37 16.7138 19.5 17.625 19.5C19.1037 19.5 20.5025 19.1576 21.7464 18.5478C21.7488 18.4905 21.75 18.4329 21.75 18.375C21.75 16.0968 19.9031 14.25 17.625 14.25C16.2069 14.25 14.956 14.9655 14.2136 16.0552M15 19.1276V19.125C15 18.0121 14.7148 16.9658 14.2136 16.0552M15 19.1276C15 19.1632 14.9997 19.1988 14.9991 19.2343C13.1374 20.3552 10.9565 21 8.625 21C6.29353 21 4.11264 20.3552 2.25092 19.2343C2.25031 19.198 2.25 19.1615 2.25 19.125C2.25 15.6042 5.10418 12.75 8.625 12.75C11.0329 12.75 13.129 14.085 14.2136 16.0552M12 6.375C12 8.23896 10.489 9.75 8.625 9.75C6.76104 9.75 5.25 8.23896 5.25 6.375C5.25 4.51104 6.76104 3 8.625 3C10.489 3 12 4.51104 12 6.375ZM20.25 8.625C20.25 10.0747 19.0747 11.25 17.625 11.25C16.1753 11.25 15 10.0747 15 8.625C15 7.17525 16.1753 6 17.625 6C19.0747 6 20.25 7.17525 20.25 8.625Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`,
|
||||||
|
|
||||||
|
"x-circle": `<path d="M9.75 9.75L14.25 14.25M14.25 9.75L9.75 14.25M21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`,
|
||||||
|
|
||||||
|
"paper-airplane": `<path d="M5.99972 12L3.2688 3.12451C9.88393 5.04617 16.0276 8.07601 21.4855 11.9997C16.0276 15.9235 9.884 18.9535 3.26889 20.8752L5.99972 12ZM5.99972 12L13.5 12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`,
|
||||||
|
|
||||||
|
envelope: `<path d="M21.75 6.75V17.25C21.75 18.4926 20.7426 19.5 19.5 19.5H4.5C3.25736 19.5 2.25 18.4926 2.25 17.25V6.75M21.75 6.75C21.75 5.50736 20.7426 4.5 19.5 4.5H4.5C3.25736 4.5 2.25 5.50736 2.25 6.75M21.75 6.75V6.99271C21.75 7.77405 21.3447 8.49945 20.6792 8.90894L13.1792 13.5243C12.4561 13.9694 11.5439 13.9694 10.8208 13.5243L3.32078 8.90894C2.65535 8.49945 2.25 7.77405 2.25 6.99271V6.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`,
|
||||||
|
|
||||||
|
slopfree: `<path d="M23 15V18C23 18.5 22.64 18.88 22.17 18.97L20.2 17H21V16H19.2L19 15.8V14C19 11.24 16.76 9 14 9H12.2L10.2 7H11V5.73C10.4 5.39 10 4.74 10 4C10 2.9 10.9 2 12 2S14 2.9 14 4C14 4.74 13.6 5.39 13 5.73V7H14C17.87 7 21 10.13 21 14H22C22.55 14 23 14.45 23 15M8.5 13.5C7.4 13.5 6.5 14.4 6.5 15.5S7.4 17.5 8.5 17.5 10.5 16.61 10.5 15.5 9.61 13.5 8.5 13.5M22.11 21.46L20.84 22.73L19.89 21.78C19.62 21.92 19.32 22 19 22H5C3.9 22 3 21.11 3 20V19H2C1.45 19 1 18.55 1 18V15C1 14.45 1.45 14 2 14H3C3 11.53 4.29 9.36 6.22 8.11L1.11 3L2.39 1.73L22.11 21.46M18.11 20L15.6 17.5C15.57 17.5 15.53 17.5 15.5 17.5C14.4 17.5 13.5 16.61 13.5 15.5C13.5 15.47 13.5 15.43 13.5 15.4L7.7 9.59C6.1 10.42 5 12.08 5 14V16H3V17H5V20H18.11Z" fill="currentColor"/>`,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type IconName = keyof typeof icons;
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { IconName } from "./icons";
|
||||||
|
|
||||||
type Card = {
|
type Card = {
|
||||||
title: string;
|
title: string;
|
||||||
content: string;
|
content: string;
|
||||||
@@ -5,8 +7,9 @@ type Card = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const siteConfig = {
|
export const siteConfig = {
|
||||||
|
siteUrl: "https://atash.dev",
|
||||||
name: "Atash Consulting",
|
name: "Atash Consulting",
|
||||||
description: "Software Consulting based in Edmonton, Alberta",
|
description: "Independent Software Consulting based in Edmonton, Alberta",
|
||||||
|
|
||||||
header: {
|
header: {
|
||||||
logo: {
|
logo: {
|
||||||
@@ -20,59 +23,187 @@ export const siteConfig = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: "Services",
|
text: "Services",
|
||||||
href: "/services",
|
href: "/#services",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "About",
|
||||||
|
href: "/#about",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: "Contact",
|
text: "Contact",
|
||||||
href: "/contact",
|
href: "/#contact",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
cta: {
|
cta: {
|
||||||
text: "Get Started",
|
text: "Let's Talk",
|
||||||
href: "/contact",
|
href: "/#contact",
|
||||||
},
|
},
|
||||||
|
mobileLogoText: "Atash",
|
||||||
},
|
},
|
||||||
|
|
||||||
hero: {
|
hero: {
|
||||||
title: "Atash Consulting",
|
title: "Atash Consulting",
|
||||||
description: "Software Consulting based in Edmonton, Alberta",
|
mainTitle: "{rotating} That Drive Growth",
|
||||||
|
rotatingText: [
|
||||||
|
{
|
||||||
|
text: "Web Applications",
|
||||||
|
className: "bg-primary text-primary-content px-2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "Mobile Apps",
|
||||||
|
className: "bg-secondary text-secondary-content px-2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "IT Strategies",
|
||||||
|
className: "bg-accent text-accent-content px-2",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
cta: "Discuss Your Project",
|
||||||
|
secondaryCta: "View Services",
|
||||||
|
trustedText: "Trusted expertise in",
|
||||||
|
features: [
|
||||||
|
{ icon: "computer-desktop", text: "Web Development" },
|
||||||
|
{ icon: "device-phone-mobile", text: "Mobile Apps" },
|
||||||
|
{ icon: "cog-6-tooth", text: "DevOps" },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
featureCards: {
|
services: {
|
||||||
enabled: true,
|
title: "Our Services",
|
||||||
cards: [
|
cards: [
|
||||||
{
|
{
|
||||||
title: "Web Development",
|
title: "Web Development",
|
||||||
content: "Functional, accessible, and beautiful websites.",
|
content: "Functional, accessible, and beautiful websites.",
|
||||||
variant: "primary",
|
variant: "primary",
|
||||||
|
icon: "computer-desktop",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Mobile App Development",
|
title: "Mobile App Development",
|
||||||
content: "iOS, Android, and cross-platform mobile applications.",
|
content: "iOS, Android, and cross-platform mobile applications.",
|
||||||
variant: "secondary",
|
variant: "secondary",
|
||||||
|
icon: "device-phone-mobile",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "DevOps",
|
title: "DevOps",
|
||||||
content: "Anything from CI/CD to end-to-end UI testing.",
|
content: "CI/CD pipelines end-to-end automation.",
|
||||||
variant: "secondary",
|
variant: "secondary",
|
||||||
|
icon: "cog-6-tooth",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "IT Support Processes",
|
title: "IT Support Processes",
|
||||||
content:
|
content:
|
||||||
"I provide expert technical support expertise, backed by over a decade of client support experience.",
|
"Expert technical guidance backed by over a decade of experience.",
|
||||||
variant: "primary",
|
variant: "primary",
|
||||||
|
icon: "lifebuoy",
|
||||||
},
|
},
|
||||||
] as Card[],
|
] as (Card & { icon: IconName })[],
|
||||||
},
|
},
|
||||||
|
|
||||||
contact: {
|
whyUs: {
|
||||||
title: "Contact Me",
|
title: "Why Partner With Us?",
|
||||||
description: "Ready to get started? Reach out to me for a consultation.",
|
cards: [
|
||||||
cta: {
|
{
|
||||||
text: "Get in Touch",
|
title: "Fast Delivery",
|
||||||
href: "/contact",
|
content: "Efficient delivery without compromising quality",
|
||||||
ariaLabel: "Contact me for consultation",
|
variant: "primary",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Slop-free Guarantee",
|
||||||
|
content: "Hand-crafted code built with care",
|
||||||
|
variant: "secondary",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Expert Support",
|
||||||
|
content: "Ongoing support and maintenance services",
|
||||||
|
variant: "accent",
|
||||||
|
},
|
||||||
|
] as Card[],
|
||||||
|
stats: [
|
||||||
|
{ value: "10+", label: "Years Experience" },
|
||||||
|
{ value: "100%", label: "Client Satisfaction" },
|
||||||
|
{ value: "12hr", label: "Response Time" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
clients: [
|
||||||
|
{
|
||||||
|
name: "Saikyo Softworks",
|
||||||
|
logo: "/clients/SaikyoSoftworks.webp",
|
||||||
|
href: "https://saikyosoft.works",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Royer Mortgages",
|
||||||
|
logo: "/clients/RoyerMortgages.webp",
|
||||||
|
href: "https://royermortgages.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Marew Consulting Ltd",
|
||||||
|
logo: "/clients/MarewConsulting.webp",
|
||||||
|
href: "https://marewconsulting.ca",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Hutch Mortgages",
|
||||||
|
logo: "/clients/HutchMortgages.webp",
|
||||||
|
href: "https://hutchmortgages.com",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
contact: {
|
||||||
|
title: "Contact Us",
|
||||||
|
cta: {
|
||||||
|
text: "Let's Talk",
|
||||||
|
href: "/#contact",
|
||||||
|
ariaLabel: "Contact us for consultation",
|
||||||
|
},
|
||||||
|
mainTitle: "Let's Work Together",
|
||||||
|
form: {
|
||||||
|
firstName: "First Name *",
|
||||||
|
lastName: "Last Name *",
|
||||||
|
email: "Email Address *",
|
||||||
|
company: "Company",
|
||||||
|
service: "Service Needed",
|
||||||
|
budget: "Project Budget",
|
||||||
|
message: "Project Details *",
|
||||||
|
placeholders: {
|
||||||
|
firstName: "Jason",
|
||||||
|
lastName: "Borne",
|
||||||
|
email: "jason@cia.gov",
|
||||||
|
company: "CIA",
|
||||||
|
message:
|
||||||
|
"Tell us about your project requirements, timeline, and any specific needs...",
|
||||||
|
},
|
||||||
|
selectPlaceholders: {
|
||||||
|
service: "Select a service...",
|
||||||
|
budget: "Select budget range...",
|
||||||
|
},
|
||||||
|
services: [
|
||||||
|
{ value: "web-development", label: "Web Development" },
|
||||||
|
{ value: "mobile-development", label: "Mobile App Development" },
|
||||||
|
{ value: "devops", label: "DevOps" },
|
||||||
|
{ value: "it-support", label: "IT Support Processes" },
|
||||||
|
{ value: "consultation", label: "General Consultation" },
|
||||||
|
],
|
||||||
|
budgets: [
|
||||||
|
{ value: "under-1k", label: "Under $1,000" },
|
||||||
|
{ value: "1k-5k", label: "$1,000 - $5,000" },
|
||||||
|
{ value: "5k-10k", label: "$5,000 - $10,000" },
|
||||||
|
{ value: "10k-plus", label: "$10,000+" },
|
||||||
|
{ value: "unsure", label: "Unsure" },
|
||||||
|
],
|
||||||
|
submit: "Send Message",
|
||||||
|
sending: "Sending...",
|
||||||
|
success: "Message sent successfully! We'll get back to you soon.",
|
||||||
|
error: "Error sending message. Please try again.",
|
||||||
|
},
|
||||||
|
direct: {
|
||||||
|
text: "Prefer to reach out directly?",
|
||||||
|
email: "contact@atash.dev",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
footer: {
|
||||||
|
copyright: "Copyright",
|
||||||
|
rights: "All rights reserved.",
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
---
|
---
|
||||||
|
import Header from "../components/Header.astro";
|
||||||
import Footer from "../components/Footer.astro";
|
import Footer from "../components/Footer.astro";
|
||||||
import { siteConfig } from "../config/site";
|
import { siteConfig } from "../config/site";
|
||||||
import "../styles/global.css";
|
import "../styles/global.css";
|
||||||
@@ -6,30 +7,95 @@ import "../styles/global.css";
|
|||||||
interface Props {
|
interface Props {
|
||||||
title?: string;
|
title?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
ogImage?: {
|
||||||
|
url: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
type: string;
|
||||||
|
alt: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const { title = siteConfig.name, description = siteConfig.description } =
|
const {
|
||||||
Astro.props;
|
title = siteConfig.name,
|
||||||
|
description = siteConfig.description,
|
||||||
|
ogImage,
|
||||||
|
} = Astro.props;
|
||||||
|
|
||||||
const metaTitle =
|
const metaTitle =
|
||||||
title === siteConfig.name ? title : `${title} | ${siteConfig.name}`;
|
title === siteConfig.name ? title : `${title} | ${siteConfig.name}`;
|
||||||
|
|
||||||
|
const resolvedOgImage = ogImage || {
|
||||||
|
url: "/logo.webp",
|
||||||
|
width: 1024,
|
||||||
|
height: 1024,
|
||||||
|
type: "image/webp",
|
||||||
|
alt: "Atash Consulting",
|
||||||
|
};
|
||||||
|
|
||||||
|
const siteUrl = siteConfig.siteUrl || Astro.url.href;
|
||||||
|
const resolvedOgImageUrl = new URL(resolvedOgImage.url, siteUrl).href;
|
||||||
|
|
||||||
|
const isProd = import.meta.env.PROD;
|
||||||
---
|
---
|
||||||
|
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en" data-theme="light">
|
<html lang="en" data-theme="light">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link
|
<link rel="icon" type="image/x-icon" href="favicon.ico" />
|
||||||
rel="icon"
|
|
||||||
href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🔥</text></svg>"
|
|
||||||
/>
|
|
||||||
<meta name="generator" content={Astro.generator} />
|
<meta name="generator" content={Astro.generator} />
|
||||||
<meta name="description" content={description} />
|
<meta name="description" content={description} />
|
||||||
|
<meta property="og:title" content={metaTitle} />
|
||||||
|
<meta property="og:description" content={description} />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:url" content={siteUrl} />
|
||||||
|
<meta property="og:image" content={resolvedOgImageUrl} />
|
||||||
|
<meta
|
||||||
|
property="og:image:width"
|
||||||
|
content={String(resolvedOgImage.width)}
|
||||||
|
/>
|
||||||
|
<meta
|
||||||
|
property="og:image:height"
|
||||||
|
content={String(resolvedOgImage.height)}
|
||||||
|
/>
|
||||||
|
<meta property="og:image:type" content={resolvedOgImage.type} />
|
||||||
|
<meta property="og:image:alt" content={resolvedOgImage.alt} />
|
||||||
|
<meta name="theme-color" content="#ffffff" />
|
||||||
|
<link rel="canonical" href={siteUrl} />
|
||||||
|
<link
|
||||||
|
rel="preload"
|
||||||
|
href="/fonts/roboto.woff2"
|
||||||
|
as="font"
|
||||||
|
type="font/woff2"
|
||||||
|
crossorigin="anonymous"
|
||||||
|
/>
|
||||||
<title>{metaTitle}</title>
|
<title>{metaTitle}</title>
|
||||||
|
{
|
||||||
|
isProd && (
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
function loadAnalytics() {
|
||||||
|
var script = document.createElement('script');
|
||||||
|
script.src = "https://analytics.atri.dad/script.js";
|
||||||
|
script.defer = true;
|
||||||
|
script.setAttribute('data-website-id', 'c7e24af4-5f14-4881-9c25-85a97abda9f1');
|
||||||
|
document.head.appendChild(script);
|
||||||
|
}
|
||||||
|
if ('requestIdleCallback' in window) {
|
||||||
|
requestIdleCallback(loadAnalytics, { timeout: 2000 });
|
||||||
|
} else {
|
||||||
|
setTimeout(loadAnalytics, 2000);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
)
|
||||||
|
}
|
||||||
</head>
|
</head>
|
||||||
<body class="min-h-screen flex flex-col bg-base-100">
|
<body class="min-h-screen flex flex-col bg-base-100 antialiased">
|
||||||
<main class="flex-grow">
|
<Header />
|
||||||
|
<main class="grow flex flex-col">
|
||||||
<slot />
|
<slot />
|
||||||
</main>
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
|
|||||||
19
src/pages/404.astro
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
---
|
||||||
|
import Layout from "../layouts/Layout.astro";
|
||||||
|
import FuzzyText from "../components/FuzzyText.vue";
|
||||||
|
---
|
||||||
|
|
||||||
|
<Layout title="404 - Not Found">
|
||||||
|
<div class="grow w-full flex flex-col items-center justify-center">
|
||||||
|
<FuzzyText
|
||||||
|
text="404"
|
||||||
|
:font-size="140"
|
||||||
|
font-weight="900"
|
||||||
|
color="#0072db"
|
||||||
|
:enable-hover="true"
|
||||||
|
:base-intensity="0.18"
|
||||||
|
:hover-intensity="0.5"
|
||||||
|
client:load
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
@@ -42,6 +42,9 @@ const sendEmailViaSMTP = async ({
|
|||||||
user: smtpUser,
|
user: smtpUser,
|
||||||
pass: smtpPassword,
|
pass: smtpPassword,
|
||||||
},
|
},
|
||||||
|
tls: {
|
||||||
|
rejectUnauthorized: false,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
26
src/pages/api/status.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import type { APIRoute } from "astro";
|
||||||
|
|
||||||
|
export const prerender = false;
|
||||||
|
|
||||||
|
export const GET: APIRoute = async () => {
|
||||||
|
const statusText = process.env.STATUS_TEXT
|
||||||
|
? process.env.STATUS_TEXT
|
||||||
|
: import.meta.env.STATUS_TEXT || "Accepting new clients";
|
||||||
|
const statusColor = process.env.STATUS_COLOR
|
||||||
|
? process.env.STATUS_COLOR
|
||||||
|
: import.meta.env.STATUS_COLOR || "green";
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
text: statusText,
|
||||||
|
color: statusColor,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Cache-Control": "public, max-age=60, stale-while-revalidate=300",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,14 +1,18 @@
|
|||||||
---
|
---
|
||||||
|
export const prerender = true;
|
||||||
|
|
||||||
import Layout from "../layouts/Layout.astro";
|
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.tsx";
|
import ContactSection from "../components/sections/ContactSection.vue";
|
||||||
|
import Section from "../components/Section.astro";
|
||||||
|
|
||||||
const pageMetaInfo = {
|
const pageMetaInfo = {
|
||||||
title: siteConfig.name,
|
title: siteConfig.name,
|
||||||
description: `Welcome to ${siteConfig.name} - ${siteConfig.description}`,
|
description: `${siteConfig.name} - ${siteConfig.description}`,
|
||||||
};
|
};
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -16,5 +20,12 @@ const pageMetaInfo = {
|
|||||||
<HeroSection />
|
<HeroSection />
|
||||||
<ServicesSection />
|
<ServicesSection />
|
||||||
<AboutSection />
|
<AboutSection />
|
||||||
<ContactSection client:load />
|
<ClientList />
|
||||||
|
<Section
|
||||||
|
id="contact"
|
||||||
|
title={siteConfig.contact.mainTitle}
|
||||||
|
background="bg-base-200"
|
||||||
|
>
|
||||||
|
<ContactSection client:visible />
|
||||||
|
</Section>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -26,9 +26,9 @@
|
|||||||
--color-warning-content: oklch(20% 0 0);
|
--color-warning-content: oklch(20% 0 0);
|
||||||
--color-error: oklch(60% 0.25 30);
|
--color-error: oklch(60% 0.25 30);
|
||||||
--color-error-content: oklch(98% 0 0);
|
--color-error-content: oklch(98% 0 0);
|
||||||
--radius-selector: 1rem;
|
--radius-selector: 0.5rem;
|
||||||
--radius-field: 1rem;
|
--radius-field: 0.5rem;
|
||||||
--radius-box: 1rem;
|
--radius-box: 0.75rem;
|
||||||
--size-selector: 0.25rem;
|
--size-selector: 0.25rem;
|
||||||
--size-field: 0.25rem;
|
--size-field: 0.25rem;
|
||||||
--border: 1px;
|
--border: 1px;
|
||||||
@@ -36,7 +36,18 @@
|
|||||||
--noise: 0;
|
--noise: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Only essential styles - all visual styling handled by Tailwind */
|
|
||||||
html {
|
html {
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Roboto Slab";
|
||||||
|
src: url("/fonts/roboto.woff2") format("woff2");
|
||||||
|
font-weight: 100 900;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: "Roboto Slab", serif;
|
||||||
|
}
|
||||||
|
|||||||