Try building

This commit is contained in:
Atridad Lahiji 2025-01-22 22:38:19 -06:00
parent 112d9dd063
commit 5bfbb3b88c
Signed by: atridad
SSH key fingerprint: SHA256:LGomp8Opq0jz+7kbwNcdfTcuaLRb5Nh0k5AchDDb438
15 changed files with 597 additions and 104 deletions

6
.env.example Normal file
View file

@ -0,0 +1,6 @@
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
FROM_EMAIL=
TO_EMAIL=

44
Dockerfile Normal file
View file

@ -0,0 +1,44 @@
# Build stage
FROM node:20-alpine AS builder
# Install pnpm
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
# Copy package files
COPY package.json pnpm-lock.yaml ./
# Install dependencies
RUN pnpm install --frozen-lockfile
# Copy source code
COPY . .
# Build the application
RUN pnpm run build
# Production stage
FROM node:20-alpine
# Install pnpm
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
# Copy built assets and necessary files
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/pnpm-lock.yaml ./pnpm-lock.yaml
# Install only production dependencies
RUN pnpm install --prod --frozen-lockfile
# Copy environment variables if needed
COPY .env.production ./.env
# Expose the port your app runs on
EXPOSE 3000
# Start the application
CMD ["node", "./dist/server/entry.mjs"]

15
docker-compose.yml Normal file
View file

@ -0,0 +1,15 @@
services:
app:
image: ${IMAGE}
ports:
- "${APP_PORT}:3000"
environment:
- HOST=0.0.0.0
- PORT=3000
- SMTP_HOST=${SMTP_HOST}
- SMTP_PORT=${SMTP_PORT}
- SMTP_USER=${SMTP_USER}
- SMTP_PASS=${SMTP_PASS}
- FROM_EMAIL=${FROM_EMAIL}
- TO_EMAIL=${TO_EMAIL}
restart: unless-stopped

View file

@ -12,6 +12,7 @@
"@astrojs/solid-js": "^5.0.4",
"@astrojs/tailwind": "^5.1.5",
"astro": "^5.1.8",
"nodemailer": "^6.9.16",
"solid-js": "^1.9.4",
"tailwindcss": "^3.4.17"
},

9
pnpm-lock.yaml generated
View file

@ -17,6 +17,9 @@ importers:
astro:
specifier: ^5.1.8
version: 5.1.8(jiti@1.21.7)(rollup@4.31.0)(typescript@5.7.3)(yaml@2.7.0)
nodemailer:
specifier: ^6.9.16
version: 6.9.16
solid-js:
specifier: ^1.9.4
version: 1.9.4
@ -1351,6 +1354,10 @@ packages:
node-releases@2.0.19:
resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==}
nodemailer@6.9.16:
resolution: {integrity: sha512-psAuZdTIRN08HKVd/E8ObdV6NO7NTBY3KsC30F7M4H1OnmLCUNaS56FpYxyb26zWLSyYF9Ozch9KYHhHegsiOQ==}
engines: {node: '>=6.0.0'}
normalize-path@3.0.0:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
@ -3552,6 +3559,8 @@ snapshots:
node-releases@2.0.19: {}
nodemailer@6.9.16: {}
normalize-path@3.0.0: {}
normalize-range@0.1.2: {}

View file

