diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a85cc4a --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +SMTP_USER= +SMTP_PASS= diff --git a/.gitignore b/.gitignore index a547bf3..6da5bef 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,10 @@ dist dist-ssr *.local +# Secrets +.env +mailer.env + # Editor directories and files .vscode/* !.vscode/extensions.json diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d0d4b6f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM node:22-alpine AS build +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build + +FROM nginx:alpine +COPY --from=build /app/dist /usr/share/nginx/html +EXPOSE 80 diff --git a/README.md b/README.md new file mode 100644 index 0000000..b0d18d3 --- /dev/null +++ b/README.md @@ -0,0 +1,45 @@ +# humanAI — landing page + +React + Vite static site, served by nginx in the `humanai-landing` Docker container, +behind Nginx Proxy Manager on the `nginxproxymanager_default` network. + +## Development + +```bash +npm install +npm run dev +``` + +## Deploy + +```bash +./deploy.sh +``` + +The script builds images locally, pushes them to piha.local via the `piha` Docker context, +and runs smoke tests against the mailer endpoint. + +## Mailer + +The `mailer/` directory contains a standalone Express microservice that handles the contact +form. It runs as `humanai-mailer` on the same Docker network as NPM so it can be proxied +without exposing any port to the host. + +### Manual steps required before first deploy + +**(a) Create a Fastmail app-password and write credentials to `mailer.env` on the deploy machine:** + +``` +# mailer.env (never commit this file) +SMTP_USER=youruser@fastmail.com +SMTP_PASS=your-app-password +``` + +**(b) In Nginx Proxy Manager → Proxy Host for `gethumanai.pl` → Custom Locations:** + +| Field | Value | +|---|---| +| location | `/api` | +| scheme | `http` | +| forward hostname | `humanai-mailer` | +| port | `3000` | diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..d89f74e --- /dev/null +++ b/deploy.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +set -euo pipefail + +CONTEXT="piha" +REMOTE="ssh://oskar@piha.local" +NETWORK="nginxproxymanager_default" + +# Create docker context if missing +docker context inspect "$CONTEXT" &>/dev/null || \ + docker context create "$CONTEXT" --docker "host=$REMOTE" + +D="docker --context $CONTEXT" + +# ── Landing ──────────────────────────────────────────────────────────────────── +echo "==> Building humanai-landing" +$D build -t humanai-landing . + +echo "==> Deploying humanai-landing" +$D rm -f humanai-landing 2>/dev/null || true +$D run -d \ + --name humanai-landing \ + --network "$NETWORK" \ + --restart unless-stopped \ + humanai-landing + +# ── Mailer ───────────────────────────────────────────────────────────────────── +echo "==> Building humanai-mailer" +$D build -t humanai-mailer ./mailer + +echo "==> Deploying humanai-mailer" +$D rm -f humanai-mailer 2>/dev/null || true +$D run -d \ + --name humanai-mailer \ + --network "$NETWORK" \ + --env-file mailer.env \ + --restart unless-stopped \ + humanai-mailer + +# ── Smoke tests ──────────────────────────────────────────────────────────────── +echo "==> Waiting for mailer to start..." +sleep 3 + +echo "==> Smoke test 1: empty body must return 400" +STATUS=$($D exec humanai-mailer node -e " +const http = require('http'); +const body = '{}'; +const req = http.request({ + host: 'localhost', port: 3000, path: '/api/contact', method: 'POST', + headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) } +}, res => { console.log(res.statusCode); }); +req.on('error', e => { console.error(e.message); process.exit(1); }); +req.write(body); +req.end(); +") +if [ "$STATUS" != "400" ]; then + echo "FAIL: expected 400, got $STATUS" + exit 1 +fi +echo " OK: got 400" + +echo "==> Smoke test 2: honeypot fill must return 200 {ok:true} (no email sent)" +RESP=$($D exec humanai-mailer node -e " +const http = require('http'); +const body = JSON.stringify({name:'Bot',email:'bot@example.com',message:'hello world',company:'acme'}); +const chunks = []; +const req = http.request({ + host: 'localhost', port: 3000, path: '/api/contact', method: 'POST', + headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) } +}, res => { + res.on('data', d => chunks.push(d)); + res.on('end', () => console.log(res.statusCode + ' ' + Buffer.concat(chunks).toString())); +}); +req.on('error', e => { console.error(e.message); process.exit(1); }); +req.write(body); +req.end(); +") +if [[ "$RESP" != 200* ]]; then + echo "FAIL: expected 200, got $RESP" + exit 1 +fi +echo " OK: got $RESP" + +echo "" +echo "Deploy complete." +echo "Next steps (manual):" +echo " 1. Set SMTP_USER/SMTP_PASS in mailer.env on this machine" +echo " 2. In NPM: Proxy Host gethumanai.pl → Custom Location /api → http://humanai-mailer:3000" diff --git a/mailer/Dockerfile b/mailer/Dockerfile new file mode 100644 index 0000000..dd1cc5e --- /dev/null +++ b/mailer/Dockerfile @@ -0,0 +1,6 @@ +FROM node:22-alpine +WORKDIR /app +COPY package*.json ./ +RUN npm install --omit=dev +COPY mailer.mjs . +CMD ["node", "mailer.mjs"] diff --git a/mailer/mailer.mjs b/mailer/mailer.mjs new file mode 100644 index 0000000..ea64764 --- /dev/null +++ b/mailer/mailer.mjs @@ -0,0 +1,82 @@ +import express from 'express'; +import nodemailer from 'nodemailer'; + +const app = express(); +app.use(express.json()); + +// Rate limiter: max 5 requests/min per IP +const rateLimiter = new Map(); +function checkRateLimit(ip) { + const now = Date.now(); + const window = 60_000; + const max = 5; + const entry = rateLimiter.get(ip) ?? { count: 0, start: now }; + if (now - entry.start > window) { + entry.count = 0; + entry.start = now; + } + entry.count++; + rateLimiter.set(ip, entry); + return entry.count <= max; +} + +// Prune rate limiter every 5 minutes +setInterval(() => { + const cutoff = Date.now() - 60_000; + for (const [ip, entry] of rateLimiter) { + if (entry.start < cutoff) rateLimiter.delete(ip); + } +}, 300_000); + +const transporter = nodemailer.createTransport({ + host: 'smtp.fastmail.com', + port: 465, + secure: true, + auth: { + user: process.env.SMTP_USER, + pass: process.env.SMTP_PASS, + }, +}); + +app.post('/api/contact', async (req, res) => { + const ip = + (req.headers['x-forwarded-for'] ?? '').split(',')[0].trim() || + req.socket.remoteAddress; + + if (!checkRateLimit(ip)) { + return res.status(429).json({ error: 'Too many requests' }); + } + + const { name, email, message, company } = req.body ?? {}; + + if (!name || typeof name !== 'string' || !name.trim()) { + return res.status(400).json({ error: 'name required' }); + } + if (!email || typeof email !== 'string' || !email.includes('@')) { + return res.status(400).json({ error: 'valid email required' }); + } + if (!message || typeof message !== 'string' || message.trim().length < 5) { + return res.status(400).json({ error: 'message too short' }); + } + + // Honeypot + if (company) { + return res.json({ ok: true }); + } + + try { + await transporter.sendMail({ + from: 'oskar@gethumanai.com', + to: 'oskar@gethumanai.com', + replyTo: email, + subject: `Kontakt od ${name.trim()}`, + text: `Imię: ${name.trim()}\nEmail: ${email}\n\n${message.trim()}`, + }); + res.json({ ok: true }); + } catch (err) { + console.error('SMTP error:', err); + res.status(502).json({ error: 'mail delivery failed' }); + } +}); + +app.listen(3000, () => console.log('mailer listening on :3000')); diff --git a/mailer/package.json b/mailer/package.json new file mode 100644 index 0000000..e55eee6 --- /dev/null +++ b/mailer/package.json @@ -0,0 +1,10 @@ +{ + "name": "humanai-mailer", + "version": "1.0.0", + "type": "module", + "main": "mailer.mjs", + "dependencies": { + "express": "^4.19.2", + "nodemailer": "^6.9.14" + } +} diff --git a/src/components/CTASection.tsx b/src/components/CTASection.tsx index 3cc4d03..500faf4 100644 --- a/src/components/CTASection.tsx +++ b/src/components/CTASection.tsx @@ -1,12 +1,38 @@ -import React from 'react'; +import React, { useState } from 'react'; import { motion } from 'framer-motion'; import { Mail, MessageSquare } from 'lucide-react'; +import { useT } from '../i18n'; + +type Status = 'idle' | 'sending' | 'success' | 'error'; const CTASection = () => { + const t = useT(); + const [name, setName] = useState(''); + const [email, setEmail] = useState(''); + const [message, setMessage] = useState(''); + const [company, setCompany] = useState(''); // honeypot + const [status, setStatus] = useState('idle'); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setStatus('sending'); + try { + const res = await fetch('/api/contact', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, email, message, company }), + }); + if (!res.ok) throw new Error(`${res.status}`); + setStatus('success'); + } catch { + setStatus('error'); + } + }; + return (
- +
@@ -29,42 +55,91 @@ const CTASection = () => {

- “Na start wystarczy jeden proces, jeden problem albo jeden pomysł.” + "Na start wystarczy jeden proces, jeden problem albo jeden pomysł."

-
e.preventDefault()}> -
-
- - +

