Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,8 @@
(`--force` overrides). Never hand-run `docker compose build/up` on the server: that is a side door
(a hand-run compose caused a 502 on manager, 2026-09-24). Server checkout `/var/www/ai-factory`,
stack `infra/docker-compose.prod.yml`. Verify by grepping the new code inside the container, not by uptime.
- After every commit and push/deploy, the report goes to Teams through the session "Bộ chỉ huy"
(2026-09-26): send it the `~/.openclaw/workspace/ops/teams-commit.sh <repo> <hash> deployed` lines with
SendMessage and let it post. Never run `teams-commit.sh` or `teams-send.sh` from this session: all
sends share one headless Chrome (CDP 9333), and two sessions at once crash it.
- After every commit and push/deploy, post it to Teams yourself with `~/.openclaw/workspace/ops/teams-commit.sh`.
If it fails with a CDP or login-looking error, run `~/.openclaw/workspace/ops/teams-chrome.sh ensure` and retry.
- Mail goes out through the company's world4you SMTP (`smtp.world4you.com:587`, STARTTLS) as
`developerweb@codemenschen.at`, DKIM-signed for codemenschen.at. Not Resend (its Tokyo IPs
landed the sign-in mail in spam, 2026-09-07) and not the host's sendmail (generic rDNS, no DKIM).
Expand Down
19 changes: 9 additions & 10 deletions apps/web/src/app/(site)/[locale]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { AccountLink } from "@/components/AccountLink";
import { LangSwitch } from "@/components/LangSwitch";
import { Logo } from "@/components/Logo";
import { MobileNav } from "@/components/MobileNav";
import { NavLinks } from "@/components/NavLinks";
import { PageViews } from "@/components/PageViews";
import { AdConsentBanner, AdConsentLink } from "@/components/AdConsent";
import "../../globals.css";
Expand All @@ -29,14 +30,15 @@ export default async function LocaleLayout({
const { locale } = await params;
if (!isLocale(locale)) notFound();
const dict = getDict(locale as Locale);
// The same links in the bar and, on a phone, behind the menu button.
// The same links in the bar and, on a phone, behind the menu button. The prototype page is the
// start button's, not a fifth link: two ways to the same page only made the bar longer.
const navLinks = [
{ href: `/${locale}#services`, label: dict.nav.services },
{ href: `/${locale}#how`, label: dict.nav.how },
{ href: `/${locale}#prices`, label: dict.nav.pricing },
{ href: `/${locale}/app`, label: dict.nav.appDev },
{ href: `/${locale}/prototype`, label: dict.proto.navLink },
];
const cta = { href: `/${locale}/prototype`, label: dict.nav.cta };

return (
<html lang={locale}>
Expand All @@ -48,18 +50,15 @@ export default async function LocaleLayout({
<Link href={`/${locale}`} className="nav-logo" aria-label="Appwerk">
<Logo by={dict.nav.by} />
</Link>
<nav className="nav-links">
{navLinks.map((l) => (
<Link key={l.href} href={l.href}>{l.label}</Link>
))}
</nav>
<NavLinks links={navLinks} />
<div className="nav-right">
<AccountLink locale={locale as Locale} labels={{ account: dict.nav.account, login: dict.nav.login }} />
<LangSwitch current={locale as Locale} />
<Link className="btn btn-primary btn-sm nav-cta" href={`/${locale}/prototype`}>
{dict.nav.cta}
<Link className="btn btn-primary btn-sm nav-cta" href={cta.href}>
<span className="nav-cta-long">{dict.nav.cta}</span>
<span className="nav-cta-short">{dict.nav.ctaShort}</span>
</Link>
<MobileNav links={navLinks} />
<MobileNav links={navLinks} cta={cta} />
</div>
</div>
</header>
Expand Down
29 changes: 21 additions & 8 deletions apps/web/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -76,22 +76,32 @@ section { padding: 72px 0; }
.logo-name { font-family: var(--font-body); font-size: 1.45rem; font-weight: 800; letter-spacing: -0.03em; }
.logo-by { font-family: var(--font-body); font-size: 0.68rem; font-weight: 500; color: var(--ink-soft); letter-spacing: 0; }
.nav-links { display: flex; gap: 22px; margin: 0 auto; }
.nav-links a { color: var(--ink-soft); text-decoration: none; font-size: 0.92rem; font-weight: 500; white-space: nowrap; }
.nav-links a:hover { color: var(--ink); }
.nav-links a { position: relative; color: var(--ink-soft); text-decoration: none; font-size: 0.92rem; font-weight: 500; white-space: nowrap; padding: 6px 0; }
.nav-links a:hover, .nav-links a[aria-current="page"] { color: var(--ink); }
.nav-links a::after { content: ""; position: absolute; left: 0; right: 0; bottom: 0; height: 2px; border-radius: 2px; background: var(--accent); transform: scaleX(0); transition: transform 0.18s ease; }
.nav-links a:hover::after, .nav-links a[aria-current="page"]::after { transform: scaleX(1); }
.nav-right { display: flex; align-items: center; gap: 10px; margin-left: auto; }
.nav-right > * { white-space: nowrap; }
.lang-toggle { font-size: 13px; font-weight: 600; border: 1px solid var(--border); border-radius: 99px; padding: 4px 12px; color: var(--ink-soft); text-decoration: none; background: var(--surface); }
.lang-toggle:hover { border-color: var(--ink-soft); color: var(--ink); }
/* German labels are longer; drop the header CTA before the bar can wrap */
@media (max-width: 1320px) { .nav-right .nav-cta { display: none; } }
.nav-account { display: inline-flex; align-items: center; }
.nav-account-ico { display: none; width: 16px; height: 16px; }
/* The start button always stays: its long label where there is room, a short one where there is not,
and on a phone it moves into the menu panel. */
.nav-cta-short { display: none; }
@media (max-width: 1180px) { .nav-cta-long { display: none; } .nav-cta-short { display: inline; } .nav-links { gap: 18px; } }
@media (max-width: 1000px) { .nav-links { display: none; } }
@media (max-width: 640px) { .nav-right .nav-cta { display: none; } }

/* The menu button and its panel: without them a phone had no menu at all. */
.nav-burger { display: none; position: relative; }
.nav-burger-btn { display: inline-flex; align-items: center; justify-content: center; width: 38px; height: 38px; font-size: 18px; line-height: 1; border: 1px solid var(--border); border-radius: 99px; background: var(--surface); color: var(--ink); cursor: pointer; }
.nav-panel { position: absolute; right: 0; top: calc(100% + 10px); min-width: 210px; display: grid; gap: 2px; padding: 8px; background: var(--surface); border: 1px solid var(--border); border-radius: 14px; box-shadow: 0 12px 30px rgba(0,0,0,.12); }
.nav-panel a { padding: 10px 12px; border-radius: 10px; color: var(--ink); text-decoration: none; font-size: 15px; font-weight: 500; }
.nav-panel a:hover { background: var(--bg); }
.nav-burger-btn { display: inline-flex; align-items: center; justify-content: center; width: 38px; height: 38px; padding: 0; border: 1px solid var(--border); border-radius: 99px; background: var(--surface); color: var(--ink); cursor: pointer; }
.nav-burger-btn[aria-expanded="true"] { border-color: var(--ink-soft); }
.nav-panel { position: absolute; right: 0; top: calc(100% + 10px); width: min(280px, calc(100vw - 32px)); display: grid; gap: 2px; padding: 8px; background: var(--surface); border: 1px solid var(--border); border-radius: 16px; box-shadow: 0 16px 40px rgba(15, 23, 42, 0.14); }
.nav-panel a:not(.btn) { padding: 11px 12px; border-radius: 10px; color: var(--ink); text-decoration: none; font-size: 15px; font-weight: 500; }
.nav-panel a:not(.btn):hover { background: var(--tint); }
.nav-panel a[aria-current="page"] { background: var(--accent-soft); color: var(--accent-hover); }
.nav-panel-cta { margin-top: 6px; text-align: center; }
@media (max-width: 1000px) { .nav-burger { display: block; } }
@media (max-width: 560px) { .logo-by { display: none; } }
/* German "Anmelden" made the bar 11px wider than a 360px phone; tighten it there */
Expand All @@ -100,6 +110,9 @@ section { padding: 72px 0; }
.nav-right { gap: 6px; }
.nav-right .lang-toggle { padding: 4px 9px; }
.logo-name { font-size: 1.25rem; }
.nav-account-label { display: none; }
.nav-account-ico { display: block; }
.nav-right .nav-account { padding: 6px 9px; }
}

/* ---------- Buttons ---------- */
Expand Down
8 changes: 6 additions & 2 deletions apps/web/src/components/AccountLink.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import Link from "next/link";
import type { Locale } from "@/lib/i18n";
import { useToken } from "@/lib/token";
import { Icon } from "./LineIcon";

/**
* Nav entry to the customer portal: "My projects" once a portal token exists on this device, "Sign in" before.
Expand All @@ -11,9 +12,12 @@ import { useToken } from "@/lib/token";
*/
export function AccountLink({ locale, labels }: { locale: Locale; labels: { account: string; login: string } }) {
const signedIn = !!useToken();
const label = signedIn ? labels.account : labels.login;
// On a small phone the words do not fit next to the logo: the person icon stands in for them.
return (
<Link href={`/${locale}/account`} className="lang-toggle nav-account">
{signedIn ? labels.account : labels.login}
<Link href={`/${locale}/account`} className="lang-toggle nav-account" aria-label={label}>
<Icon name="user" className="ico nav-account-ico" />
<span className="nav-account-label">{label}</span>
</Link>
);
}
29 changes: 21 additions & 8 deletions apps/web/src/components/MobileNav.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,36 @@
"use client";

import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";

/**
* The menu on a narrow screen. The links in the bar are hidden under 1000px, and until now
* nothing took their place: on a phone the site had no menu at all.
* The menu on a narrow screen: the links of the bar, and the start button, which the bar has no
* room for on a phone. Closes on Escape, on a click outside and when a link is followed.
*/
export function MobileNav({ links }: { links: { href: string; label: string }[] }) {
export function MobileNav({ links, cta }: { links: { href: string; label: string }[]; cta: { href: string; label: string } }) {
const path = usePathname();
// Open is remembered per page, so going somewhere else closes the menu without an effect.
const [openOn, setOpenOn] = useState<string | null>(null);
const open = openOn === path;
const setOpen = (next: boolean) => setOpenOn(next ? path : null);
const box = useRef<HTMLDivElement>(null);

useEffect(() => {
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpenOn(null);
const onClick = (e: MouseEvent) => {
if (box.current && !box.current.contains(e.target as Node)) setOpenOn(null);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
document.addEventListener("click", onClick);
return () => {
window.removeEventListener("keydown", onKey);
document.removeEventListener("click", onClick);
};
}, []);

return (
<div className="nav-burger">
<div className="nav-burger" ref={box}>
<button
type="button"
className="nav-burger-btn"
Expand All @@ -31,15 +39,20 @@ export function MobileNav({ links }: { links: { href: string; label: string }[]
aria-label="Menu"
onClick={() => setOpen(!open)}
>
<span aria-hidden="true">{open ? "✕" : "☰"}</span>
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
{open ? <path d="M6 6l12 12M18 6 6 18" /> : <path d="M4 7h16M4 12h16M4 17h16" />}
</svg>
</button>
{open && (
<nav id="nav-panel" className="nav-panel">
{links.map((l) => (
<Link key={l.href} href={l.href} onClick={() => setOpen(false)}>
<Link key={l.href} href={l.href} aria-current={l.href === path ? "page" : undefined} onClick={() => setOpen(false)}>
{l.label}
</Link>
))}
<Link className="btn btn-primary nav-panel-cta" href={cta.href} onClick={() => setOpen(false)}>
{cta.label}
</Link>
</nav>
)}
</div>
Expand Down
19 changes: 19 additions & 0 deletions apps/web/src/components/NavLinks.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"use client";

import Link from "next/link";
import { usePathname } from "next/navigation";

/** The links of the bar; the page one is on is marked, so the menu says where you are. */
export function NavLinks({ links }: { links: { href: string; label: string }[] }) {
const path = usePathname();

return (
<nav className="nav-links">
{links.map((l) => (
<Link key={l.href} href={l.href} aria-current={l.href === path ? "page" : undefined}>
{l.label}
</Link>
))}
</nav>
);
}
1 change: 1 addition & 0 deletions apps/web/src/dictionaries/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export const de = {
pricing: "Preise",
faq: "FAQ",
cta: "Kostenlose Vorschau starten",
ctaShort: "Gratis Vorschau",
account: "Meine Projekte",
login: "Anmelden",
},
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/dictionaries/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export const en = {
pricing: "Pricing",
faq: "FAQ",
cta: "Get a free preview",
ctaShort: "Free preview",
account: "My projects",
login: "Sign in",
},
Expand Down
Loading