@ -0,0 +1,140 @@
import { createSignal, type Component } from "solid-js";
import { Show } from "solid-js/web";
const ContactForm: Component = () => {
const [email, setEmail] = 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({
email: email(),
message: message(),
}),
});
if (!response.ok) {
throw new Error("Failed to send message");
}
setStatus("success");
setEmail("");
setMessage("");
// Reset success status after 3 seconds
setTimeout(() => setStatus("idle"), 3000);
} catch (error) {
setStatus("error");
setErrorMessage(
error instanceof Error ? error.message : "Failed to send message",
);
}
};
return (
<form class="space-y-6" onSubmit={handleSubmit}>
<div class="form-control w-full">
<label for="email" class="label">
<span class="label-text text-neutral-content">Email</span>
</label>
<input
type="email"
id="email"
required
class="input input-bordered w-full"
placeholder="your@email.com"
value={email()}
onInput={(e) => setEmail(e.currentTarget.value)}
disabled={status() === "sending"}
/>
</div>
<div class="form-control w-full">
<label for="message" class="label">
<span class="label-text text-neutral-content">Message</span>
</label>
<textarea
id="message"
required
class="textarea textarea-bordered h-32"
placeholder="How can we help you?"
value={message()}
onInput={(e) => setMessage(e.currentTarget.value)}
disabled={status() === "sending"}
/>
</div>
<Show when={status() === "error"}>
<div class="alert alert-error">
<svg
xmlns="http://www.w3.org/2000/svg"
class="stroke-current shrink-0 h-6 w-6"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>
{errorMessage() || "Error sending message. Please try again."}
</span>
</div>
</Show>
<Show when={status() === "success"}>
<div class="alert alert-success">
<svg
xmlns="http://www.w3.org/2000/svg"
class="stroke-current shrink-0 h-6 w-6"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>Message sent successfully!</span>
</div>
</Show>
<div class="card-actions justify-end">
<button
type="submit"
class="btn btn-primary"
disabled={status() === "sending"}
>
{status() === "sending" ? (
<>
<span class="loading loading-spinner"></span>
Sending...
</>
) : (
"Send Message"
)}
</button>
</div>
</form>
);
};
export default ContactForm;

View file

@ -1,3 +1,7 @@
---
import { siteConfig } from "../config/site";
---
<footer
class="footer footer-center p-4 bg-base-300 text-base-content"
role="contentinfo"
@ -5,7 +9,9 @@
<aside>
<p>
<span class="sr-only">Copyright</span>
© 2024 - All rights reserved by Your Business Name
© {new Date().getFullYear()} - All rights reserved by {
siteConfig.name
}
</p>
</aside>
</footer>

View file

@ -1,3 +1,7 @@
---
import { siteConfig } from "../config/site";
---
<header class="navbar bg-base-100 shadow-lg" role="banner">
<div class="navbar-start">
<div class="dropdown">
@ -27,29 +31,45 @@
class="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow bg-base-100 rounded-box w-52"
role="menu"
>
<li role="none"><a href="/" role="menuitem">Home</a></li>
<li role="none">
<a href="/services" role="menuitem">Services</a>
</li>
<li role="none">
<a href="/contact" role="menuitem">Contact</a>
</li>
{
siteConfig.header.nav.map(({ text, href }) => (
<li role="none">
<a href={href} role="menuitem">
{text}
</a>
</li>
))
}
</ul>
</div>
<a href="/" class="btn btn-ghost text-xl" aria-label="Home"
>Your Business</a
<a
href={siteConfig.header.logo.href}
class="btn btn-ghost text-xl"
aria-label="Home"
>
{siteConfig.header.logo.text}
</a>
</div>
<nav class="navbar-center hidden lg:flex" aria-label="Main navigation">
<ul class="menu menu-horizontal px-1" role="menubar">
<li role="none"><a href="/" role="menuitem">Home</a></li>
<li role="none">
<a href="/services" role="menuitem">Services</a>
</li>
<li role="none"><a href="/contact" role="menuitem">Contact</a></li>
{
siteConfig.header.nav.map(({ text, href }) => (
<li role="none">
<a href={href} role="menuitem">
{text}
</a>
</li>
))
}
</ul>
</nav>
<div class="navbar-end">
<a href="/contact" class="btn btn-primary" role="button">Get Started</a>
<a
href={siteConfig.header.cta.href}
class="btn btn-primary"
role="button"
>
{siteConfig.header.cta.text}
</a>
</div>
</header>

106
src/config/site.ts Normal file
View file

