Compare commits

..

81 Commits

Author SHA1 Message Date
e2571880ce Deps (no more beta)
All checks were successful
Docker Deploy / build-and-push (push) Successful in 5m26s
2026-03-03 13:31:12 -07:00
0eafcb9a67 Update bun.lock 2026-03-03 13:23:32 -07:00
9a2e7f65cb Deps
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m28s
2026-03-01 01:17:06 -07:00
e5110ddd75 Update site.ts
All checks were successful
Docker Deploy / build-and-push (push) Successful in 2m52s
2026-02-25 18:06:59 -07:00
3b2abe7a99 Attempt #3
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m31s
2026-02-25 16:49:34 -07:00
4cbe911b0c Attempt to fix FOUC
All checks were successful
Docker Deploy / build-and-push (push) Successful in 2m50s
2026-02-25 16:29:29 -07:00
75321034aa The fuckin lock file is pissing me off
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m30s
2026-02-25 13:02:48 -07:00
174afb6a10 Pls
Some checks failed
Docker Deploy / build-and-push (push) Failing after 1m49s
2026-02-25 10:40:48 -07:00
483e80db79 Small fix to the CSS animation nonsense
All checks were successful
Docker Deploy / build-and-push (push) Successful in 2m41s
2026-02-25 08:22:55 -07:00
a303b8be00 Update ContactSection.vue
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m47s
2026-02-24 23:48:25 -07:00
bb0b348069 Update bun.lock 2026-02-24 23:24:39 -07:00
cf2195b4f3 Update package.json
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m28s
2026-02-24 23:14:57 -07:00
6de9c9c83b Update package.json 2026-02-24 23:14:41 -07:00
e3787281fd ???
All checks were successful
Docker Deploy / build-and-push (push) Successful in 5m5s
2026-02-24 18:05:38 -07:00
255abd508d Ok try this
Some checks failed
Docker Deploy / build-and-push (push) Failing after 6m6s
2026-02-24 17:57:25 -07:00
07561a4335 Create bun.lock
Some checks failed
Docker Deploy / build-and-push (push) Has been cancelled
2026-02-24 17:51:22 -07:00
22d3b9d7df Move to Bun 2026-02-24 17:51:12 -07:00
c5fc1cedd7 Fuck it... right now we don't need CSP. I do nothing useful on this
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m19s
site.
2026-02-24 16:57:18 -07:00
cbdab153da I guess DaisyUI has rotating text now... awesome!
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m25s
2026-02-24 16:50:00 -07:00
6dea3ac96c Remove partytown
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m32s
2026-02-24 16:26:11 -07:00
8c5556eb3c Yelling at me to add this
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m28s
2026-02-24 14:49:22 -07:00
50d8f2a9aa ugh
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m0s
2026-02-24 12:34:39 -07:00
e9845965d7 Update Layout.astro
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m12s
2026-02-24 11:46:08 -07:00
737a4dd7e8 Slop-free
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m49s
2026-02-23 11:54:27 -07:00
c31d0b5589 Docker optimization
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m10s
2026-02-12 15:09:31 -07:00
58902e081a Cleaned up Icon components
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m11s
2026-02-12 14:23:33 -07:00
62dcec8202 Moved to keeping the SVGs in repo
Some checks failed
Docker Deploy / build-and-push (push) Failing after 3m7s
2026-02-12 13:15:34 -07:00
b15dce4cd4 deps
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m34s
2026-02-09 13:12:34 -07:00
25af507805 Added marew
Some checks failed
Docker Deploy / build-and-push (push) Has been cancelled
2026-02-09 13:12:21 -07:00
8b8b60b302 Added links for clients
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m23s
2026-02-06 00:14:57 -07:00
59577f0e58 Update astro.config.mjs
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m20s
2026-01-30 14:57:28 -07:00
8bd4ccbafb Switch to auto
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m15s
2026-01-30 14:54:07 -07:00
beaf088391 Party again
Some checks failed
Docker Deploy / build-and-push (push) Failing after 3m6s
2026-01-30 14:48:24 -07:00
f8555413e1 :(
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m29s
2026-01-30 14:41:29 -07:00
7c24cf61d5 Security headers
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m3s
2026-01-30 13:57:41 -07:00
f70ce24bcb Small text update to make it less cringe
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m34s
2026-01-30 13:51:58 -07:00
a089c9dfc7 PARTY!!!!!
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m19s
2026-01-30 13:37:58 -07:00
c03128a314 Analytics
Some checks failed
Docker Deploy / build-and-push (push) Has been cancelled
2026-01-30 13:36:50 -07:00
cf4a4827df Moved the scrolling text to Vue since it needs some JS and custom
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m16s
styling not available in Tailwind
2026-01-30 13:28:37 -07:00
f7fd011660 Duplicate config
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m31s
2026-01-30 11:53:00 -07:00
3b2fca97aa Fixed button shadow
Some checks failed
Docker Deploy / build-and-push (push) Has been cancelled
2026-01-30 11:50:48 -07:00
1ddd73431b Updated Saikyo Logo
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m38s
2026-01-30 11:40:30 -07:00
332794d62c Update ClientList.astro
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m17s
2026-01-30 11:13:33 -07:00
d339e66cf0 Removed rush 2026-01-30 11:04:57 -07:00
7d731c3857 Made the feedback a bit cleaner for the form
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m35s
2026-01-29 15:57:14 -07:00
6a44f1943e More minor style fixes
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m18s
2026-01-29 15:39:13 -07:00
6cec6ef02f Style tweaking
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m28s
2026-01-29 15:30:04 -07:00
9c95362800 Added a new logo loop + optimized the component setup (DRY)
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m26s
2026-01-29 13:48:37 -07:00
3e89a109ec Deps
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m21s
2026-01-26 20:11:44 -07:00
e146ea311d Added proper 404
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m8s
2026-01-25 21:51:49 -07:00
6b51d34490 Moved to Vue. Making it Vuetiful
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m29s
2026-01-25 16:17:20 -07:00
58ae6d5d0c Fixed font issues
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m29s
2026-01-16 10:42:42 -07:00
81723ebdfa ???
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m37s
2026-01-16 00:18:08 -07:00
b0e918a93d Update budgets
Some checks failed
Docker Deploy / build-and-push (push) Has been cancelled
2026-01-16 00:17:54 -07:00
a0f6e5ad30 Deps
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m29s
2026-01-15 23:48:01 -07:00
33c3f02412 Small changes
Some checks failed
Docker Deploy / build-and-push (push) Has been cancelled
2026-01-15 23:47:30 -07:00
6cdd6202da Fixed font popin
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m35s
2026-01-04 18:54:45 -07:00
0d8d4a8d09 This should fix it... turns out prerendering broke the status
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m36s
2026-01-04 01:42:00 -07:00
f5a8a2e5b4 Maybe this helps?
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m24s
2026-01-01 13:33:10 -07:00
91e1be00f5 Optimizations
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m2s
2026-01-01 00:58:20 -07:00
aad65a3a58 Deps
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m16s
2025-12-30 00:44:58 -07:00
8242260355 Bumped Node
All checks were successful
Docker Deploy / build-and-push (push) Successful in 2m53s
2025-12-25 23:39:16 -07:00
e1313b7184 Re-added nix :)
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m25s
2025-12-25 02:11:35 -07:00
edcafd9355 Oof
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m25s
2025-12-22 16:04:26 -07:00
a7d8510f93 What
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m21s
2025-12-22 15:25:02 -07:00
ecfc163255 Added scrolling text
Some checks failed
Build and Deploy / build-and-push (push) Failing after 51s
2025-12-22 15:10:17 -07:00
90cd82d320 remove this animation
All checks were successful
Build and Deploy / build-and-push (push) Successful in 2m45s
2025-12-22 14:49:55 -07:00
0ca5d4096f More deps
Some checks failed
Build and Deploy / build-and-push (push) Has been cancelled
2025-12-22 14:48:37 -07:00
a33c106785 More text changes
Some checks are pending
Build and Deploy / build-and-push (push) Has started running
2025-12-22 14:48:21 -07:00
f65e5d1c0c Apparently variable fonts are better... thanks reddit!
All checks were successful
Build and Deploy / build-and-push (push) Successful in 2m47s
2025-12-22 13:59:16 -07:00
8341938692 Optimizations
All checks were successful
Build and Deploy / build-and-push (push) Successful in 2m40s
2025-12-22 13:05:29 -07:00
03792ce1d6 Oops
All checks were successful
Build and Deploy / build-and-push (push) Successful in 2m45s
2025-12-22 12:50:58 -07:00
2630071315 pls
All checks were successful
Build and Deploy / build-and-push (push) Successful in 2m37s
2025-12-22 12:37:07 -07:00
11c4e61e79 Changed some config
All checks were successful
Build and Deploy / build-and-push (push) Successful in 2m47s
2025-12-22 12:23:25 -07:00
c1387b9cd7 Languagre changes
All checks were successful
Build and Deploy / build-and-push (push) Successful in 2m45s
2025-12-20 15:32:20 -07:00
6ca843a4f8 Fixed some wording
All checks were successful
Build and Deploy / build-and-push (push) Successful in 2m54s
2025-12-20 00:56:32 -07:00
0297d0a8ef Updated logo (quick and dirty basic logo)
All checks were successful
Build and Deploy / build-and-push (push) Successful in 2m44s
2025-12-18 15:20:03 -07:00
d3a85945ed oof fixed certs
All checks were successful
Build and Deploy / build-and-push (push) Successful in 2m37s
2025-12-18 14:08:08 -07:00
7517833c93 Fixed opacity
All checks were successful
Build and Deploy / build-and-push (push) Successful in 2m37s
2025-12-18 13:52:34 -07:00
79c8e6bba8 More deps 2025-12-18 13:47:09 -07:00
279d70e71e Smal changes to email
All checks were successful
Build and Deploy / build-and-push (push) Successful in 2m43s
2025-12-18 13:46:44 -07:00
52 changed files with 3090 additions and 5900 deletions

