asdf
This commit is contained in:
1 parent
cc41849cf5
commit
f84687d745
22 files changed
+554
-13
No files matched your search
@@ -0,0 +1,7 @@
|
|||||||
|
# Contact form (server/contact.mjs). Copy to .env for `npm run contact`; set the same in Dokploy.
|
||||||
|
SMTP_HOST=smtp.example.com
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=website@ciphercell.de
|
||||||
|
SMTP_PASS=
|
||||||
|
CONTACT_FROM=website@ciphercell.de
|
||||||
|
CONTACT_TO=office@ciphercell.de
|
||||||
@@ -10,3 +10,7 @@ ds-bundle/
|
|||||||
.design-sync/learnings/
|
.design-sync/learnings/
|
||||||
.design-sync/node_modules
|
.design-sync/node_modules
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
# Local secrets for the contact handler
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
+12
-1
@@ -10,10 +10,21 @@ RUN npm ci --no-audit --no-fund
|
|||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Serve dist/ with nginx.
|
# The contact handler's production dependencies (nodemailer only).
|
||||||
|
FROM node:22-alpine AS contact
|
||||||
|
WORKDIR /srv/contact
|
||||||
|
COPY server/package.json server/package-lock.json ./
|
||||||
|
RUN npm ci --omit=dev --no-audit --no-fund
|
||||||
|
COPY server/contact.mjs ./
|
||||||
|
|
||||||
|
# Serve dist/ with nginx; POST /contact goes to the handler on 127.0.0.1:3000.
|
||||||
FROM nginx:1.27-alpine AS runtime
|
FROM nginx:1.27-alpine AS runtime
|
||||||
|
RUN apk add --no-cache nodejs
|
||||||
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
|
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY docker/entrypoint.sh /entrypoint.sh
|
||||||
COPY --from=build /app/dist /usr/share/nginx/html
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
COPY --from=contact /srv/contact /srv/contact
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
CMD wget -qO- http://127.0.0.1/healthz || exit 1
|
CMD wget -qO- http://127.0.0.1/healthz || exit 1
|
||||||
@@ -26,7 +26,8 @@ docker run --rm -p 8080:80 ciphercell # http://localhost:8080
|
|||||||
|
|
||||||
In Dokploy: create an Application from this repository, set Build Type to **Dockerfile**
|
In Dokploy: create an Application from this repository, set Build Type to **Dockerfile**
|
||||||
(path `Dockerfile`), and under Domains add the domain with container port **80** and HTTPS on.
|
(path `Dockerfile`), and under Domains add the domain with container port **80** and HTTPS on.
|
||||||
TLS is handled by Dokploy's Traefik.
|
TLS is handled by Dokploy's Traefik. The contact form needs the SMTP variables under
|
||||||
|
Environment; see *Contact form* below.
|
||||||
|
|
||||||
## Where things live
|
## Where things live
|
||||||
|
|
||||||
@@ -43,6 +44,7 @@ TLS is handled by Dokploy's Traefik.
|
|||||||
| `src/components/verifier/*` | The CipherVerifier product page, in page order: Hero (line burst), Intro (image band), HowItWorks (five group cards, one schematic each; on wide screens the heading is sticky and the cards stack under it as you scroll), Cards, Facts, Outro. |
|
| `src/components/verifier/*` | The CipherVerifier product page, in page order: Hero (line burst), Intro (image band), HowItWorks (five group cards, one schematic each; on wide screens the heading is sticky and the cards stack under it as you scroll), Cards, Facts, Outro. |
|
||||||
| `src/components/verifier/SoonHero.astro`, `ComingSoon.astro` | The coming-soon version of `/cipherverifier`, shown while `verifierComingSoon` in `src/data/flags.ts` is true: hero on a build-time SVG relief of ridge lines in the logo blues, under the floating header, "Coming soon." with the alpha sign-up beside it, a status strip, and the product name set across the full width and cut by the hero's lower edge, release path, spec sheet and the first schematic. Copy in `ui.verifier.soon`. |
|
| `src/components/verifier/SoonHero.astro`, `ComingSoon.astro` | The coming-soon version of `/cipherverifier`, shown while `verifierComingSoon` in `src/data/flags.ts` is true: hero on a build-time SVG relief of ridge lines in the logo blues, under the floating header, "Coming soon." with the alpha sign-up beside it, a status strip, and the product name set across the full width and cut by the hero's lower edge, release path, spec sheet and the first schematic. Copy in `ui.verifier.soon`. |
|
||||||
| `src/components/verifier/diagrams.ts` | The five schematics as data: a hand-placed wide layout and an auto-generated tall spine per group. `Schematic.astro` renders either. Labels come from `ui.verifier.how.groups`; the tuple shape is in `types.ts`. Every element carries the `group` of the node it belongs to, which drives the hover focus and the draw-in order. |
|
| `src/components/verifier/diagrams.ts` | The five schematics as data: a hand-placed wide layout and an auto-generated tall spine per group. `Schematic.astro` renders either. Labels come from `ui.verifier.how.groups`; the tuple shape is in `types.ts`. Every element carries the `group` of the node it belongs to, which drives the hover focus and the draw-in order. |
|
||||||
|
| `server/contact.mjs` | The contact form handler: validates, rate-limits and sends by SMTP. Its own `package.json`, so the image only carries nodemailer. |
|
||||||
| `scripts/*.mjs` | Review tooling: `shot.mjs` (full page), `scroll-shots.mjs` (viewport by viewport), `tabs-test.mjs` (keyboard test for the sectors tabs), `contact-sheet.mjs`. Need `npx astro preview --port 4322`. |
|
| `scripts/*.mjs` | Review tooling: `shot.mjs` (full page), `scroll-shots.mjs` (viewport by viewport), `tabs-test.mjs` (keyboard test for the sectors tabs), `contact-sheet.mjs`. Need `npx astro preview --port 4322`. |
|
||||||
| `src/pages/_*.astro` | Shared page bodies. `index.astro` and `de/index.astro` render `_Home.astro` with a `lang` prop, and so on. |
|
| `src/pages/_*.astro` | Shared page bodies. `index.astro` and `de/index.astro` render `_Home.astro` with a `lang` prop, and so on. |
|
||||||
| `src/assets/images/` | Source images. Astro emits AVIF with a WebP fallback and explicit dimensions. |
|
| `src/assets/images/` | Source images. Astro emits AVIF with a WebP fallback and explicit dimensions. |
|
||||||
@@ -56,9 +58,33 @@ TLS is handled by Dokploy's Traefik.
|
|||||||
|
|
||||||
## Things the host has to provide
|
## Things the host has to provide
|
||||||
|
|
||||||
- **Contact form.** The form posts `first_name`, `last_name`, `email`, `message` and a honeypot
|
- **Contact form.** The form posts `first_name`, `last_name`, `email`, `message`, `lang` and a
|
||||||
field `website` to `POST /contact`, as the current site does. The handler is server-side and
|
honeypot field `website` to `POST /contact`. That one path is handled by `server/contact.mjs`
|
||||||
not part of this static build.
|
(Node, nodemailer), which sends the message by SMTP to `CONTACT_TO` with the sender as
|
||||||
|
Reply-To. With scripting the form sends in place and shows the result under the button;
|
||||||
|
without, the handler redirects to `/contact/sent` or `/contact/error` (under `/de` for German).
|
||||||
|
Both pages are `noindex` and left out of the sitemap. The handler caps size and field lengths,
|
||||||
|
refuses cross-site posts, and allows 5 messages per address per 10 minutes and 60 per hour
|
||||||
|
overall.
|
||||||
|
|
||||||
|
In the container, nginx proxies `/contact` to the handler on `127.0.0.1:3000`; both are started
|
||||||
|
by `docker/entrypoint.sh`, which restarts the handler if it crashes. If the handler is down,
|
||||||
|
visitors are sent to the error page. Without SMTP settings the handler does not start and the
|
||||||
|
rest of the site is served as usual.
|
||||||
|
|
||||||
|
| Variable | Default | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `SMTP_HOST` | (required) | Mail server. |
|
||||||
|
| `SMTP_PORT` | `587` | `587` uses STARTTLS (required), `465` TLS from the start. |
|
||||||
|
| `SMTP_USER`, `SMTP_PASS` | | Login. Leave unset for a server without authentication. |
|
||||||
|
| `CONTACT_FROM` | `SMTP_USER` | Sender address; must be one the SMTP account may send as. |
|
||||||
|
| `CONTACT_TO` | `office@ciphercell.de` | Where messages go. |
|
||||||
|
| `SMTP_SECURE` | by port | Force TLS from the start (`true`) or not (`false`). |
|
||||||
|
| `SMTP_REQUIRE_TLS` | `true` | `false` only for a local mail catcher. |
|
||||||
|
|
||||||
|
In Dokploy, set these under the application's Environment. Locally, put them in `.env` (see
|
||||||
|
`.env.example`), run `npm run contact` next to `npm run dev`; the dev server proxies `/contact`
|
||||||
|
to it.
|
||||||
- **Alpha sign-up.** While `verifierComingSoon` in `src/data/flags.ts` is true, `/cipherverifier`
|
- **Alpha sign-up.** While `verifierComingSoon` in `src/data/flags.ts` is true, `/cipherverifier`
|
||||||
shows a coming-soon page whose form posts `email`, `consent=yes`, `lang`,
|
shows a coming-soon page whose form posts `email`, `consent=yes`, `lang`,
|
||||||
`list=cipherverifier-alpha` and the honeypot `website` to `POST /notify`. The handler is
|
`list=cipherverifier-alpha` and the honeypot `website` to `POST /notify`. The handler is
|
||||||
|
|||||||
+3
-1
@@ -5,11 +5,13 @@ import sitemap from '@astrojs/sitemap';
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
site: 'https://www.ciphercell.de',
|
site: 'https://www.ciphercell.de',
|
||||||
trailingSlash: 'never',
|
trailingSlash: 'never',
|
||||||
integrations: [sitemap({ i18n: { defaultLocale: 'en', locales: { en: 'en-GB', de: 'de-DE' } } })],
|
integrations: [sitemap({ filter: (page) => !/\/contact\//.test(page), i18n: { defaultLocale: 'en', locales: { en: 'en-GB', de: 'de-DE' } } })],
|
||||||
i18n: {
|
i18n: {
|
||||||
defaultLocale: 'en',
|
defaultLocale: 'en',
|
||||||
locales: ['en', 'de'],
|
locales: ['en', 'de'],
|
||||||
routing: { prefixDefaultLocale: false },
|
routing: { prefixDefaultLocale: false },
|
||||||
},
|
},
|
||||||
build: { inlineStylesheets: 'auto' },
|
build: { inlineStylesheets: 'auto' },
|
||||||
|
// In development the form posts to the contact handler started with `npm run contact`.
|
||||||
|
vite: { server: { proxy: { '^/contact$': { target: 'http://127.0.0.1:3000', changeOrigin: false } } } },
|
||||||
});
|
});
|
||||||
Executable
+15
@@ -0,0 +1,15 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Runs the contact handler next to nginx. The handler is restarted if it crashes, but not when it
|
||||||
|
# exits for missing configuration (code 78); the static site is served either way.
|
||||||
|
(
|
||||||
|
cd /srv/contact || exit
|
||||||
|
while :; do
|
||||||
|
node contact.mjs
|
||||||
|
code=$?
|
||||||
|
[ "$code" -eq 78 ] && { echo "contact: not started, SMTP is not configured." >&2; exit; }
|
||||||
|
echo "contact: exited with $code, restarting in 5 s." >&2
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
) &
|
||||||
|
|
||||||
|
exec nginx -g 'daemon off;'
|
||||||
@@ -7,6 +7,13 @@ server {
|
|||||||
|
|
||||||
# TLS terminates at Dokploy's Traefik; keep redirects relative so they stay on https.
|
# TLS terminates at Dokploy's Traefik; keep redirects relative so they stay on https.
|
||||||
absolute_redirect off;
|
absolute_redirect off;
|
||||||
|
|
||||||
|
# The client address arrives in X-Forwarded-For from Traefik on the private network.
|
||||||
|
set_real_ip_from 10.0.0.0/8;
|
||||||
|
set_real_ip_from 172.16.0.0/12;
|
||||||
|
set_real_ip_from 192.168.0.0/16;
|
||||||
|
real_ip_header X-Forwarded-For;
|
||||||
|
real_ip_recursive on;
|
||||||
server_tokens off;
|
server_tokens off;
|
||||||
|
|
||||||
gzip on;
|
gzip on;
|
||||||
@@ -24,6 +31,24 @@ server {
|
|||||||
return 200 "ok\n";
|
return 200 "ok\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# The contact form is the one dynamic path: server/contact.mjs, sending by SMTP.
|
||||||
|
location = /contact {
|
||||||
|
limit_except POST { deny all; }
|
||||||
|
client_max_body_size 64k;
|
||||||
|
proxy_pass http://127.0.0.1:3000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_read_timeout 30s;
|
||||||
|
proxy_intercept_errors on;
|
||||||
|
error_page 502 503 504 = @contact_down;
|
||||||
|
}
|
||||||
|
# Handler not running: send the visitor to the error page in their language.
|
||||||
|
location @contact_down {
|
||||||
|
if ($http_referer ~ "/de(/|$|#)") { return 303 /de/contact/error; }
|
||||||
|
return 303 /contact/error;
|
||||||
|
}
|
||||||
|
|
||||||
# trailingSlash: 'never' — /team is served from /team/index.html; /team/ redirects to /team.
|
# trailingSlash: 'never' — /team is served from /team/index.html; /team/ redirects to /team.
|
||||||
location ~ ^(.+)/$ {
|
location ~ ^(.+)/$ {
|
||||||
return 301 $1$is_args$args;
|
return 301 $1$is_args$args;
|
||||||
|
|||||||
+2
-1
@@ -8,7 +8,8 @@
|
|||||||
"dev": "ASTRO_TELEMETRY_DISABLED=1 astro dev",
|
"dev": "ASTRO_TELEMETRY_DISABLED=1 astro dev",
|
||||||
"build": "ASTRO_TELEMETRY_DISABLED=1 astro build",
|
"build": "ASTRO_TELEMETRY_DISABLED=1 astro build",
|
||||||
"preview": "ASTRO_TELEMETRY_DISABLED=1 astro preview",
|
"preview": "ASTRO_TELEMETRY_DISABLED=1 astro preview",
|
||||||
"check": "ASTRO_TELEMETRY_DISABLED=1 astro check"
|
"check": "ASTRO_TELEMETRY_DISABLED=1 astro check",
|
||||||
|
"contact": "node --env-file-if-exists=.env server/contact.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/inter-tight": "^5.3.0",
|
"@fontsource-variable/inter-tight": "^5.3.0",
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
/**
|
||||||
|
* The contact form handler. nginx proxies POST /contact here; everything else on the site stays static.
|
||||||
|
*
|
||||||
|
* The form posts first_name, last_name, email, message, lang and the honeypot website. A browser
|
||||||
|
* with scripting asks for JSON and shows the result in place; without scripting the answer is a
|
||||||
|
* redirect to /contact/sent or /contact/error (under /de for German).
|
||||||
|
*
|
||||||
|
* Configuration comes from the environment only, see README.md.
|
||||||
|
*/
|
||||||
|
import { createServer } from 'node:http';
|
||||||
|
import nodemailer from 'nodemailer';
|
||||||
|
|
||||||
|
const env = process.env;
|
||||||
|
const PORT = Number(env.CONTACT_PORT || 3000);
|
||||||
|
const HOST = env.CONTACT_HOST || '127.0.0.1';
|
||||||
|
const TO = env.CONTACT_TO || 'office@ciphercell.de';
|
||||||
|
const FROM = env.CONTACT_FROM || env.SMTP_USER;
|
||||||
|
const SMTP_PORT = Number(env.SMTP_PORT || 587);
|
||||||
|
|
||||||
|
if (!env.SMTP_HOST || !FROM) {
|
||||||
|
console.error('contact: SMTP_HOST and CONTACT_FROM (or SMTP_USER) must be set.');
|
||||||
|
process.exit(78); // EX_CONFIG: the entrypoint does not restart on this one.
|
||||||
|
}
|
||||||
|
|
||||||
|
const transport = nodemailer.createTransport({
|
||||||
|
host: env.SMTP_HOST,
|
||||||
|
port: SMTP_PORT,
|
||||||
|
// 465 speaks TLS from the first byte; 587 and 25 upgrade with STARTTLS, which we then require.
|
||||||
|
// SMTP_REQUIRE_TLS=false is for a local mail catcher only.
|
||||||
|
secure: env.SMTP_SECURE ? env.SMTP_SECURE === 'true' : SMTP_PORT === 465,
|
||||||
|
requireTLS: env.SMTP_REQUIRE_TLS !== 'false' && SMTP_PORT !== 465,
|
||||||
|
auth: env.SMTP_USER ? { user: env.SMTP_USER, pass: env.SMTP_PASS } : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Limits. The body cap is generous for four short fields; the rate caps stop a flood, not a person. */
|
||||||
|
const MAX_BODY = 32 * 1024;
|
||||||
|
const MAX = { first_name: 100, last_name: 100, email: 254, message: 5000 };
|
||||||
|
const PER_IP = { count: 5, windowMs: 10 * 60 * 1000 };
|
||||||
|
const GLOBAL = { count: 60, windowMs: 60 * 60 * 1000 };
|
||||||
|
|
||||||
|
const hits = new Map();
|
||||||
|
const globalHits = [];
|
||||||
|
function limited(ip, now = Date.now()) {
|
||||||
|
while (globalHits.length && now - globalHits[0] > GLOBAL.windowMs) globalHits.shift();
|
||||||
|
const mine = (hits.get(ip) || []).filter((t) => now - t < PER_IP.windowMs);
|
||||||
|
if (mine.length >= PER_IP.count || globalHits.length >= GLOBAL.count) return true;
|
||||||
|
mine.push(now);
|
||||||
|
hits.set(ip, mine);
|
||||||
|
globalHits.push(now);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [ip, list] of hits) if (list.every((t) => now - t >= PER_IP.windowMs)) hits.delete(ip);
|
||||||
|
}, PER_IP.windowMs).unref();
|
||||||
|
|
||||||
|
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
const oneLine = (s) => s.replace(/[\r\n\t]+/g, ' ').trim();
|
||||||
|
|
||||||
|
function validate(form) {
|
||||||
|
const data = {
|
||||||
|
first_name: oneLine(form.get('first_name') || ''),
|
||||||
|
last_name: oneLine(form.get('last_name') || ''),
|
||||||
|
email: oneLine(form.get('email') || ''),
|
||||||
|
message: (form.get('message') || '').replace(/\r\n/g, '\n').trim(),
|
||||||
|
};
|
||||||
|
for (const [key, max] of Object.entries(MAX)) {
|
||||||
|
if (!data[key] || data[key].length > max) return null;
|
||||||
|
}
|
||||||
|
return EMAIL.test(data.email) ? data : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBody(req) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let size = 0;
|
||||||
|
const chunks = [];
|
||||||
|
req.on('data', (chunk) => {
|
||||||
|
size += chunk.length;
|
||||||
|
if (size > MAX_BODY) { reject(new Error('too large')); req.destroy(); return; }
|
||||||
|
chunks.push(chunk);
|
||||||
|
});
|
||||||
|
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
||||||
|
req.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function reply(req, res, lang, outcome) {
|
||||||
|
const status = { sent: 200, invalid: 400, limited: 429, failed: 502 }[outcome];
|
||||||
|
if ((req.headers.accept || '').includes('application/json')) {
|
||||||
|
res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
||||||
|
res.end(JSON.stringify({ ok: outcome === 'sent', outcome }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const page = outcome === 'sent' ? 'sent' : 'error';
|
||||||
|
res.writeHead(303, { Location: `${lang === 'de' ? '/de' : ''}/contact/${page}`, 'Cache-Control': 'no-store' });
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = createServer(async (req, res) => {
|
||||||
|
const path = (req.url || '').split('?')[0];
|
||||||
|
if (path === '/contact/healthz') { res.writeHead(200).end('ok\n'); return; }
|
||||||
|
if (req.method !== 'POST' || path !== '/contact') { res.writeHead(404).end(); return; }
|
||||||
|
|
||||||
|
// A cross-site post is not a visitor using the form.
|
||||||
|
const origin = req.headers.origin;
|
||||||
|
const host = req.headers['x-forwarded-host'] || req.headers.host;
|
||||||
|
if (origin && host && new URL(origin).host !== host) { res.writeHead(403).end(); return; }
|
||||||
|
|
||||||
|
let form;
|
||||||
|
try {
|
||||||
|
form = new URLSearchParams(await readBody(req));
|
||||||
|
} catch {
|
||||||
|
res.writeHead(413).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const lang = form.get('lang') === 'de' ? 'de' : 'en';
|
||||||
|
|
||||||
|
// Filled honeypot: answer as if sent, so the bot learns nothing.
|
||||||
|
if (form.get('website')) { reply(req, res, lang, 'sent'); return; }
|
||||||
|
|
||||||
|
const data = validate(form);
|
||||||
|
if (!data) { reply(req, res, lang, 'invalid'); return; }
|
||||||
|
|
||||||
|
// nginx sets X-Real-IP from the proxy chain; fall back to the socket for local runs.
|
||||||
|
const ip = req.headers['x-real-ip'] || req.socket.remoteAddress || 'unknown';
|
||||||
|
if (limited(String(ip))) { reply(req, res, lang, 'limited'); return; }
|
||||||
|
|
||||||
|
const name = `${data.first_name} ${data.last_name}`;
|
||||||
|
try {
|
||||||
|
await transport.sendMail({
|
||||||
|
from: { name: 'CipherCell website', address: FROM },
|
||||||
|
to: TO,
|
||||||
|
replyTo: { name, address: data.email },
|
||||||
|
subject: `Website enquiry from ${name}`,
|
||||||
|
text: [
|
||||||
|
`Name: ${name}`,
|
||||||
|
`Email: ${data.email}`,
|
||||||
|
`Language: ${lang}`,
|
||||||
|
'',
|
||||||
|
data.message,
|
||||||
|
'',
|
||||||
|
'-- ',
|
||||||
|
'Sent from the contact form on ciphercell.de. Reply to this email to answer.',
|
||||||
|
].join('\n'),
|
||||||
|
});
|
||||||
|
reply(req, res, lang, 'sent');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('contact: sending failed:', err.message);
|
||||||
|
reply(req, res, lang, 'failed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(PORT, HOST, () => console.log(`contact: listening on ${HOST}:${PORT}, delivering to ${TO}`));
|
||||||
Generated
+25
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "ciphercell-contact",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "ciphercell-contact",
|
||||||
|
"dependencies": {
|
||||||
|
"nodemailer": "^7.0.13"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/nodemailer": {
|
||||||
|
"version": "7.0.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz",
|
||||||
|
"integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==",
|
||||||
|
"license": "MIT-0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "ciphercell-contact",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Receives the contact form and sends it on by SMTP. Runs next to nginx; see README.",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node contact.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"nodemailer": "^7.0.13"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,17 @@ const f = ui.contact.form;
|
|||||||
</address>
|
</address>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form class="contact__form" action="/contact" method="post" lang={lang}>
|
<form
|
||||||
|
class="contact__form"
|
||||||
|
action="/contact"
|
||||||
|
method="post"
|
||||||
|
lang={lang}
|
||||||
|
data-contact
|
||||||
|
data-sent={f.sent}
|
||||||
|
data-invalid={f.invalid}
|
||||||
|
data-failed={f.failed}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="lang" value={lang} />
|
||||||
<p class="visually-hidden" aria-hidden="true">
|
<p class="visually-hidden" aria-hidden="true">
|
||||||
<label for="website">{f.honeypot}</label>
|
<label for="website">{f.honeypot}</label>
|
||||||
<input type="text" id="website" name="website" tabindex="-1" autocomplete="off" />
|
<input type="text" id="website" name="website" tabindex="-1" autocomplete="off" />
|
||||||
@@ -43,6 +53,22 @@ const f = ui.contact.form;
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- The form's answer, as a toast in the lower left. Framed like the menu, on night. -->
|
||||||
|
<div
|
||||||
|
class="toast hud-frame"
|
||||||
|
data-toast
|
||||||
|
data-label-sending={f.toast.sending}
|
||||||
|
data-label-sent={f.toast.sent}
|
||||||
|
data-label-failed={f.toast.failed}
|
||||||
|
>
|
||||||
|
<div class="toast__head">
|
||||||
|
<p class="toast__label"><span data-toast-label></span><span class="toast__dots" aria-hidden="true"><span>.</span><span>.</span><span>.</span></span></p>
|
||||||
|
<button class="toast__close" type="button" data-toast-close>{f.toast.close}</button>
|
||||||
|
</div>
|
||||||
|
<p class="toast__message" role="status" aria-live="polite" data-toast-message></p>
|
||||||
|
<span class="toast__timer" aria-hidden="true" data-toast-timer></span>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -89,6 +115,107 @@ const f = ui.contact.form;
|
|||||||
margin-top: var(--space-2);
|
margin-top: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.contact__form[aria-busy='true'] .button { opacity: 0.6; cursor: progress; }
|
||||||
|
|
||||||
|
/* Toast: a night panel fixed in the lower right. The menu's corner crosses frame it; their arms
|
||||||
|
reach out onto the page, so they take the menu's line colour, not the night one. */
|
||||||
|
.toast {
|
||||||
|
--frame-color: var(--hud-line);
|
||||||
|
position: fixed;
|
||||||
|
right: var(--gutter);
|
||||||
|
bottom: var(--gutter);
|
||||||
|
z-index: 20;
|
||||||
|
width: min(26rem, calc(100% - 2 * var(--gutter)));
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-4) var(--space-5) var(--space-5);
|
||||||
|
background: var(--night);
|
||||||
|
color: var(--ink-on-night);
|
||||||
|
/* Closed: lowered and slightly small, grown from its corner. Opening pops it up; closing
|
||||||
|
just sinks back, quicker and without the overshoot. */
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transform: translateY(var(--space-5)) scale(0.94);
|
||||||
|
transform-origin: bottom right;
|
||||||
|
transition:
|
||||||
|
opacity var(--dur-fast) var(--ease),
|
||||||
|
transform var(--dur-fast) var(--ease),
|
||||||
|
visibility 0s linear var(--dur-fast);
|
||||||
|
}
|
||||||
|
.toast[data-open] {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
transform: none;
|
||||||
|
transition:
|
||||||
|
opacity var(--dur-fast) var(--ease),
|
||||||
|
transform var(--dur-slow) var(--ease-pop);
|
||||||
|
}
|
||||||
|
.toast__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
.toast__label,
|
||||||
|
.toast__close {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--step-micro);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: var(--track-caps);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.toast__label { color: var(--ink-on-night); }
|
||||||
|
/* While sending, three dots after the label light up one after another. */
|
||||||
|
.toast__dots { display: none; }
|
||||||
|
.toast[data-state='sending'] .toast__dots { display: inline; }
|
||||||
|
.toast__dots span { animation: calc(4 * var(--dur-slow)) steps(1, end) infinite; }
|
||||||
|
.toast__dots span:nth-child(1) { animation-name: toast-dot-1; }
|
||||||
|
.toast__dots span:nth-child(2) { animation-name: toast-dot-2; }
|
||||||
|
.toast__dots span:nth-child(3) { animation-name: toast-dot-3; }
|
||||||
|
.toast__close {
|
||||||
|
color: var(--ink-muted-on-night);
|
||||||
|
padding: var(--space-2);
|
||||||
|
margin: calc(-1 * var(--space-2));
|
||||||
|
transition: color var(--dur-fast) var(--ease);
|
||||||
|
}
|
||||||
|
.toast__close:hover { color: var(--ink-on-night); }
|
||||||
|
.toast__close:focus-visible { outline-color: var(--ink-on-night); }
|
||||||
|
.toast__message {
|
||||||
|
font-size: var(--step-small);
|
||||||
|
line-height: var(--leading-small);
|
||||||
|
color: var(--ink-muted-on-night);
|
||||||
|
}
|
||||||
|
.toast__message:empty { display: none; }
|
||||||
|
/* A hairline counts down the time until the toast leaves, longer for an error, which has more
|
||||||
|
to read. Pointer or focus pauses it. */
|
||||||
|
.toast__timer {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
height: var(--hud-line-width);
|
||||||
|
background: var(--ink-muted-on-night);
|
||||||
|
transform-origin: left;
|
||||||
|
transform: scaleX(0);
|
||||||
|
}
|
||||||
|
.toast[data-open][data-state='sent'] .toast__timer { animation: toast-timer var(--dur-toast) linear forwards; }
|
||||||
|
.toast[data-open][data-state='failed'] .toast__timer { animation: toast-timer var(--dur-toast-error) linear forwards; }
|
||||||
|
.toast:hover .toast__timer,
|
||||||
|
.toast:focus-within .toast__timer { animation-play-state: paused; }
|
||||||
|
@keyframes toast-timer { from { transform: scaleX(1); } to { transform: scaleX(0); } }
|
||||||
|
/* One shared cycle in four beats: none, one, two, three dots. */
|
||||||
|
@keyframes toast-dot-1 { 0% { opacity: 0; } 25%, 100% { opacity: 1; } }
|
||||||
|
@keyframes toast-dot-2 { 0% { opacity: 0; } 50%, 100% { opacity: 1; } }
|
||||||
|
@keyframes toast-dot-3 { 0% { opacity: 0; } 75%, 100% { opacity: 1; } }
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.toast, .toast[data-open] { transition: none; transform: none; }
|
||||||
|
.toast__dots span { animation: none; }
|
||||||
|
/* The timer still runs, since it dismisses the toast, but it is not drawn. */
|
||||||
|
.toast__timer { visibility: hidden; }
|
||||||
|
}
|
||||||
|
|
||||||
@media (min-width: 48rem) {
|
@media (min-width: 48rem) {
|
||||||
.contact__text { grid-column: 1 / span 5; }
|
.contact__text { grid-column: 1 / span 5; }
|
||||||
.contact__form { grid-column: 7 / span 6; }
|
.contact__form { grid-column: 7 / span 6; }
|
||||||
@@ -98,3 +225,62 @@ const f = ui.contact.form;
|
|||||||
.field { grid-column: 1 / -1; }
|
.field { grid-column: 1 / -1; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// With scripting the form sends in place and answers in a toast. Without it, the server
|
||||||
|
// redirects to /contact/sent or /contact/error.
|
||||||
|
const form = document.querySelector<HTMLFormElement>('[data-contact]');
|
||||||
|
const toast = document.querySelector<HTMLElement>('[data-toast]');
|
||||||
|
if (form && toast) {
|
||||||
|
const label = toast.querySelector<HTMLElement>('[data-toast-label]')!;
|
||||||
|
const message = toast.querySelector<HTMLElement>('[data-toast-message]')!;
|
||||||
|
const timer = toast.querySelector<HTMLElement>('[data-toast-timer]')!;
|
||||||
|
const close = toast.querySelector<HTMLButtonElement>('[data-toast-close]')!;
|
||||||
|
const submit = form.querySelector<HTMLButtonElement>('[type="submit"]')!;
|
||||||
|
|
||||||
|
const show = (state: 'sending' | 'sent' | 'failed', text: string) => {
|
||||||
|
toast.dataset.state = state;
|
||||||
|
toast.setAttribute('data-open', '');
|
||||||
|
// Restart the countdown for a second message in a row.
|
||||||
|
timer.style.animation = 'none';
|
||||||
|
void timer.offsetWidth;
|
||||||
|
timer.style.animation = '';
|
||||||
|
label.textContent = toast.dataset[`label${state[0].toUpperCase()}${state.slice(1)}`] ?? '';
|
||||||
|
// Set on the next frame, so the now visible live region announces it.
|
||||||
|
requestAnimationFrame(() => { message.textContent = text; });
|
||||||
|
};
|
||||||
|
const hide = () => {
|
||||||
|
if (toast.contains(document.activeElement)) submit.focus();
|
||||||
|
toast.removeAttribute('data-open');
|
||||||
|
};
|
||||||
|
|
||||||
|
timer.addEventListener('animationend', hide);
|
||||||
|
close.addEventListener('click', hide);
|
||||||
|
toast.addEventListener('keydown', (e) => { if (e.key === 'Escape') hide(); });
|
||||||
|
|
||||||
|
form.addEventListener('submit', async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (form.getAttribute('aria-busy') === 'true') return;
|
||||||
|
form.setAttribute('aria-busy', 'true');
|
||||||
|
show('sending', '');
|
||||||
|
let outcome = 'failed';
|
||||||
|
try {
|
||||||
|
const res = await fetch(form.action, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
body: new URLSearchParams(new FormData(form) as unknown as Record<string, string>),
|
||||||
|
});
|
||||||
|
outcome = (await res.json()).outcome ?? 'failed';
|
||||||
|
} catch {
|
||||||
|
outcome = 'failed';
|
||||||
|
}
|
||||||
|
if (outcome === 'sent') {
|
||||||
|
form.reset();
|
||||||
|
show('sent', form.dataset.sent ?? '');
|
||||||
|
} else {
|
||||||
|
show('failed', (outcome === 'invalid' ? form.dataset.invalid : form.dataset.failed) ?? '');
|
||||||
|
}
|
||||||
|
form.removeAttribute('aria-busy');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -207,7 +207,9 @@ const quoted = (text: string) => (lang === 'de' ? `„${text}“` : `“${text}
|
|||||||
@media (min-width: 64rem) {
|
@media (min-width: 64rem) {
|
||||||
.quote__frame { grid-template-columns: 11fr 9fr; }
|
.quote__frame { grid-template-columns: 11fr 9fr; }
|
||||||
.quote__panel { padding: var(--space-8) var(--space-8); }
|
.quote__panel { padding: var(--space-8) var(--space-8); }
|
||||||
.quote__img, :global(.quote__img) { aspect-ratio: auto; }
|
/* The image's own ratio once loaded, and the same square reserved before, so the lazy
|
||||||
|
portrait does not grow the section when it arrives. Both portraits are square. */
|
||||||
|
.quote__img, :global(.quote__img) { aspect-ratio: auto 1 / 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const items = ui.sectors.items.map((item, i) => ({ ...item, image: images[i], id
|
|||||||
tabindex={i === 0 ? undefined : -1}
|
tabindex={i === 0 ? undefined : -1}
|
||||||
>{item.title}</button>
|
>{item.title}</button>
|
||||||
</h3>
|
</h3>
|
||||||
<div class="sectors__panel" role="tabpanel" id={`${item.id}-panel`} aria-labelledby={`${item.id}-tab`} tabindex="0">
|
<div class="sectors__panel" role="tabpanel" id={`${item.id}-panel`} aria-labelledby={`${item.id}-tab`} tabindex="0" data-open={i === 0 ? '' : undefined}>
|
||||||
<div class="sectors__panel-inner">
|
<div class="sectors__panel-inner">
|
||||||
<p class="muted">{item.body}</p>
|
<p class="muted">{item.body}</p>
|
||||||
<Picture
|
<Picture
|
||||||
@@ -104,7 +104,8 @@ const items = ui.sectors.items.map((item, i) => ({ ...item, image: images[i], id
|
|||||||
/* The stage image on the right exists for wide screens. On narrow screens each panel carries its own image. */
|
/* The stage image on the right exists for wide screens. On narrow screens each panel carries its own image. */
|
||||||
.sectors__media { display: none; }
|
.sectors__media { display: none; }
|
||||||
|
|
||||||
/* With scripting, one panel at a time; without, everything is readable. */
|
/* With scripting, one panel at a time; without, everything is readable. The first panel is
|
||||||
|
open in the markup, so nothing expands on load and the page below it does not move. */
|
||||||
:global(html.js) .sectors__panel {
|
:global(html.js) .sectors__panel {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: 0fr;
|
grid-template-rows: 0fr;
|
||||||
|
|||||||
+17
-1
@@ -156,6 +156,14 @@ const en = {
|
|||||||
required: 'Required.',
|
required: 'Required.',
|
||||||
honeypot: 'Leave this field empty',
|
honeypot: 'Leave this field empty',
|
||||||
submit: 'Send message',
|
submit: 'Send message',
|
||||||
|
sent: 'Message sent. We respond within 24 hours.',
|
||||||
|
invalid: 'Please fill in every field and give a valid email address.',
|
||||||
|
failed: 'Your message did not go through. Please try again, or write to office@ciphercell.de.',
|
||||||
|
toast: { sending: 'Sending', sent: 'Sent', failed: 'Not sent', close: 'Close' },
|
||||||
|
},
|
||||||
|
result: {
|
||||||
|
sent: { title: 'Message sent.', body: 'Thank you. We respond within 24 hours.', link: 'Go to the start page' },
|
||||||
|
error: { title: 'Message not sent.', body: 'Your message did not go through. Please try again, or write to office@ciphercell.de.', link: 'Return to the form' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
footer: {
|
footer: {
|
||||||
@@ -288,7 +296,7 @@ const en = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
team: {
|
team: {
|
||||||
title: ['Researchers,', 'engineers,', 'architects.'],
|
title: ['Engineers,', 'architects,', 'researchers.'],
|
||||||
intro: 'The people who go deep on the problems that matter, from network, software to hardware and cloud-native systems. If it\'s a genuinely hard problem, we\'re in our element.',
|
intro: 'The people who go deep on the problems that matter, from network, software to hardware and cloud-native systems. If it\'s a genuinely hard problem, we\'re in our element.',
|
||||||
photoAlt: 'The CipherCell team on a staircase in the offices in Passau.',
|
photoAlt: 'The CipherCell team on a staircase in the offices in Passau.',
|
||||||
roles: {
|
roles: {
|
||||||
@@ -463,6 +471,14 @@ const de: typeof en = {
|
|||||||
required: 'Pflichtfeld.',
|
required: 'Pflichtfeld.',
|
||||||
honeypot: 'Dieses Feld bitte leer lassen',
|
honeypot: 'Dieses Feld bitte leer lassen',
|
||||||
submit: 'Nachricht senden',
|
submit: 'Nachricht senden',
|
||||||
|
sent: 'Nachricht gesendet. Wir antworten innerhalb von 24 Stunden.',
|
||||||
|
invalid: 'Bitte füllen Sie alle Felder aus und geben Sie eine gültige E-Mail-Adresse an.',
|
||||||
|
failed: 'Ihre Nachricht ist nicht angekommen. Bitte versuchen Sie es erneut oder schreiben Sie an office@ciphercell.de.',
|
||||||
|
toast: { sending: 'Wird gesendet', sent: 'Gesendet', failed: 'Nicht gesendet', close: 'Schließen' },
|
||||||
|
},
|
||||||
|
result: {
|
||||||
|
sent: { title: 'Nachricht gesendet.', body: 'Vielen Dank. Wir antworten innerhalb von 24 Stunden.', link: 'Zur Startseite' },
|
||||||
|
error: { title: 'Nachricht nicht gesendet.', body: 'Ihre Nachricht ist nicht angekommen. Bitte versuchen Sie es erneut oder schreiben Sie an office@ciphercell.de.', link: 'Zurück zum Formular' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
footer: {
|
footer: {
|
||||||
|
|||||||
@@ -9,10 +9,12 @@ interface Props {
|
|||||||
description: string;
|
description: string;
|
||||||
lang?: Lang;
|
lang?: Lang;
|
||||||
ogImage?: string;
|
ogImage?: string;
|
||||||
|
/** Pages that only make sense as the answer to an action, such as the contact form result. */
|
||||||
|
noindex?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const lang: Lang = Astro.props.lang ?? langFromUrl(Astro.url);
|
const lang: Lang = Astro.props.lang ?? langFromUrl(Astro.url);
|
||||||
const { title, description } = Astro.props;
|
const { title, description, noindex } = Astro.props;
|
||||||
const ui = t(lang);
|
const ui = t(lang);
|
||||||
const fullTitle = title === ui.meta.home.title ? title : `${title}. ${ui.meta.siteName}`;
|
const fullTitle = title === ui.meta.home.title ? title : `${title}. ${ui.meta.siteName}`;
|
||||||
const canonical = new URL(Astro.url.pathname, Astro.site);
|
const canonical = new URL(Astro.url.pathname, Astro.site);
|
||||||
@@ -26,6 +28,7 @@ const altDe = new URL(switchLang(Astro.url, 'de'), Astro.site);
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>{fullTitle}</title>
|
<title>{fullTitle}</title>
|
||||||
<meta name="description" content={description} />
|
<meta name="description" content={description} />
|
||||||
|
{noindex && <meta name="robots" content="noindex" />}
|
||||||
<meta name="color-scheme" content="light" />
|
<meta name="color-scheme" content="light" />
|
||||||
<meta name="theme-color" content="#E9E5DF" />
|
<meta name="theme-color" content="#E9E5DF" />
|
||||||
<link rel="canonical" href={canonical} />
|
<link rel="canonical" href={canonical} />
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
import Base from '../layouts/Base.astro';
|
||||||
|
import { localePath, t, type Lang } from '../i18n/ui';
|
||||||
|
interface Props { lang: Lang; outcome: 'sent' | 'error' }
|
||||||
|
const { lang, outcome } = Astro.props;
|
||||||
|
const r = t(lang).contact.result[outcome];
|
||||||
|
// After an error the way forward is the form again; after sending it is the rest of the site.
|
||||||
|
const href = outcome === 'sent' ? localePath(lang, '/') : `${localePath(lang, '/')}#contact`;
|
||||||
|
---
|
||||||
|
<Base lang={lang} title={r.title} description={r.body} noindex>
|
||||||
|
<section class="section">
|
||||||
|
<div class="container grid">
|
||||||
|
<h1 class="display result__title">{r.title}</h1>
|
||||||
|
<div class="result__body">
|
||||||
|
<p class="muted">{r.body}</p>
|
||||||
|
<a class="link" href={href}>{r.link}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</Base>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.result__title { grid-column: 1 / -1; }
|
||||||
|
.result__body { grid-column: 1 / -1; display: grid; gap: var(--space-5); max-width: var(--measure); }
|
||||||
|
@media (min-width: 48rem) {
|
||||||
|
.result__title { grid-column: 1 / span 8; }
|
||||||
|
.result__body { grid-column: 1 / span 5; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
---
|
||||||
|
import ContactResult from '../_ContactResult.astro';
|
||||||
|
---
|
||||||
|
<ContactResult lang="en" outcome="error" />
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
---
|
||||||
|
import ContactResult from '../_ContactResult.astro';
|
||||||
|
---
|
||||||
|
<ContactResult lang="en" outcome="sent" />
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
---
|
||||||
|
import ContactResult from '../../_ContactResult.astro';
|
||||||
|
---
|
||||||
|
<ContactResult lang="de" outcome="error" />
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
---
|
||||||
|
import ContactResult from '../../_ContactResult.astro';
|
||||||
|
---
|
||||||
|
<ContactResult lang="de" outcome="sent" />
|
||||||
@@ -121,6 +121,8 @@
|
|||||||
|
|
||||||
/* Motion */
|
/* Motion */
|
||||||
--ease: cubic-bezier(0.2, 0, 0, 1);
|
--ease: cubic-bezier(0.2, 0, 0, 1);
|
||||||
|
/* A small overshoot, for the one element that pops up: the contact form's toast. */
|
||||||
|
--ease-pop: cubic-bezier(0.34, 1.4, 0.64, 1);
|
||||||
--dur-fast: 180ms;
|
--dur-fast: 180ms;
|
||||||
--dur: 240ms;
|
--dur: 240ms;
|
||||||
--dur-slow: 320ms;
|
--dur-slow: 320ms;
|
||||||
@@ -128,6 +130,8 @@
|
|||||||
--dur-rise: 700ms; /* a headline line rising in on load */
|
--dur-rise: 700ms; /* a headline line rising in on load */
|
||||||
--delay-rise: 240ms; /* before the first line */
|
--delay-rise: 240ms; /* before the first line */
|
||||||
--stagger-rise: 160ms; /* between lines */
|
--stagger-rise: 160ms; /* between lines */
|
||||||
|
--dur-toast: 6000ms; /* a success toast stays this long */
|
||||||
|
--dur-toast-error: 9000ms; /* an error toast, which has more to read */
|
||||||
|
|
||||||
/* Chrome */
|
/* Chrome */
|
||||||
--header-height: 4.5rem;
|
--header-height: 4.5rem;
|
||||||
|
|||||||
Reference in new issue
Block a user