@ -0,0 +1,106 @@
type Card = {
title: string;
content: string;
variant: "primary" | "secondary" | "accent" | "neutral";
};
type StatChange = {
value: string;
percentage: string;
direction: "up" | "down";
};
type Stat = {
title: string;
value: string;
change?: StatChange;
};
export const siteConfig = {
name: "Atash Consulting",
description: "Software Consulting based in Edmonton, Alberta",
header: {
logo: {
text: "Atash Consulting",
href: "/",
},
nav: [
{
text: "Home",
href: "/",
},
{
text: "Services",
href: "/services",
},
{
text: "Contact",
href: "/contact",
},
],
cta: {
text: "Get Started",
href: "/contact",
},
},
hero: {
title: "Atash Consulting",
description: "Software Consulting based in Edmonton, Alberta",
},
featureCards: {
enabled: true,
cards: [
{
title: "Web Development",
content: "Functional, accessible, and beautiful websites.",
variant: "primary",
},
{
title: "Mobile App Development",
content: "iOS, Android, and cross-platform mobile applications.",
variant: "secondary",
},
{
title: "DevOps",
content: "Anything from CI/CD to end-to-end UI testing.",
variant: "secondary",
},
{
title: "IT Support Processes",
content:
"Providing expert technical support expertise, backed by over a decade of client support experience.",
variant: "primary",
},
] as Card[],
},
statistics: {
enabled: true,
title: "Company Statistics",
stats: [
{
title: "Years of Experience",
value: "10+",
},
{
title: "Reviews",
value: "5⭐",
},
] as Stat[],
},
contact: {
title: "Contact Us",
description: "Ready to get started? Reach out to us for a consultation.",
cta: {
text: "Get in Touch",
href: "/contact",
ariaLabel: "Contact us for consultation",
},
},
} as const;
export type SiteConfig = typeof siteConfig;

View file