View File

@@ -1,13 +1,15 @@
# Container Image
# Docker Configuration
IMAGE=atashdotdev:latest
# Application Port
APP_PORT=4321
# SMTP Configuration (required for contact form)
# Application Configuration
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=your-email@example.com
SMTP_PASSWORD=your-password
FROM_EMAIL=noreply@atash.dev
TO_EMAIL=contact@atash.dev
# Site Status
STATUS_TEXT="Accepting new clients"
STATUS_COLOR="green" # green, yellow, red

1
.envrc Normal file
View File

@@ -0,0 +1 @@
use flake

View 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

View File

@@ -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
View File

@@ -23,3 +23,6 @@ pnpm-debug.log*
# jetbrains setting folder
.idea/
# nix
.direnv/

View File

@@ -1,27 +1,28 @@
FROM node:lts-alpine AS builder
FROM oven/bun:1.3.9-alpine AS base
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 ./
RUN pnpm install
FROM base AS builder
COPY package.json bun.lock ./
RUN --mount=type=cache,id=bun,target=/root/.bun/install/cache \
bun install --frozen-lockfile || bun install
COPY . .
RUN pnpm run build
RUN bun run build
FROM node:lts-alpine AS runtime
FROM base AS runtime
WORKDIR /app
RUN npm i -g pnpm
COPY --from=builder /app/dist ./dist
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --prod
COPY --from=prod-deps /app/node_modules ./node_modules
COPY package.json ./
ENV HOST=0.0.0.0
ENV PORT=4321
EXPOSE 4321
CMD ["node", "./dist/server/entry.mjs"]
CMD ["bun", "run", "./dist/server/entry.mjs"]