{t.contact.successTitle}

+

{t.contact.successBody}

+ + ) : ( + + {/* Honeypot — hidden from real users */} + + +
+
+ + setName(e.target.value)} + required + className="w-full bg-background border border-white/10 rounded-lg px-4 py-3 focus:outline-none focus:border-primary/50 transition-colors" + /> +
+
+ + setEmail(e.target.value)} + required + className="w-full bg-background border border-white/10 rounded-lg px-4 py-3 focus:outline-none focus:border-primary/50 transition-colors" + /> +
+
- - {t.contact.labelMessage} + -
- - + + {status === 'error' && ( +

+ {t.contact.errorTitle}{' '} + {t.contact.errorBody} +

+ )} + + + + )}
diff --git a/src/i18n/en.ts b/src/i18n/en.ts new file mode 100644 index 0000000..1129073 --- /dev/null +++ b/src/i18n/en.ts @@ -0,0 +1,16 @@ +export const en = { + contact: { + labelName: 'Name', + placeholderName: 'Your name', + labelEmail: 'Email', + placeholderEmail: 'email@company.com', + labelMessage: 'How can we help?', + placeholderMessage: 'Briefly describe your process or challenge...', + submit: 'Schedule a call', + sending: 'Sending...', + successTitle: 'Message sent!', + successBody: "We'll get back to you shortly.", + errorTitle: 'Something went wrong.', + errorBody: 'Please try again or email us directly at hello@gethumanai.com.', + }, +}; diff --git a/src/i18n/index.ts b/src/i18n/index.ts new file mode 100644 index 0000000..0288bd7 --- /dev/null +++ b/src/i18n/index.ts @@ -0,0 +1,7 @@ +import { pl } from './pl'; +import { en } from './en'; + +export function useT() { + const lang = navigator.language?.startsWith('pl') ? 'pl' : 'en'; + return lang === 'pl' ? pl : en; +} diff --git a/src/i18n/pl.ts b/src/i18n/pl.ts new file mode 100644 index 0000000..51ead77 --- /dev/null +++ b/src/i18n/pl.ts @@ -0,0 +1,16 @@ +export const pl = { + contact: { + labelName: 'Imię', + placeholderName: 'Twoje imię', + labelEmail: 'Email', + placeholderEmail: 'email@firma.pl', + labelMessage: 'W czym możemy pomóc?', + placeholderMessage: 'Opisz krótko swój proces lub wyzwanie...', + submit: 'Umów rozmowę', + sending: 'Wysyłam...', + successTitle: 'Wiadomość wysłana!', + successBody: 'Odezwiemy się wkrótce.', + errorTitle: 'Coś poszło nie tak.', + errorBody: 'Spróbuj ponownie lub napisz bezpośrednio na hello@gethumanai.com.', + }, +};