@ -1,27 +1,29 @@
---
import Header from "../components/Header.astro";
import Footer from "../components/Footer.astro";
import { siteConfig } from "../config/site";
interface Props {
title?: string;
description?: string;
}
const {
title = "Atash Consulting",
description = "Technical Excellence with a Human Touch",
} = Astro.props;
const { title = siteConfig.name, description = siteConfig.description } =
Astro.props;
const metaTitle =
title === siteConfig.name ? title : `${title} | ${siteConfig.name}`;
---
<!doctype html>
<html lang="en" data-theme="coffee">
<html lang="en" data-theme="sunset">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="generator" content={Astro.generator} />
<meta name="description" content={description} />
<title>{title}</title>
<title>{metaTitle}</title>
<style>
.skip-to-content {
position: absolute;

58
src/pages/api/contact.ts Normal file
View file

@ -0,0 +1,58 @@
import type { APIRoute } from "astro";
import nodemailer from "nodemailer";
export const prerender = false;
const transporter = nodemailer.createTransport({
host: import.meta.env.SMTP_HOST,
port: parseInt(import.meta.env.SMTP_PORT),
secure: import.meta.env.SMTP_PORT === "465",
auth: {
user: import.meta.env.SMTP_USER,
pass: import.meta.env.SMTP_PASS,
},
});
export const POST: APIRoute = async ({ request }) => {
try {
const data = await request.json();
const { email, message } = data;
if (!email || !message) {
return new Response(
JSON.stringify({
message: "Email and message are required",
}),
{ status: 400 },
);
}
await transporter.sendMail({
from: import.meta.env.FROM_EMAIL,
to: [import.meta.env.TO_EMAIL],
subject: `New Contact Form Submission from ${email}`,
text: message,
html: `
<h2>New Contact Form Submission</h2>
<p><strong>From:</strong> ${email}</p>
<p><strong>Message:</strong></p>
<p>${message.replace(/\n/g, "<br>")}</p>
`,
});
return new Response(
JSON.stringify({
message: "Email sent successfully",
}),
{ status: 200 },
);
} catch (error) {
console.error("Error sending email:", error);
return new Response(
JSON.stringify({
message: "Error sending email",
}),
{ status: 500 },
);
}
};

25
src/pages/contact.astro Normal file
View file

@ -0,0 +1,25 @@
---
import Layout from "../layouts/Layout.astro";
import ContactForm from "../components/ContactForm";
import { siteConfig } from "../config/site";
const pageMetaInfo = {
title: "Contact | " + siteConfig.name,
description: "Get in touch with us for your software development needs",
};
---
<Layout title={pageMetaInfo.title} description={pageMetaInfo.description}>
<div class="max-w-2xl mx-auto">
<section class="card bg-neutral text-neutral-content shadow-xl">
<div class="card-body">
<h1 class="card-title text-3xl">Contact Us</h1>
<p class="text-lg">
Ready to get started? Send us a message and we'll get back
to you shortly.
</p>
<ContactForm client:load />
</div>
</section>
</div>
</Layout>

View file

@ -1,111 +1,104 @@
---
import Layout from "../layouts/Layout.astro";
import { siteConfig } from "../config/site";
const pageMetaInfo = {
title: "Atash Consulting",
description:
"Welcome to Atash Consulting - Technical Excellence with a Human Touch",
};
const layoutProps = {
...(pageMetaInfo.title?.trim() && { title: pageMetaInfo.title }),
...(pageMetaInfo.description?.trim() && {
description: pageMetaInfo.description,
}),
title: siteConfig.name,
description: `Welcome to ${siteConfig.name} - ${siteConfig.description}`,
};
---
<Layout {...layoutProps}>
<Layout title={pageMetaInfo.title} description={pageMetaInfo.description}>
<div class="max-w-4xl mx-auto">
<section
class="card bg-base-100 shadow-xl"
class="card bg-neutral text-neutral-content shadow-xl"
aria-labelledby="welcome-heading"
>
<div class="card-body">
<h1 id="welcome-heading" class="card-title text-3xl">
{pageMetaInfo.title || "Atash Consulting"}
{siteConfig.hero.title}
</h1>
<p class="text-base-content/70">
{
pageMetaInfo.description ||
"Technical Excellence with a Human Touch"
}
</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-8">
<article class="card bg-primary text-primary-content">
<div class="card-body">
<h2 class="card-title">Our Mission</h2>
<p>
To deliver exceptional value through innovative
solutions.
</p>
</div>
</article>
<article class="card bg-secondary text-secondary-content">
<div class="card-body">
<h2 class="card-title">Our Vision</h2>
<p>
Leading the industry with cutting-edge
technology.
</p>
</div>
</article>
</div>
<p>{siteConfig.hero.description}</p>
</div>
</section>
<section
class="stats shadow mt-8 w-full"
aria-label="Company Statistics"
>
<div class="stat">
<div class="stat-title" id="clients-stat">Total Clients</div>
<div class="stat-value" aria-labelledby="clients-stat">
500+
</div>
<div class="stat-desc" aria-label="Increase of 40 clients (2%)">
↗︎ 40 (2%)
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-8">
{
siteConfig.featureCards.cards.map((card) => (
<article
class={
card.variant === "primary"
? "card bg-primary text-primary-content"
: "card bg-secondary text-secondary-content"
}
>
<div class="card-body">
<h2 class="card-title">{card.title}</h2>
</div>
</article>
))
}
</div>
<div class="stat">
<div class="stat-title" id="projects-stat">
Projects Completed
</div>
<div class="stat-value" aria-labelledby="projects-stat">
1,200
</div>
<div
class="stat-desc"
aria-label="Increase of 90 projects (14%)"
>
↗︎ 90 (14%)
</div>
</div>
<div class="stat">
<div class="stat-title" id="success-stat">Success Rate</div>
<div class="stat-value" aria-labelledby="success-stat">98%</div>
<div class="stat-desc" aria-label="Increase of 2%">↗︎ 2%</div>
</div>
</section>
{
siteConfig.statistics.enabled &&
siteConfig.statistics.stats.length > 0 && (
<section
class="stats shadow mt-8 w-full bg-neutral text-neutral-content"
aria-label={siteConfig.statistics.title}
>
{siteConfig.statistics.stats.map((stat) => (
<div class="stat">
<div
class="stat-title"
id={`${stat.title.toLowerCase()}-stat`}
>
{stat.title}
</div>
<div
class="stat-value"
aria-labelledby={`${stat.title.toLowerCase()}-stat`}
>
{stat.value}
</div>
{stat.change && (
<div
class="stat-desc"
aria-label={`${
stat.change.direction === "up"
? "Increase"
: "Decrease"
} of ${stat.change.value} (${stat.change.percentage}%)`}
>
{stat.change.direction === "up"
? "↗︎"
: "↘︎"}{" "}
{stat.change.value} (
{stat.change.percentage}%)
</div>
)}
</div>
))}
</section>
)
}
<section
class="card bg-base-100 shadow-xl mt-8"
class="card bg-neutral text-neutral-content shadow-xl mt-8 hidden lg:block"
aria-labelledby="contact-heading"
>
<div class="card-body">
<h2 id="contact-heading" class="card-title">Contact Us</h2>
<p>Ready to get started? Reach out to us for a consultation.</p>
<h2 id="contact-heading" class="card-title">
{siteConfig.contact.title}
</h2>
<p>{siteConfig.contact.description}</p>
<div class="card-actions justify-end">
<button
class="btn btn-primary"
onclick="window.location.href='/contact'"
aria-label="Contact us for consultation"
onclick={`window.location.href='${siteConfig.contact.cta.href}'`}
aria-label={siteConfig.contact.cta.ariaLabel}
>
Get in Touch
{siteConfig.contact.cta.text}
</button>
</div>
</div>

68
src/pages/services.astro Normal file
View file

@ -0,0 +1,68 @@
---
import Layout from "../layouts/Layout.astro";
import { siteConfig } from "../config/site";
const pageMetaInfo = {
title: "Services | " + siteConfig.name,
description:
"Comprehensive software development and IT services in Edmonton, Alberta",
};
---
<Layout title={pageMetaInfo.title} description={pageMetaInfo.description}>
<div class="max-w-5xl mx-auto flex flex-col gap-4">
<!-- Increased from 4xl to 5xl -->
<section class="card bg-neutral text-neutral-content shadow-xl">
<div class="card-body">
<h1 class="card-title text-3xl">Our Services</h1>
<p class="text-lg">
Comprehensive software development and IT solutions tailored
to your needs.
</p>
</div>
</section>
<div class="flex flex-wrap gap-4">
{
siteConfig.featureCards.cards.map((service, index) => (
<section class="card bg-neutral text-neutral-content shadow-xl basis-[calc(50%-0.5rem)]">
<div class="card-body">
<div class="flex items-center gap-4">
<div
class={`badge badge-${service.variant} badge-lg`}
>
{index + 1}
</div>
<h2 class="card-title text-2xl">
{service.title}
</h2>
</div>
<p class="text-lg mt-4">{service.content}</p>
</div>
</section>
))
}
</div>
<section
class="card bg-neutral text-neutral-content shadow-xl mt-8 hidden lg:block"
aria-labelledby="contact-heading"
>
<div class="card-body">
<h2 id="contact-heading" class="card-title">
{siteConfig.contact.title}
</h2>
<p>{siteConfig.contact.description}</p>
<div class="card-actions justify-end">
<button
class="btn btn-primary"
onclick={`window.location.href='${siteConfig.contact.cta.href}'`}
aria-label={siteConfig.contact.cta.ariaLabel}
>
{siteConfig.contact.cta.text}
</button>
</div>
</div>
</section>
</div>
</Layout>

View file

@ -6,6 +6,6 @@ export default {
},
plugins: [require("daisyui")],
daisyui: {
themes: ["coffee", "dark", "business"],
themes: ["coffee", "dark", "sunset"],
},
};