View File

@@ -1,5 +1,3 @@
# Atash Website
This project requires Nix with Flakes enabled.
Run ```nix develop``` in order to install dependencies. Then, use pnpm as normal.
:)

View File

@@ -1,15 +1,17 @@
// @ts-check
import { defineConfig } from "astro/config";
import solidJs from "@astrojs/solid-js";
import vue from "@astrojs/vue";
import node from "@astrojs/node";
import tailwindcss from "@tailwindcss/vite";
// https://astro.build/config
const isDev = process.env.NODE_ENV === "development";
export default defineConfig({
integrations: [solidJs()],
output: "server",
build: {
inlineStylesheets: "auto",
},
integrations: [vue()],
adapter: node({
mode: "standalone",

1146
bun.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -13,4 +13,6 @@ services:
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
FROM_EMAIL: ${FROM_EMAIL:-noreply@atash.dev}
TO_EMAIL: ${TO_EMAIL:-}
STATUS_TEXT: ${STATUS_TEXT:-"Accepting new clients"}
STATUS_COLOR: ${STATUS_COLOR:-green}
restart: unless-stopped

46
flake.lock generated
View File

@@ -1,59 +1,25 @@
{
"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": {
"locked": {
"lastModified": 1753250450,
"narHash": "sha256-i+CQV2rPmP8wHxj0aq4siYyohHwVlsh40kV89f3nw1s=",
"owner": "NixOS",
"lastModified": 1766473571,
"narHash": "sha256-5G1NDO2PulBx1RoaA6U1YoUDX0qZslpPxv+n5GX6Qto=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "fc02ee70efb805d3b2865908a13ddd4474557ecf",
"rev": "76701a179d3a98b07653e2b0409847499b2a07d3",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"owner": "nixos",
"ref": "nixos-25.11",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"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",

View File

@@ -1,84 +1,35 @@
{
description = "Development environment for atashdotdev with Node and pnpm";
description = "atashdotdev dev shell";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
nixpkgs.url = "github:nixos/nixpkgs/nixos-25.11";
};
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
outputs = { self, nixpkgs }:
let
pkgs = nixpkgs.legacyPackages.${system};
version = (pkgs.lib.importJSON ./package.json).version;
app = pkgs.stdenv.mkDerivation {
pname = "atashdotdev";
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
'';
};
allSystems = [
"x86_64-linux"
"aarch64-linux"
"x86_64-darwin"
"aarch64-darwin"
];
forAllSystems = f: nixpkgs.lib.genAttrs allSystems (system: f {
pkgs = import nixpkgs { inherit system; };
});
in
{
# Dev shell
devShells.default = pkgs.mkShell {
packages = with pkgs; [ nodejs_24 nodePackages.pnpm ];
shellHook = ''
echo "🚀 atashdotdev development environment loaded!"
echo "Node version: $(node --version)"
echo "pnpm version: $(pnpm --version)"
devShells = forAllSystems ({ pkgs }: {
default = pkgs.mkShell {
packages = with pkgs; [
bun
];
if [ ! -d "node_modules" ]; then
echo "📦 Installing pnpm dependencies..."
pnpm install --frozen-lockfile
fi
shellHook = ''
echo "<atashdotdev dev shell>"
echo "Bun version: $(bun --version)"
'';
};
# 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";
};
};
});
};
}

View File

@@ -1,26 +1,25 @@
{
"name": "atashdotdev",
"type": "module",
"version": "1.1.1",
"version": "2.0.0",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"nix:run": "nix develop",
"astro": "astro"
},
"dependencies": {
"@astrojs/node": "^9.5.1",
"@astrojs/solid-js": "^5.1.3",
"@tailwindcss/vite": "^4.1.18",
"astro": "^5.16.6",
"nodemailer": "^7.0.11",
"solid-js": "^1.9.10",
"tailwindcss": "^4.1.18"
"@astrojs/node": "9.5.4",
"@astrojs/vue": "5.1.4",
"@tailwindcss/vite": "^4.2.1",
"astro": "5.18.0",
"nodemailer": "^8.0.1",
"tailwindcss": "^4.2.1",
"vue": "^3.5.29"
},
"devDependencies": {
"@types/node": "^25.0.3",
"@types/nodemailer": "^7.0.4",
"daisyui": "^5.5.14"
"@types/node": "^25.3.3",
"@types/nodemailer": "^7.0.11",
"daisyui": "^5.5.19"
}
}

5165
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 291 KiB

BIN
public/favicon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 617 B

BIN
public/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -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

Binary file not shown.

BIN
public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

5
public/logo.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 48 KiB

BIN
public/logo.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -7,7 +7,6 @@ import { siteConfig } from "../config/site";
<div class="footer footer-center py-10">
<aside class="space-y-4">
<div class="flex items-center gap-2 text-2xl font-bold">
<span>🔥</span>
<span>{siteConfig.name}</span>
</div>
<p class="text-neutral-content/70 max-w-md">
@@ -26,8 +25,8 @@ import { siteConfig } from "../config/site";
</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">Copyright</span>
© {new Date().getFullYear()} {siteConfig.name}. All rights reserved.
<span class="sr-only">{siteConfig.footer.copyright}</span>
© {new Date().getFullYear()} {siteConfig.name}. {siteConfig.footer.rights}
</p>
</div>
</div>

View 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>

View File

@@ -1,36 +1,36 @@
---
import { siteConfig } from "../config/site";
import Icon from "./Icon.astro";
import { Image } from "astro:assets";
---
<header class="sticky top-0 z-50 backdrop-blur-lg bg-base-100/80 border-b border-base-200" 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="dropdown">
<div tabindex="0" role="button" class="btn btn-ghost btn-circle lg:hidden">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
<div
tabindex="0"
role="button"
class="btn btn-ghost btn-circle lg:hidden"
aria-label="Open menu"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h8m-8 6h16"></path>
</svg>
<Icon name="bars-3-bottom-left" class="h-5 w-5" />
</div>
<ul
tabindex="0"
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 }) => (
<li role="none">
<a href={href} role="menuitem" class="py-3 px-4 rounded-lg font-medium hover:bg-primary/10 hover:text-primary transition-colors">
<li>
<a
href={href}
class="py-3 px-4 rounded-lg font-medium hover:bg-primary/10 hover:text-primary transition-colors"
>
{text}
</a>
</li>
@@ -40,20 +40,33 @@ import { siteConfig } from "../config/site";
</div>
<a
href={siteConfig.header.logo.href}
class="btn btn-ghost text-xl font-bold tracking-tight hover:bg-transparent hover:text-primary transition-colors"
class="btn btn-ghost text-xl font-bold tracking-tight hover:bg-transparent hover:text-primary transition-colors gap-2"
aria-label="Home"
>
<span class="text-primary">🔥</span>
<span class="hidden sm:inline">{siteConfig.header.logo.text}</span>
<span class="sm:hidden">Atash</span>
<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>
</div>
<nav class="navbar-center hidden lg:flex" aria-label="Main navigation">
<ul class="menu menu-horizontal gap-1" role="menubar">
<ul class="menu menu-horizontal gap-1">
{
siteConfig.header.nav.map(({ text, href }) => (
<li role="none">
<a href={href} role="menuitem" class="font-medium px-4 py-2 rounded-lg hover:bg-primary/10 hover:text-primary transition-colors">
<li>
<a
href={href}
class="font-medium px-4 py-2 rounded-lg hover:bg-primary/10 hover:text-primary transition-colors"
>
{text}
</a>
</li>
@@ -65,7 +78,7 @@ import { siteConfig } from "../config/site";
<a
href={siteConfig.header.cta.href}
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}
</a>

27
src/components/Icon.astro Normal file
View 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
View 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
View File

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

View File

@@ -0,0 +1,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>

View 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>

View 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>

View File

@@ -1,73 +1,84 @@
<section id="about" class="py-20 lg:py-28">
<div class="max-w-7xl mx-auto px-6">
<div class="text-center mb-16">
<span class="badge badge-secondary badge-lg font-semibold mb-4">Why Us</span>
<h2 class="text-3xl lg:text-4xl font-bold text-base-content mb-4">
Why Choose Atash Consulting?
</h2>
<p class="text-lg text-base-content/60 max-w-2xl mx-auto">
With over a decade of experience in the software industry, we bring deep
technical expertise and a commitment to excellence to every project.
</p>
</div>
---
import Icon from "../Icon.astro";
import { type IconName } from "../../config/icons";
import { siteConfig } from "../../config/site";
import Section from "../Section.astro";
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">
<div class="group text-center p-8 rounded-2xl bg-base-100 border border-base-300/50 hover:border-primary/30 hover:shadow-xl transition-all duration-300">
<div class="w-16 h-16 bg-primary/10 rounded-2xl flex items-center justify-center mx-auto mb-6 group-hover:scale-110 transition-transform duration-300">
<svg class="w-8 h-8 text-primary" 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>
{
features.map((feature) => {
const styles =
variantStyles[feature.variant] || variantStyles.primary;
return (
<div class="group text-center p-8 rounded-2xl bg-base-200 border border-base-300/50 hover:border-primary/30 shadow-sm hover:shadow-xl transition-all duration-300 hover:-translate-y-1">
<div
class:list={[
"w-16 h-16 rounded-2xl flex items-center justify-center mx-auto mb-6 group-hover:scale-110 transition-transform duration-300",
styles.bg,
]}
>
<Icon
name={feature.icon}
class:list={["w-8 h-8", styles.text]}
/>
</div>
<h3 class="text-xl font-bold text-base-content mb-3">Fast Delivery</h3>
<h3 class="text-xl font-bold text-base-content mb-3">
{feature.title}
</h3>
<p class="text-base-content/60 leading-relaxed">
Quick turnaround without compromising quality
{feature.content}
</p>
</div>
<div class="group text-center p-8 rounded-2xl bg-base-100 border border-base-300/50 hover:border-secondary/30 hover:shadow-xl transition-all duration-300">
<div class="w-16 h-16 bg-secondary/10 rounded-2xl flex items-center justify-center mx-auto mb-6 group-hover:scale-110 transition-transform duration-300">
<svg class="w-8 h-8 text-secondary" fill="none" 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>
</div>
<h3 class="text-xl font-bold text-base-content mb-3">Quality Assured</h3>
<p class="text-base-content/60 leading-relaxed">
Rigorous testing and quality control processes
</p>
);
})
}
</div>
<div class="group text-center p-8 rounded-2xl bg-base-100 border border-base-300/50 hover:border-accent/30 hover:shadow-xl transition-all duration-300">
<div class="w-16 h-16 bg-accent/10 rounded-2xl flex items-center justify-center mx-auto mb-6 group-hover:scale-110 transition-transform duration-300">
<svg class="w-8 h-8 text-accent" 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 mb-3">Expert Support</h3>
<p class="text-base-content/60 leading-relaxed">
Ongoing support and maintenance services
</p>
</div>
</div>
<div class="mt-20 p-8 lg:p-12 rounded-2xl bg-gradient-to-br from-neutral to-neutral/90">
<div class="grid grid-cols-2 lg:grid-cols-4 gap-8">
<div
class="mt-20 p-8 lg:p-12 rounded-2xl bg-linear-to-br from-neutral to-neutral/90"
>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-8">
{
siteConfig.whyUs.stats.map((stat) => (
<div class="text-center">
<div class="text-3xl lg:text-4xl font-bold text-neutral-content mb-2">10+</div>
<div class="text-neutral-content/60 text-sm font-medium">Years Experience</div>
<div class="text-3xl lg:text-4xl font-bold text-neutral-content mb-2">
{stat.value}
</div>
<div class="text-center">
<div class="text-3xl lg:text-4xl font-bold text-neutral-content mb-2">50+</div>
<div class="text-neutral-content/60 text-sm font-medium">Projects Delivered</div>
</div>
<div class="text-center">
<div class="text-3xl lg:text-4xl font-bold text-neutral-content mb-2">100%</div>
<div class="text-neutral-content/60 text-sm font-medium">Client Satisfaction</div>
</div>
<div class="text-center">
<div class="text-3xl lg:text-4xl font-bold text-neutral-content mb-2">24/7</div>
<div class="text-neutral-content/60 text-sm font-medium">Support Available</div>
<div class="text-neutral-content/60 text-sm font-medium">
{stat.label}
</div>
</div>
))
}
</div>
</div>
</section>
</Section>

View File

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

View File

@@ -1,291 +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-20 lg:py-28 bg-base-200/50">
<div class="max-w-7xl mx-auto px-6">
<div class="text-center mb-16">
<span class="badge badge-accent badge-lg font-semibold mb-4">Contact</span>
<h2 class="text-3xl lg:text-4xl font-bold text-base-content mb-4">
Get In Touch
</h2>
<p class="text-lg text-base-content/60 max-w-2xl mx-auto">
Ready to start your project? Let's discuss how we can help.
</p>
</div>
<div class="max-w-2xl mx-auto">
<div class="card bg-base-100 border border-base-300/50 shadow-xl">
<div class="card-body p-8 lg:p-10">
<form class="space-y-6" onSubmit={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">
First Name *
</legend>
<input
type="text"
name="firstName"
class="input input-bordered w-full bg-base-100 focus:border-primary focus:outline-primary"
required
value={firstName()}
onInput={(e) => setFirstName(e.currentTarget.value)}
disabled={status() === "sending"}
placeholder="John"
/>
</fieldset>
<fieldset class="fieldset">
<legend class="fieldset-legend text-sm font-semibold text-base-content">
Last Name *
</legend>
<input
type="text"
name="lastName"
class="input input-bordered w-full bg-base-100 focus:border-primary focus:outline-primary"
required
value={lastName()}
onInput={(e) => setLastName(e.currentTarget.value)}
disabled={status() === "sending"}
placeholder="Doe"
/>
</fieldset>
</div>
<fieldset class="fieldset">
<legend class="fieldset-legend text-sm font-semibold text-base-content">
Email Address *
</legend>
<input
type="email"
name="email"
class="input input-bordered w-full bg-base-100 focus:border-primary focus:outline-primary"
required
value={email()}
onInput={(e) => setEmail(e.currentTarget.value)}
disabled={status() === "sending"}
placeholder="john@example.com"
/>
</fieldset>
<fieldset class="fieldset">
<legend class="fieldset-legend text-sm font-semibold text-base-content">
Company
</legend>
<input
type="text"
name="company"
class="input input-bordered w-full bg-base-100 focus:border-primary focus:outline-primary"
value={company()}
onInput={(e) => setCompany(e.currentTarget.value)}
disabled={status() === "sending"}
placeholder="Your company name (optional)"
/>
</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">
Service Needed
</legend>
<select
name="service"
class="select select-bordered w-full bg-base-100 focus:border-primary focus:outline-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>
</fieldset>
<fieldset class="fieldset">
<legend class="fieldset-legend text-sm font-semibold text-base-content">
Project Budget
</legend>
<select
name="budget"
class="select select-bordered w-full bg-base-100 focus:border-primary focus:outline-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>
</fieldset>
</div>
<fieldset class="fieldset">
<legend class="fieldset-legend text-sm font-semibold text-base-content">
Project Details *
</legend>
<textarea
id="project-details"
name="message"
class="textarea textarea-bordered h-36 w-full resize-none bg-base-100 focus:border-primary focus:outline-primary"
placeholder="Tell me about your project requirements, timeline, and any specific needs..."
required
value={message()}
onInput={(e) => setMessage(e.currentTarget.value)}
disabled={status() === "sending"}
/>
</fieldset>
<Show when={status() === "error"}>
<div role="alert" class="alert alert-error">
<svg
xmlns="http://www.w3.org/2000/svg"
class="stroke-current shrink-0 h-5 w-5"
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 class="text-sm">
{errorMessage() || "Error sending message. Please try again."}
</span>
</div>
</Show>
<Show when={status() === "success"}>
<div role="alert" class="alert alert-success">
<svg
xmlns="http://www.w3.org/2000/svg"
class="stroke-current shrink-0 h-5 w-5"
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 class="text-sm">Message sent successfully! We'll get back to you soon.</span>
</div>
</Show>
<button
type="submit"
class="btn btn-primary btn-lg w-full shadow-lg shadow-primary/25 hover:shadow-xl hover:shadow-primary/30 transition-all duration-300"
disabled={status() === "sending"}
>
{status() === "sending" ? (
<>
<span class="loading loading-spinner loading-sm"></span>
Sending...
</>
) : (
<>
<svg
class="w-5 h-5"
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>
</form>
</div>
</div>
<div class="mt-10 text-center">
<p class="text-base-content/60 mb-4">Prefer to reach out directly?</p>
<a
href="mailto:hello@atash.dev"
class="link link-primary font-medium inline-flex items-center gap-2 hover:gap-3 transition-all"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path>
</svg>
hello@atash.dev
</a>
</div>
</div>
</div>
</section>
);
};
export default ContactSection;

View 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>

View File

@@ -1,95 +1,92 @@
---
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 bg-neutral">
<div class="absolute inset-0 bg-[linear-gradient(to_right,#ffffff06_1px,transparent_1px),linear-gradient(to_bottom,#ffffff06_1px,transparent_1px)] bg-[size:4rem_4rem]"></div>
<div class="relative max-w-7xl mx-auto px-6 py-20 lg:py-32">
<div class="text-center max-w-4xl mx-auto">
<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">
<span class="relative flex h-2 w-2">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-success opacity-75"></span>
<span class="relative inline-flex rounded-full h-2 w-2 bg-success"></span>
</span>
Accepting new clients
<Section
fullWidth={true}
background="bg-neutral"
class="relative overflow-hidden"
>
<div
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>
<h1 class="text-4xl sm:text-5xl lg:text-6xl xl:text-7xl font-extrabold text-white leading-tight tracking-tight mb-6">
Building
Digital Solutions
<br class="hidden sm:block" />
That Drive Growth
<div class="relative max-w-7xl mx-auto px-6">
<div class="text-center max-w-4xl mx-auto">
<StatusIndicator client:idle />
<h1
class="text-4xl sm:text-5xl lg:text-6xl xl:text-7xl font-extrabold text-white leading-tight tracking-tight mb-6"
>
{
rotatingText ? (
<>
<RotatingText items={rotatingText} client:idle />
<span class="block">
{siteConfig.hero.mainTitle
.replace("{rotating}", "")
.trim()}
</span>
</>
) : (
siteConfig.hero.mainTitle
)
}
</h1>
<p class="text-lg sm:text-xl text-white/70 max-w-2xl mx-auto mb-10 leading-relaxed">
{siteConfig.hero.description}. Delivering reliable, scalable
solutions tailored to your business needs.
<p
class="text-lg sm:text-xl text-white max-w-2xl mx-auto mb-10 leading-relaxed"
>
{siteConfig.description}.
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center items-center">
<div
class="flex flex-col sm:flex-row gap-4 justify-center items-center"
>
<a
href="#contact"
class="btn btn-accent btn-lg shadow-lg shadow-accent/25 hover:shadow-xl hover:shadow-accent/30 hover:-translate-y-0.5 transition-all duration-300 group"
class="btn btn-accent btn-lg shadow-lg shadow-accent/25 hover:shadow-xl hover:shadow-accent/30"
>
<svg
class="w-5 h-5 transition-transform group-hover:rotate-12"
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
<Icon name="bolt" class="w-5 h-5" />
{siteConfig.hero.cta}
</a>
<a
href="#services"
class="btn btn-outline btn-lg border-white/30 text-white hover:bg-white hover:text-neutral hover:border-white transition-all duration-300"
class="btn btn-outline btn-lg border-white text-white hover:bg-white hover:text-neutral hover:border-white transition-all duration-300"
>
<svg
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"></path>
</svg>
Explore Services
<Icon name="chevron-down" class="w-5 h-5" />
{siteConfig.hero.secondaryCta}
</a>
</div>
<div class="mt-16 pt-10 border-t border-white/10">
<p class="text-white/50 text-sm mb-6 uppercase tracking-wider font-medium">Trusted expertise in</p>
<div class="flex flex-wrap justify-center items-center gap-8 lg:gap-12">
<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">
<svg class="w-5 h-5" 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"></path>
</svg>
<span class="font-medium">Web Development</span>
<Icon name={feature.icon} class="w-5 h-5" />
<span class="font-medium">{feature.text}</span>
</div>
<div class="flex items-center gap-2.5 text-white/80">
<svg class="w-5 h-5" 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"></path>
</svg>
<span class="font-medium">Mobile Apps</span>
</div>
<div class="flex items-center gap-2.5 text-white/80">
<svg class="w-5 h-5" 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>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
<span class="font-medium">DevOps</span>
))
}
</div>
</div>
</div>
</div>
</div>
</section>
</Section>

View File

@@ -1,41 +1,37 @@
---
import { siteConfig } from "../../config/site";
import Icon from "../Icon.astro";
import Section from "../Section.astro";
const serviceIcons = [
`<svg class="w-6 h-6" 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>`,
`<svg class="w-6 h-6" 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>`,
`<svg class="w-6 h-6" 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>`,
`<svg class="w-6 h-6" 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>`,
];
const variantStyles: Record<string, string> = {
primary: "bg-primary/10 text-primary",
secondary: "bg-secondary/10 text-secondary",
accent: "bg-accent/10 text-accent",
};
---
<section id="services" class="py-20 lg:py-28 bg-base-200/50">
<div class="max-w-7xl mx-auto px-6">
<div class="text-center mb-16">
<span class="badge badge-primary badge-lg font-semibold mb-4">What We Do</span>
<h2 class="text-3xl lg:text-4xl font-bold text-base-content mb-4">
Our Services
</h2>
<p class="text-lg text-base-content/60 max-w-2xl mx-auto">
Comprehensive software solutions designed to drive your business forward
</p>
</div>
<Section
id="services"
background="bg-base-100"
title={siteConfig.services.title}
>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 lg:gap-8">
{
siteConfig.featureCards.cards.map((card, index) => (
siteConfig.services.cards.map((card) => {
const styles =
variantStyles[card.variant] || variantStyles.primary;
return (
<div class="group card bg-base-100 border border-base-300/50 hover:border-primary/30 shadow-sm hover:shadow-xl transition-all duration-300 hover:-translate-y-1">
<div class="card-body p-8">
<div class="flex items-start gap-5">
<div
class={`
flex-shrink-0 w-14 h-14 rounded-xl flex items-center justify-center transition-transform duration-300 group-hover:scale-110
${card.variant === "primary" ? "bg-primary/10 text-primary" : ""}
${card.variant === "secondary" ? "bg-secondary/10 text-secondary" : ""}
${card.variant === "accent" ? "bg-accent/10 text-accent" : ""}
`}
class:list={[
"shrink-0 w-14 h-14 rounded-xl flex items-center justify-center transition-transform duration-300 group-hover:scale-110",
styles,
]}
>
<Fragment set:html={serviceIcons[index]} />
<Icon name={card.icon} class="w-6 h-6" />
</div>
<div class="flex-1 space-y-3">
<h3 class="text-xl font-bold text-base-content group-hover:text-primary transition-colors">
@@ -44,20 +40,12 @@ const serviceIcons = [
<p class="text-base-content/60 leading-relaxed">
{card.content}
</p>
<div class="pt-2">
<a href="#contact" class="inline-flex items-center gap-2 text-primary font-medium text-sm opacity-0 group-hover:opacity-100 transition-opacity duration-300">
Learn more
<svg class="w-4 h-4 transition-transform group-hover:translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
</a>
</div>
</div>
</div>
</div>
</div>
))
);
})
}
</div>
</div>
</section>
</Section>

29
src/config/icons.ts Normal file
View 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;

View File

@@ -1,3 +1,5 @@
import type { IconName } from "./icons";
type Card = {
title: string;
content: string;
@@ -5,8 +7,9 @@ type Card = {
};
export const siteConfig = {
siteUrl: "https://atash.dev",
name: "Atash Consulting",
description: "Software Consulting based in Edmonton, Alberta",
description: "Independent Software Consulting based in Edmonton, Alberta",
header: {
logo: {
@@ -16,67 +19,191 @@ export const siteConfig = {
nav: [
{
text: "Home",
href: "#",
href: "/",
},
{
text: "Services",
href: "#services",
href: "/#services",
},
{
text: "About",
href: "#about",
href: "/#about",
},
{
text: "Contact",
href: "#contact",
href: "/#contact",
},
],
cta: {
text: "Get Started",
href: "#contact",
text: "Let's Talk",
href: "/#contact",
},
mobileLogoText: "Atash",
},
hero: {
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: {
enabled: true,
services: {
title: "Our Services",
cards: [
{
title: "Web Development",
content: "Functional, accessible, and beautiful websites.",
variant: "primary",
icon: "computer-desktop",
},
{
title: "Mobile App Development",
content: "iOS, Android, and cross-platform mobile applications.",
variant: "secondary",
icon: "device-phone-mobile",
},
{
title: "DevOps",
content: "Anything from CI/CD to end-to-end UI testing.",
content: "CI/CD pipelines end-to-end automation.",
variant: "secondary",
icon: "cog-6-tooth",
},
{
title: "IT Support Processes",
content:
"We 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",
icon: "lifebuoy",
},
] as (Card & { icon: IconName })[],
},
whyUs: {
title: "Why Partner With Us?",
cards: [
{
title: "Fast Delivery",
content: "Efficient delivery without compromising quality",
variant: "primary",
},
] as Card[],
{
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",
description: "Ready to get started? Reach out to us for a consultation.",
cta: {
text: "Get in Touch",
href: "#contact",
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;

View File

@@ -7,13 +7,36 @@ import "../styles/global.css";
interface Props {
title?: string;
description?: string;
ogImage?: {
url: string;
width: number;
height: number;
type: string;
alt: string;
};
}
const { title = siteConfig.name, description = siteConfig.description } =
Astro.props;
const {
title = siteConfig.name,
description = siteConfig.description,
ogImage,
} = Astro.props;
const metaTitle =
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>
@@ -21,20 +44,58 @@ const metaTitle =
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link
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>"
/>
<link rel="icon" type="image/x-icon" href="favicon.ico" />
<meta name="generator" content={Astro.generator} />
<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>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
{
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>
<body class="min-h-screen flex flex-col bg-base-100 font-sans antialiased">
<body class="min-h-screen flex flex-col bg-base-100 antialiased">
<Header />
<main class="flex-grow">
<main class="grow flex flex-col">
<slot />
</main>
<Footer />

19
src/pages/404.astro Normal file
View 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>

View File

@@ -42,6 +42,9 @@ const sendEmailViaSMTP = async ({
user: smtpUser,
pass: smtpPassword,
},
tls: {
rejectUnauthorized: false,
},
});
try {

26
src/pages/api/status.ts Normal file
View 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",
},
},
);
};

View File

@@ -1,14 +1,18 @@
---
export const prerender = true;
import Layout from "../layouts/Layout.astro";
import { siteConfig } from "../config/site";
import HeroSection from "../components/sections/HeroSection.astro";
import ServicesSection from "../components/sections/ServicesSection.astro";
import ClientList from "../components/sections/ClientList.astro";
import AboutSection from "../components/sections/AboutSection.astro";
import ContactSection from "../components/sections/ContactSection.tsx";
import ContactSection from "../components/sections/ContactSection.vue";
import Section from "../components/Section.astro";
const pageMetaInfo = {
title: siteConfig.name,
description: `Welcome to ${siteConfig.name} - ${siteConfig.description}`,
description: `${siteConfig.name} - ${siteConfig.description}`,
};
---
@@ -16,5 +20,12 @@ const pageMetaInfo = {
<HeroSection />
<ServicesSection />
<AboutSection />
<ContactSection client:load />
<ClientList />
<Section
id="contact"
title={siteConfig.contact.mainTitle}
background="bg-base-200"
>
<ContactSection client:visible />
</Section>
</Layout>

View File

@@ -40,6 +40,14 @@ html {
scroll-behavior: smooth;
}
body {
font-family: 'Inter', system-ui, -apple-system, sans-serif;
@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;
}