fix website, basic seo, waitlist functionality

This commit is contained in:
Rajpreet0 2026-09-20 19:02:31 +02:00
parent eb5aca3a87
commit a369c20f22
25 changed files with 1305 additions and 115 deletions

37
app/agb/page.tsx Normal file
View file

@ -0,0 +1,37 @@
import type { Metadata } from "next";
import { LegalPage, Todo } from "../components/legal";
export const metadata: Metadata = {
title: "Allgemeine Geschäftsbedingungen",
description: "Allgemeine Geschäftsbedingungen für die Nutzung von Kumasy.",
alternates: { canonical: "/agb" },
robots: { index: false, follow: true },
};
export default function AgbPage() {
return (
<LegalPage
title="Allgemeine Geschäftsbedingungen"
intro="Bedingungen für die Nutzung von Kumasy."
>
<section>
<p>
<Todo>
Die AGB sind noch nicht verfasst. Sie sollten von einer
Rechtsanwältin oder einem Rechtsanwalt erstellt werden — ein
Vorlagentext führt bei einem SaaS-Vertrag mit Zahlungsabwicklung
und Auftragsverarbeitung erfahrungsgemäß zu unwirksamen Klauseln.
</Todo>
</p>
<p>
Folgende Punkte sind typischerweise zu regeln: Vertragsgegenstand
und Leistungsumfang, Zustandekommen des Vertrags, Preise und
Zahlungsbedingungen, Laufzeit und Kündigung, Verfügbarkeit und
Wartungsfenster, Pflichten der Nutzerin, Haftung und
Haftungsbeschränkung, Datenschutz und Auftragsverarbeitung,
Änderungen der AGB sowie Schlussbestimmungen und Gerichtsstand.
</p>
</section>
</LegalPage>
);
}

BIN
app/apple-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

View file

@ -137,7 +137,11 @@ export function Hero() {
{/* Per-character entrance. The <h1> carries the readable text for
assistive tech; the animated spans are aria-hidden. */}
<h1 className="mt-7 font-display text-[clamp(2.75rem,8.6vw,5.25rem)] font-semibold leading-[0.96] tracking-[-0.055em] text-ink">
{/* The floor is 2.1rem, not 2.75rem: the longest line ("und
bezahlen sich") has to fit 321px of usable width on a 353px
Samsung viewport. 10.2vw carries it from there up to the
5.25rem ceiling. */}
<h1 className="mt-7 text-balance font-display text-[clamp(2.1rem,10.2vw,5.25rem)] font-semibold leading-[0.96] tracking-[-0.055em] text-ink">
<span className="sr-only">
Deine Kurse füllen und bezahlen sich von selbst.
</span>
@ -171,7 +175,7 @@ export function Hero() {
<Reveal delay={0.65}>
<div className="mx-auto mt-9 max-w-[560px]" id="warteliste">
<WaitlistForm />
<WaitlistForm source="Hero" />
</div>
</Reveal>

60
app/components/legal.tsx Normal file
View file

@ -0,0 +1,60 @@
import type { ReactNode } from "react";
import { Footer } from "./sections";
import { Nav } from "./ui";
/**
* Shared shell for the legal pages. The root layout renders only
* {children}, so each page has to bring its own chrome — same reasoning
* as not-found.tsx.
*/
export function LegalPage({
title,
intro,
children,
}: {
title: string;
intro?: string;
children: ReactNode;
}) {
return (
<>
<Nav />
<main
id="hauptinhalt"
className="relative isolate flex-1 px-4 pb-24 pt-36 sm:pt-44"
>
<div className="mx-auto w-full max-w-[760px]">
<h1 className="text-balance font-display text-[clamp(2rem,5.5vw,3rem)] font-semibold leading-[1.04] tracking-[-0.04em] text-ink">
{title}
</h1>
{intro ? (
<p className="mt-5 text-pretty text-body-lg text-slate-body">
{intro}
</p>
) : null}
{/* Long-form legal copy: generous measure, clear hierarchy. */}
<div className="mt-12 flex flex-col gap-10 text-body text-slate-body [&_a]:font-medium [&_a]:text-ink [&_a]:underline [&_a]:decoration-amber [&_a]:decoration-2 [&_a]:underline-offset-4 [&_h2]:font-display [&_h2]:text-subheading [&_h2]:font-semibold [&_h2]:text-ink [&_li]:mt-1.5 [&_p]:mt-3 [&_ul]:mt-3 [&_ul]:list-disc [&_ul]:pl-5">
{children}
</div>
</div>
</main>
<Footer />
</>
);
}
/**
* Marks copy that still has to be supplied. Visible on purpose — a
* placeholder that blends in is a placeholder that ships.
*/
export function Todo({ children }: { children: ReactNode }) {
return (
<span className="rounded-md bg-peach-wash px-1.5 py-0.5 font-medium text-peach-ink">
{children}
</span>
);
}

View file

@ -50,7 +50,15 @@ export function BrowserFrame({
}
/* ------------------------------------------------------------------ *
* Floating glass stat chip
* Floating stat chip
*
* Deliberately opaque, unlike the rest of the glass family. These chips
* sit directly on top of the dashboard screenshot, so a translucent
* surface lets the sidebar labels and the chart line read straight
* through the chip's own text — backdrop-blur cannot fix that, because
* what is behind is small sharp type rather than a soft wash. Same call
* the app makes for dialogs: darken behind, never blur, so the content
* in front stays legible.
* ------------------------------------------------------------------ */
function GlassStat({
@ -65,7 +73,7 @@ function GlassStat({
tone: "mint" | "amber";
}) {
return (
<div className="glass-xl glass-rim w-[196px] rounded-[20px] p-4">
<div className="glass-rim w-[196px] rounded-[20px] border border-white/70 bg-paper p-4 shadow-elevated">
<div className="flex items-center gap-2">
<span
className={`grid size-7 place-items-center rounded-full ${

View file

@ -149,6 +149,14 @@ export function MaskedWords({
* KineticHeadline — per-character entrance for the hero only
* ------------------------------------------------------------------ */
/**
* Splits into words that keep their trailing space, so `"von "` still
* renders the gap before whatever follows it. Never breaks mid-word.
*/
function splitWords(text: string): string[] {
return text.match(/\S+\s*|\s+/g) ?? [];
}
const charVariants: Variants = {
hidden: { opacity: 0, y: "70%", rotateX: -70 },
shown: {
@ -222,15 +230,23 @@ export function KineticLine({
whileInView="shown"
viewport={{ once: true, amount: 0.3 }}
>
{Array.from(text).map((char, index) => (
<motion.span
key={index}
variants={charVariants}
className="inline-block origin-bottom"
style={{ transformStyle: "preserve-3d" }}
>
{char === " " ? " " : char}
</motion.span>
{/* Grouped per word, not per character: a bare list of inline-block
letters lets the browser break inside a word ("fülle/n"), which it
does on narrow phones. Each word is whitespace-nowrap, so it travels
as one unit while the stagger still runs across the whole line. */}
{splitWords(text).map((word, wordIndex) => (
<span key={wordIndex} className="inline-block whitespace-nowrap">
{Array.from(word).map((char, index) => (
<motion.span
key={index}
variants={charVariants}
className="inline-block origin-bottom"
style={{ transformStyle: "preserve-3d" }}
>
{char === " " ? " " : char}
</motion.span>
))}
</span>
))}
</motion.span>
);

View file

@ -1,6 +1,7 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import type { ComponentType, ReactNode } from "react";
import {
ArrowRight,
@ -30,6 +31,8 @@ import {
import { BrowserFrame, ParallaxLayer } from "./mockup";
import { BrandButton, Eyebrow, Faq, GhostLink, WaitlistForm } from "./ui";
import { Logo } from "./logo";
import { FAQ_ENTRIES } from "../lib/faq";
import { CONTACT_EMAIL } from "../lib/site";
/* ------------------------------------------------------------------ *
* Shared section scaffolding
@ -497,7 +500,15 @@ export function Pricing() {
Platz auf der Warteliste sichern
</BrandButton>
<p className="text-[13px] text-slate-helper">
[Mindestlaufzeit und Kündigungsfrist hier ergänzen]
Monatlich abrechenbar. Die Konditionen zu Laufzeit und
Kündigung stehen in den{" "}
<Link
href="/agb"
className="underline decoration-hairline underline-offset-2 transition-colors duration-200 hover:text-ink"
>
AGB
</Link>
.
</p>
</div>
</div>
@ -620,15 +631,15 @@ export function Pilot() {
</p>
</div>
<blockquote className="mt-8 border-l-2 border-hairline pl-5">
<p className="text-body-lg italic text-ink">
„[Hier das Zitat von 120gradStudio einsetzen — ein bis zwei
Sätze, am besten mit konkreter Zahl.]“
</p>
<footer className="mt-3 text-body text-slate-helper">
[Name], 120gradStudio
</footer>
</blockquote>
{/* Das Testimonial folgt, sobald 120gradStudio ein freigegebenes
Zitat geliefert hat. Bis dahin steht hier die belegbare
Aussage statt eines erfundenen Zitats — und im Markup kein
Review-Schema, das Google als Manipulation werten würde. */}
<p className="mt-8 border-l-2 border-amber pl-5 text-body-lg text-ink">
Seit dem Start laufen dort Kursplanung, Buchungen und Zahlungen
vollständig über Kumasy — im laufenden Betrieb, nicht in einer
Testumgebung.
</p>
</div>
</Reveal>
@ -685,33 +696,26 @@ export function FaqSection() {
</div>
<Reveal delay={0.1}>
{/* Driven by FAQ_ENTRIES so the rendered text and the FAQPage
structured data cannot drift apart — Google requires them to
match exactly. */}
<div className="border-t border-hairline">
<Faq question="Brauchen meine Kundinnen einen Account?">
Nein. Gastbuchung ohne Registrierung ist Standard — genau da
springen sonst die meisten ab. Wer will, legt sich trotzdem ein
Konto an und sieht seine Buchungshistorie.
</Faq>
<Faq question="Was passiert mit meinen bestehenden Daten?">
Unser Migrationstool pflegt Kurse, Kundinnen und Termine aus
deinem alten System automatisch in Kumasy ein. Du musst nichts
abtippen und kannst vorher prüfen, was übernommen wird.
</Faq>
<Faq question="Wer zahlt die 0,50 € pro Buchung?">
Deine Kundin, beim Checkout. Dein monatlicher Betrag bleibt bei
150 € — unabhängig davon, ob du 20 oder 2.000 Buchungen im Monat
hast. Dazu kommen nur die Transaktionsgebühren von Stripe bzw.
PayPal.
</Faq>
<Faq question="Ist Kumasy DSGVO-konform?">
Ja. Gehostet wird über Vercel in der Region Frankfurt, Zahlungen
laufen über Stripe, Rechnungen werden GoBD-konform erstellt.
[Auftragsverarbeitungsvertrag und Datenschutzerklärung hier
verlinken.]
</Faq>
<Faq question="Wann kann ich starten?">
Wir sind in der Pilotphase und geben nach und nach Zugänge frei.
Du bekommst eine E-Mail, sobald du dran bist.
</Faq>
{FAQ_ENTRIES.map(({ question, answer, link }) => (
<Faq key={question} question={question}>
{answer}
{link ? (
<>
{" "}
<Link
href={link.href}
className="font-medium text-ink underline decoration-amber decoration-2 underline-offset-4 transition-opacity duration-200 hover:opacity-60"
>
{link.label}
</Link>
</>
) : null}
</Faq>
))}
</div>
</Reveal>
</div>
@ -765,7 +769,7 @@ export function ClosingCta() {
</Reveal>
<Reveal delay={0.32}>
<div className="mx-auto mt-9 max-w-[520px] text-left">
<WaitlistForm compact />
<WaitlistForm compact source="Abschluss-CTA" />
</div>
</Reveal>
</div>
@ -778,6 +782,12 @@ export function ClosingCta() {
* Footer
* ------------------------------------------------------------------ */
const LEGAL_LINKS = [
{ href: "/impressum", label: "Impressum" },
{ href: "/datenschutz", label: "Datenschutz" },
{ href: "/agb", label: "AGB" },
];
export function Footer() {
return (
<footer className="border-t border-hairline px-4 py-10">
@ -787,25 +797,24 @@ export function Footer() {
<nav aria-label="Rechtliches">
<ul className="flex flex-wrap items-center gap-x-6 gap-y-2 text-body text-slate-helper">
<li>
<a href="mailto:info@kumasy.de" className="hover:text-ink">
info@kumasy.de
</a>
</li>
<li>
<a href="/impressum" className="hover:text-ink">
Impressum
</a>
</li>
<li>
<a href="/datenschutz" className="hover:text-ink">
Datenschutz
</a>
</li>
<li>
<a href="/agb" className="hover:text-ink">
AGB
<a
href={`mailto:${CONTACT_EMAIL}`}
translate="no"
className="transition-colors duration-200 hover:text-ink"
>
{CONTACT_EMAIL}
</a>
</li>
{LEGAL_LINKS.map(({ href, label }) => (
<li key={href}>
<Link
href={href}
className="transition-colors duration-200 hover:text-ink"
>
{label}
</Link>
</li>
))}
</ul>
</nav>
</div>

View file

@ -1,10 +1,21 @@
"use client";
import { useEffect, useId, useState, type ReactNode } from "react";
import {
useActionState,
useCallback,
useEffect,
useId,
useRef,
useState,
type ReactNode,
} from "react";
import Link from "next/link";
import { AnimatePresence, motion } from "motion/react";
import { Check, Menu, Plus, X } from "lucide-react";
import { Check, Lock, Menu, Plus, X } from "lucide-react";
import { Logo } from "./logo";
import { useReducedMotion } from "./motion";
import { joinWaitlist } from "../lib/waitlist";
import { initialWaitlistState } from "../lib/waitlist-state";
/* ------------------------------------------------------------------ *
* Eyebrow label pill
@ -139,6 +150,148 @@ export function GhostLink({
);
}
/* ------------------------------------------------------------------ *
* Login dialog — there is no app to log into yet
*
* The product is still in its pilot, so app.kumasy.de would either
* refuse the visitor or, worse, look broken. Intercepting the click and
* saying so keeps the one path that does exist — the waitlist — in
* front of them instead of sending them to a dead end.
* ------------------------------------------------------------------ */
export function LoginDialog({
open,
onClose,
}: {
open: boolean;
onClose: () => void;
}) {
const reduced = useReducedMotion();
const panelRef = useRef<HTMLDivElement>(null);
const titleId = useId();
const bodyId = `${titleId}-body`;
// Close on Escape, and keep Tab inside the panel while it is open —
// a dialog the keyboard can walk out of is not really modal.
useEffect(() => {
if (!open) return;
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onClose();
return;
}
if (event.key !== "Tab") return;
const panel = panelRef.current;
if (!panel) return;
const focusable = panel.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), input, [tabindex]:not([tabindex="-1"])',
);
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]);
// Move focus into the panel on open, and stop the page behind from
// scrolling under it.
useEffect(() => {
if (!open) return;
const previous = document.activeElement as HTMLElement | null;
const overflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
const frame = requestAnimationFrame(() => {
panelRef.current
?.querySelector<HTMLElement>("input, button, a[href]")
?.focus();
});
return () => {
cancelAnimationFrame(frame);
document.body.style.overflow = overflow;
previous?.focus();
};
}, [open]);
return (
<AnimatePresence>
{open ? (
<div className="fixed inset-0 z-[70] flex items-center justify-center px-4">
{/* Darkened, not blurred — the same call the app makes for
dialogs, so the panel's own text stays crisp. */}
<motion.div
className="absolute inset-0 bg-ink/45"
onClick={onClose}
initial={reduced ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
exit={reduced ? undefined : { opacity: 0 }}
transition={{ duration: 0.25 }}
/>
<motion.div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={bodyId}
className="glass-rim relative w-full max-w-[460px] rounded-cards border border-white/70 bg-paper p-7 shadow-elevated sm:p-8"
initial={reduced ? false : { opacity: 0, y: 16, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={reduced ? undefined : { opacity: 0, y: 8, scale: 0.98 }}
transition={{ duration: 0.34, ease: [0.22, 1, 0.36, 1] }}
>
<button
type="button"
onClick={onClose}
aria-label="Schließen"
className="absolute right-5 top-5 rounded-full p-1 text-slate-helper transition-colors duration-200 hover:bg-mist hover:text-ink"
>
<X className="size-4" strokeWidth={2} />
</button>
<span className="grid size-10 place-items-center rounded-full bg-amber-soft">
<Lock className="size-4 text-[#8a6d00]" strokeWidth={2.2} />
</span>
<h2
id={titleId}
className="mt-5 font-display text-heading-sm font-semibold text-ink"
>
Der Login kommt bald.
</h2>
<p id={bodyId} className="mt-3 text-body text-slate-body">
Kumasy ist noch in der Pilotphase — Zugänge vergeben wir nach
und nach über die Warteliste. Trag dich ein, und du bekommst
eine E-Mail, sobald dein Zugang bereit ist.
</p>
<div className="mt-6">
<WaitlistForm compact source="Login-Dialog" />
</div>
</motion.div>
</div>
) : null}
</AnimatePresence>
);
}
/* ------------------------------------------------------------------ *
* Floating glass nav
* ------------------------------------------------------------------ */
@ -153,8 +306,16 @@ const NAV_LINKS = [
export function Nav() {
const [scrolled, setScrolled] = useState(false);
const [open, setOpen] = useState(false);
const [loginOpen, setLoginOpen] = useState(false);
const reduced = useReducedMotion();
// Closes the mobile menu too, so the dialog never opens behind it.
const openLogin = useCallback(() => {
setOpen(false);
setLoginOpen(true);
}, []);
const closeLogin = useCallback(() => setLoginOpen(false), []);
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 24);
onScroll();
@ -172,6 +333,7 @@ export function Nav() {
}, [open]);
return (
<>
<header className="pointer-events-none fixed inset-x-0 top-4 z-50 flex justify-center px-4">
<motion.nav
aria-label="Hauptnavigation"
@ -201,9 +363,13 @@ export function Nav() {
</ul>
<div className="hidden items-center gap-3 md:flex">
<GhostLink href="https://app.kumasy.de" className="text-[14px]">
<button
type="button"
onClick={openLogin}
className="inline-flex items-center gap-1.5 text-[14px] font-medium text-ink transition-opacity duration-200 hover:opacity-60"
>
Login
</GhostLink>
</button>
<BrandButton href="#warteliste" className="!py-2 !text-[14px]">
Auf die Warteliste
</BrandButton>
@ -249,7 +415,13 @@ export function Nav() {
</li>
))}
<li className="mt-2 flex items-center justify-between gap-3 px-3 pb-2">
<GhostLink href="https://app.kumasy.de">Login</GhostLink>
<button
type="button"
onClick={openLogin}
className="inline-flex items-center gap-1.5 text-body font-medium text-ink transition-opacity duration-200 hover:opacity-60"
>
Login
</button>
<BrandButton href="#warteliste" className="!py-2 !text-[14px]">
Auf die Warteliste
</BrandButton>
@ -260,6 +432,12 @@ export function Nav() {
</AnimatePresence>
</motion.nav>
</header>
{/* Outside the header on purpose: that element is pointer-events-none
and a centred flex row, both of which a fixed dialog must not
inherit. */}
<LoginDialog open={loginOpen} onClose={closeLogin} />
</>
);
}
@ -267,21 +445,24 @@ export function Nav() {
* Waitlist form
* ------------------------------------------------------------------ */
export function WaitlistForm({ compact = false }: { compact?: boolean }) {
const [email, setEmail] = useState("");
const [state, setState] = useState<"idle" | "sending" | "done">("idle");
export function WaitlistForm({
compact = false,
/** Names the form in the notification email, so you can tell them apart. */
source = "Startseite",
}: {
compact?: boolean;
source?: string;
}) {
const [state, formAction, pending] = useActionState(
joinWaitlist,
initialWaitlistState,
);
const fieldId = useId();
const statusId = `${fieldId}-status`;
const errorId = `${fieldId}-error`;
const consentId = `${fieldId}-consent`;
// No backend is wired up yet — this validates and acknowledges locally.
const onSubmit = (event: React.FormEvent) => {
event.preventDefault();
if (state !== "idle") return;
setState("sending");
window.setTimeout(() => setState("done"), 700);
};
if (state === "done") {
if (state.status === "success") {
return (
<motion.div
className="glass glass-rim flex items-center gap-3 rounded-inputs px-5 py-4"
@ -300,16 +481,32 @@ export function WaitlistForm({ compact = false }: { compact?: boolean }) {
);
}
const failed = state.status === "error";
return (
<form onSubmit={onSubmit} className="w-full">
<form action={formAction} className="w-full">
<label htmlFor={fieldId} className="sr-only">
E-Mail-Adresse
</label>
{/* Bait for bots: off-screen, skipped by keyboard and screen readers. */}
<div aria-hidden className="absolute left-[-9999px] top-auto h-px w-px overflow-hidden">
<label htmlFor={`${fieldId}-website`}>Website</label>
<input
id={`${fieldId}-website`}
type="text"
name="website"
tabIndex={-1}
autoComplete="off"
/>
</div>
<input type="hidden" name="source" value={source} />
<div
className={`glass glass-rim flex w-full items-center gap-2 rounded-full p-2 ${
className={`glass glass-rim flex w-full items-center gap-2 rounded-full p-2 transition-shadow duration-200 focus-within:ring-2 focus-within:ring-ink/70 focus-within:ring-offset-2 focus-within:ring-offset-canvas ${
compact ? "" : "sm:p-2.5"
}`}
} ${failed ? "ring-2 ring-peach-ink/50" : ""}`}
>
<input
id={fieldId}
@ -318,18 +515,57 @@ export function WaitlistForm({ compact = false }: { compact?: boolean }) {
required
autoComplete="email"
inputMode="email"
placeholder="E-Mail-Adresse"
aria-describedby={statusId}
value={email}
onChange={(event) => setEmail(event.target.value)}
spellCheck={false}
maxLength={254}
placeholder="name@studio.de"
defaultValue={state.email ?? ""}
aria-describedby={failed ? `${errorId} ${statusId}` : statusId}
aria-invalid={failed || undefined}
className="min-w-0 flex-1 bg-transparent px-4 py-2.5 text-body text-ink outline-none placeholder:text-slate-quiet"
/>
<BrandButton type="submit" disabled={state === "sending"}>
{state === "sending" ? "Moment…" : "Platz sichern"}
<BrandButton type="submit" disabled={pending}>
<span className="min-w-[6.5rem] text-center">
{pending ? "Moment…" : "Platz sichern"}
</span>
</BrandButton>
</div>
<p id={statusId} className="mt-3 text-[14px] text-slate-helper">
{/* text-left: these forms sit inside centred sections, and centred
multi-line consent copy is noticeably harder to read. */}
<div className="mt-3.5 flex items-start gap-2.5 text-left">
<input
id={consentId}
type="checkbox"
name="consent"
value="ja"
required
className="mt-0.5 size-4 shrink-0 accent-amber-deep"
/>
<label htmlFor={consentId} className="text-[14px] text-slate-helper">
Ja, ihr dürft mich per E-Mail über den Start von Kumasy
informieren. Ich kann das jederzeit formlos widerrufen — mehr dazu
in der{" "}
<Link
href="/datenschutz"
className="font-medium text-ink underline decoration-amber decoration-2 underline-offset-2"
>
Datenschutzerklärung
</Link>
.
</label>
</div>
{failed ? (
<p
id={errorId}
role="alert"
className="mt-3 text-left text-[14px] font-medium text-[#9a2c06]"
>
{state.message}
</p>
) : null}
<p id={statusId} className="mt-2.5 text-[14px] text-slate-helper">
Du bekommst eine E-Mail, sobald dein Zugang bereit ist. Keine
Verpflichtung, keine Zahlungsdaten.
</p>

123
app/datenschutz/page.tsx Normal file
View file

@ -0,0 +1,123 @@
import type { Metadata } from "next";
import { LegalPage, Todo } from "../components/legal";
import { CONTACT_EMAIL } from "../lib/site";
export const metadata: Metadata = {
title: "Datenschutzerklärung",
description:
"Informationen zur Verarbeitung personenbezogener Daten bei Kumasy nach Art. 13 DSGVO.",
alternates: { canonical: "/datenschutz" },
robots: { index: false, follow: true },
};
export default function DatenschutzPage() {
return (
<LegalPage
title="Datenschutzerklärung"
intro="Informationen zur Verarbeitung personenbezogener Daten nach Art. 13 und 14 DSGVO."
>
<section>
<h2>Verantwortlicher</h2>
<p>
<Todo>
Name und Anschrift des Verantwortlichen eintragen — identisch mit
den Angaben im Impressum.
</Todo>
</p>
<p>
E-Mail: <a href={`mailto:${CONTACT_EMAIL}`}>{CONTACT_EMAIL}</a>
</p>
</section>
<section>
<h2>Datenschutzbeauftragter</h2>
<p>
<Todo>
Kontaktdaten des Datenschutzbeauftragten ergänzen, sofern nach
Art. 37 DSGVO einer benannt werden muss.
</Todo>
</p>
</section>
<section>
<h2>Hosting</h2>
<p>
Diese Website wird bei Vercel Inc. in der Region Frankfurt
(eu-central-1) gehostet. Beim Aufruf der Seite verarbeitet der
Hoster technisch notwendige Zugriffsdaten wie IP-Adresse, Zeitpunkt
des Zugriffs, aufgerufene Seite und User-Agent.
</p>
<p>
<Todo>
Rechtsgrundlage, Speicherdauer und den Stand des
Auftragsverarbeitungsvertrags mit Vercel ergänzen.
</Todo>
</p>
</section>
<section>
<h2>Schriftarten</h2>
<p>
Die verwendeten Schriftarten werden mit dem Build der Seite
ausgeliefert und lokal vom eigenen Server geladen. Es entsteht
dadurch keine Verbindung zu Servern Dritter.
</p>
</section>
<section>
<h2>Warteliste</h2>
<p>
<Todo>
Sobald das Formular an einen Dienst angebunden ist: Empfänger der
E-Mail-Adresse, Zweck, Rechtsgrundlage (in der Regel Art. 6 Abs. 1
lit. a oder b DSGVO), Speicherdauer und Widerrufsmöglichkeit
beschreiben.
</Todo>
</p>
</section>
<section>
<h2>Zahlungsabwicklung</h2>
<p>
<Todo>
Verarbeitung durch Stripe beschreiben, sobald Zahlungen über diese
Website laufen — inklusive Drittlandtransfer in die USA und der
dafür herangezogenen Garantien.
</Todo>
</p>
</section>
<section>
<h2>Cookies und Analyse</h2>
<p>
<Todo>
Aktuell setzt die Seite nach unserer Kenntnis keine Cookies und
bindet keine Analysedienste ein. Diesen Abschnitt anpassen, sobald
sich das ändert — dann ist zusätzlich ein Consent-Banner nötig.
</Todo>
</p>
</section>
<section>
<h2>Deine Rechte</h2>
<p>
Dir stehen gegenüber dem Verantwortlichen die folgenden Rechte
hinsichtlich deiner personenbezogenen Daten zu:
</p>
<ul>
<li>Auskunft nach Art. 15 DSGVO</li>
<li>Berichtigung nach Art. 16 DSGVO</li>
<li>Löschung nach Art. 17 DSGVO</li>
<li>Einschränkung der Verarbeitung nach Art. 18 DSGVO</li>
<li>Datenübertragbarkeit nach Art. 20 DSGVO</li>
<li>Widerspruch nach Art. 21 DSGVO</li>
</ul>
<p>
Außerdem steht dir ein Beschwerderecht bei einer
Datenschutz-Aufsichtsbehörde zu.{" "}
<Todo>Zuständige Aufsichtsbehörde nach Bundesland ergänzen.</Todo>
</p>
</section>
</LegalPage>
);
}

View file

@ -130,6 +130,16 @@ body {
border-radius: 6px;
}
/* Text fields opt out. They sit inside a rounded glass pill and are
themselves transparent, so the shared ring draws a second, smaller
box with its own corners inside the surface the user sees as the
field. The caret and the filled value already say where focus is. */
input:focus-visible,
textarea:focus-visible {
outline: none;
border-radius: inherit;
}
/* ── Glass system ─────────────────────────────────────────
The four ingredients from DESIGN-SYSTEM.md, bundled once:
translucent fill, backdrop blur, light edge, soft shadow.

16
app/icon.svg Normal file
View file

@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="Kumasy">
<!-- Geometry mirrors LogoMark in app/components/logo.tsx. Kept in sync by
hand: this file is served to browsers directly and cannot import. -->
<defs>
<linearGradient id="amber" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#ffd84d" />
<stop offset="45%" stop-color="#f4c00c" />
<stop offset="100%" stop-color="#e09b00" />
</linearGradient>
</defs>
<rect width="512" height="512" rx="114" fill="url(#amber)" />
<g fill="#1e293b">
<rect x="153" y="118" width="71" height="277" rx="35.5" />
<path d="M234.6 299.6 317.4 220.5 A20 20 0 0 1 352 234.4 L352 379.6 A20 20 0 0 1 317.4 393.5 L234.6 314.4 A10 10 0 0 1 234.6 299.6 Z" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 799 B

92
app/impressum/page.tsx Normal file
View file

@ -0,0 +1,92 @@
import type { Metadata } from "next";
import { LegalPage, Todo } from "../components/legal";
import { CONTACT_EMAIL } from "../lib/site";
export const metadata: Metadata = {
title: "Impressum",
description: "Anbieterkennzeichnung nach § 5 DDG für Kumasy.",
alternates: { canonical: "/impressum" },
// Legal boilerplate carries no search value and competes with the
// landing page for crawl budget, but it must stay reachable.
robots: { index: false, follow: true },
};
export default function ImpressumPage() {
return (
<LegalPage
title="Impressum"
intro="Angaben gemäß § 5 Digitale-Dienste-Gesetz (DDG)."
>
<section>
<h2>Anbieter</h2>
<p>
<Todo>
Vollständiger Firmenname inkl. Rechtsform, Straße und Hausnummer,
PLZ und Ort hier eintragen.
</Todo>
</p>
</section>
<section>
<h2>Vertreten durch</h2>
<p>
<Todo>
Name der vertretungsberechtigten Person(en) — bei einer GmbH die
Geschäftsführung, bei einem Einzelunternehmen dein Name.
</Todo>
</p>
</section>
<section>
<h2>Kontakt</h2>
<p>
E-Mail: <a href={`mailto:${CONTACT_EMAIL}`}>{CONTACT_EMAIL}</a>
</p>
<p>
<Todo>
Telefonnummer ergänzen — eine unmittelbare Kontaktmöglichkeit ist
nach § 5 DDG verpflichtend.
</Todo>
</p>
</section>
<section>
<h2>Registereintrag</h2>
<p>
<Todo>
Registergericht und Handelsregisternummer eintragen, sofern
vorhanden. Entfällt bei nicht eingetragenen Einzelunternehmen.
</Todo>
</p>
</section>
<section>
<h2>Umsatzsteuer-Identifikationsnummer</h2>
<p>
<Todo>
USt-IdNr. nach § 27 a Umsatzsteuergesetz eintragen, sofern
vorhanden.
</Todo>
</p>
</section>
<section>
<h2>Redaktionell verantwortlich</h2>
<p>
<Todo>Name und vollständige Anschrift der verantwortlichen Person.</Todo>
</p>
</section>
<section>
<h2>Verbraucherstreitbeilegung</h2>
<p>
<Todo>
Hinweis zur Teilnahme oder Nichtteilnahme an einem
Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle
ergänzen (§ 36 VSBG).
</Todo>
</p>
</section>
</LegalPage>
);
}

View file

@ -1,6 +1,8 @@
import type { Metadata, Viewport } from "next";
import { Manrope, Plus_Jakarta_Sans } from "next/font/google";
import "./globals.css";
import { SITE_NAME, SITE_URL } from "./lib/site";
import { buildStructuredData } from "./lib/structured-data";
// DESIGN.md names Switzer / Basier Circle; neither ships on Google Fonts.
// These are the documented substitutes — Manrope for UI, Plus Jakarta Sans for
@ -20,51 +22,60 @@ const display = Plus_Jakarta_Sans({
display: "swap",
});
const SITE_URL = "https://kumasy.de";
export const metadata: Metadata = {
metadataBase: new URL(SITE_URL),
title: {
default: "Kumasy — Deine Kurse füllen und bezahlen sich von selbst.",
// Under 60 characters so Google does not truncate it in the SERP,
// with the primary keyword ahead of the brand name.
default: "Kursverwaltung & Buchungssystem für Kursbetriebe · Kumasy",
template: "%s · Kumasy",
},
description:
"Kumasy bündelt Kurse, Räume, Trainer, Buchungen und Zahlungen in einer Oberfläche. Deine Teilnehmer:innen buchen und bezahlen in unter zwei Minuten selbst — ohne DM, ohne Rückfrage, ohne Excel.",
keywords: [
"Kursverwaltung",
"Buchungssystem",
"Fitnessstudio Software",
"Yoga Studio Verwaltung",
"Kursbuchung",
"Stripe Zahlungen",
"GoBD",
"DSGVO",
],
"Kumasy bündelt Kurse, Räume, Trainer, Buchungen und Zahlungen in einer Oberfläche. Deine Teilnehmer:innen buchen und bezahlen selbst — mit Stripe, Warteliste und GoBD-konformen Rechnungen.",
applicationName: SITE_NAME,
category: "Business Software",
authors: [{ name: SITE_NAME, url: SITE_URL }],
creator: SITE_NAME,
publisher: SITE_NAME,
alternates: { canonical: "/" },
openGraph: {
type: "website",
locale: "de_DE",
url: SITE_URL,
siteName: "Kumasy",
siteName: SITE_NAME,
title: "Deine Kurse füllen und bezahlen sich von selbst.",
description:
"Kurse, Räume, Trainer, Buchungen und Zahlungen in einer Oberfläche. Jetzt auf die Warteliste.",
},
twitter: {
card: "summary_large_image",
title: "Kumasy — Deine Kurse füllen und bezahlen sich von selbst.",
title: "Kumasy — Kursverwaltung und Buchungssystem",
description:
"Kurse, Räume, Trainer, Buchungen und Zahlungen in einer Oberfläche.",
},
icons: {
icon: "/favicon.ico",
apple: "/icons/apple-touch-icon.png",
// No `icons` key: app/favicon.ico, app/icon.svg and app/apple-icon.png
// are picked up by the file convention. Declaring them here as well
// emitted a duplicate <link rel="icon">.
manifest: "/manifest.webmanifest",
formatDetection: { telephone: false, address: false, email: false },
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
// Let Google use full-length snippets, large image previews and
// full video previews rather than its conservative defaults.
"max-snippet": -1,
"max-image-preview": "large",
"max-video-preview": -1,
},
},
robots: { index: true, follow: true },
};
export const viewport: Viewport = {
themeColor: "#ffffff",
// Matches --color-canvas (#f9fafb), the actual body background.
themeColor: "#f9fafb",
colorScheme: "light",
};
@ -74,6 +85,18 @@ export default function RootLayout({ children }: LayoutProps<"/">) {
lang="de"
className={`${switzer.variable} ${display.variable} h-full antialiased`}
>
<head>
{/* Structured data. Rendered on the server into the initial HTML,
so crawlers see it without executing any JavaScript. */}
<script
type="application/ld+json"
// The payload is built from our own constants, never from user
// input, so there is nothing here to escape.
dangerouslySetInnerHTML={{
__html: JSON.stringify(buildStructuredData()),
}}
/>
</head>
<body className="min-h-full flex flex-col bg-canvas text-ink">
<a
href="#hauptinhalt"

45
app/lib/faq.ts Normal file
View file

@ -0,0 +1,45 @@
/**
* The FAQ lives here rather than in JSX because it feeds two consumers:
* the rendered accordion and the FAQPage structured data. Google requires
* the schema to match the visible text exactly, so they must not be able
* to drift apart.
*/
export type FaqEntry = {
question: string;
answer: string;
/**
* Optional internal link rendered after the answer. Kept out of the
* schema text on purpose: Google wants the answer prose, and a bare
* "Mehr dazu" adds nothing there.
*/
link?: { href: string; label: string };
};
export const FAQ_ENTRIES: FaqEntry[] = [
{
question: "Brauchen meine Kundinnen einen Account?",
answer:
"Nein. Gastbuchung ohne Registrierung ist Standard — genau da springen sonst die meisten ab. Wer will, legt sich trotzdem ein Konto an und sieht seine Buchungshistorie.",
},
{
question: "Was passiert mit meinen bestehenden Daten?",
answer:
"Unser Migrationstool pflegt Kurse, Kundinnen und Termine aus deinem alten System automatisch in Kumasy ein. Du musst nichts abtippen und kannst vorher prüfen, was übernommen wird.",
},
{
question: "Wer zahlt die 0,50 € pro Buchung?",
answer:
"Deine Kundin, beim Checkout. Dein monatlicher Betrag bleibt bei 150 € — unabhängig davon, ob du 20 oder 2.000 Buchungen im Monat hast. Dazu kommen nur die Transaktionsgebühren von Stripe bzw. PayPal.",
},
{
question: "Ist Kumasy DSGVO-konform?",
answer:
"Ja. Gehostet wird über Vercel in der Region Frankfurt, Zahlungen laufen über Stripe, Rechnungen werden GoBD-konform erstellt. Deine Daten bleiben in der EU.",
link: { href: "/datenschutz", label: "Zur Datenschutzerklärung" },
},
{
question: "Wann kann ich starten?",
answer:
"Wir sind in der Pilotphase und geben nach und nach Zugänge frei. Du bekommst eine E-Mail, sobald du dran bist.",
},
];

32
app/lib/site.ts Normal file
View file

@ -0,0 +1,32 @@
/**
* One source of truth for the canonical origin. robots.ts, sitemap.ts,
* layout.tsx and the structured data all read from here — a mismatch
* between any two of them is the kind of SEO bug that only surfaces
* weeks later in Search Console.
*
* Vercel sets VERCEL_PROJECT_PRODUCTION_URL to the production domain on
* every deployment, including previews, so preview builds emit canonicals
* pointing at production rather than at their own throwaway URL.
*/
const PRODUCTION_URL =
process.env.NEXT_PUBLIC_SITE_URL ??
(process.env.VERCEL_PROJECT_PRODUCTION_URL
? `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`
: "https://kumasy.de");
/** Canonical origin, never with a trailing slash. */
export const SITE_URL = PRODUCTION_URL.replace(/\/+$/, "");
export const SITE_NAME = "Kumasy";
/** Contact address, used in both the footer and the structured data. */
export const CONTACT_EMAIL = "info@kumasy.de";
/**
* The monthly price, in one place because it appears in the pricing
* section, the FAQ copy and the Product structured data. If it changes
* and the schema still says 150, Google flags the mismatch.
*/
export const PRICE_MONTHLY = 150;
export const PRICE_PER_BOOKING = 0.5;
export const PRICE_CURRENCY = "EUR";

104
app/lib/structured-data.ts Normal file
View file

@ -0,0 +1,104 @@
import { FAQ_ENTRIES } from "./faq";
import {
CONTACT_EMAIL,
PRICE_CURRENCY,
PRICE_MONTHLY,
SITE_NAME,
SITE_URL,
} from "./site";
/**
* Structured data for the landing page, emitted as a single @graph so the
* entities can reference each other by @id instead of repeating themselves.
*
* Deliberately omitted:
* - AggregateRating / Review — we have no reviews. Inventing them is a
* manual-action risk, not a ranking shortcut.
* - Organization address / telephone — not published anywhere on the site
* yet. Schema must describe what the page actually shows.
*/
export function buildStructuredData() {
const organizationId = `${SITE_URL}/#organization`;
const websiteId = `${SITE_URL}/#website`;
return {
"@context": "https://schema.org",
"@graph": [
{
"@type": "Organization",
"@id": organizationId,
name: SITE_NAME,
url: SITE_URL,
logo: {
"@type": "ImageObject",
url: `${SITE_URL}/icons/icon-512.png`,
width: 512,
height: 512,
},
email: CONTACT_EMAIL,
contactPoint: {
"@type": "ContactPoint",
email: CONTACT_EMAIL,
contactType: "customer support",
availableLanguage: ["de"],
},
},
{
"@type": "WebSite",
"@id": websiteId,
url: SITE_URL,
name: SITE_NAME,
inLanguage: "de-DE",
publisher: { "@id": organizationId },
},
{
"@type": "SoftwareApplication",
"@id": `${SITE_URL}/#software`,
name: SITE_NAME,
applicationCategory: "BusinessApplication",
applicationSubCategory: "Kursverwaltung und Buchungssystem",
operatingSystem: "Web",
url: SITE_URL,
inLanguage: "de-DE",
publisher: { "@id": organizationId },
description:
"Kumasy bündelt Kurse, Räume, Trainer, Buchungen und Zahlungen für Kursbetriebe in einer Oberfläche. Teilnehmer:innen buchen und bezahlen selbst — mit öffentlicher Buchungsseite, Warteliste und Stripe-Zahlungen.",
featureList: [
"Kurse und Serientermine verwalten",
"Öffentliche Buchungsseite mit Gastbuchung",
"Zahlungen über Stripe",
"Warteliste mit automatischem Nachrücken",
"Räume- und Trainerverwaltung",
"Rabattcodes und Stornoregeln",
"GoBD-konforme Rechnungen",
],
offers: {
"@type": "Offer",
price: PRICE_MONTHLY,
priceCurrency: PRICE_CURRENCY,
// Schema.org has no "per month" on Offer itself; the
// UnitPriceSpecification carries the billing period.
priceSpecification: {
"@type": "UnitPriceSpecification",
price: PRICE_MONTHLY,
priceCurrency: PRICE_CURRENCY,
unitCode: "MON",
billingIncrement: 1,
},
availability: "https://schema.org/PreOrder",
url: `${SITE_URL}/#preis`,
},
},
{
"@type": "FAQPage",
"@id": `${SITE_URL}/#faq`,
inLanguage: "de-DE",
mainEntity: FAQ_ENTRIES.map(({ question, answer }) => ({
"@type": "Question",
name: question,
acceptedAnswer: { "@type": "Answer", text: answer },
})),
},
],
};
}

15
app/lib/waitlist-state.ts Normal file
View file

@ -0,0 +1,15 @@
/**
* Type and initial value for the waitlist form state.
*
* Separate from waitlist.ts on purpose: a "use server" module may only
* export async functions, so the constant cannot live next to the action
* it belongs to.
*/
export type WaitlistState = {
status: "idle" | "success" | "error";
message?: string;
/** Echoed back so a rejected submission does not clear the field. */
email?: string;
};
export const initialWaitlistState: WaitlistState = { status: "idle" };

181
app/lib/waitlist.ts Normal file
View file

@ -0,0 +1,181 @@
"use server";
import { CONTACT_EMAIL, SITE_NAME } from "./site";
import type { WaitlistState } from "./waitlist-state";
/**
* Waitlist signup.
*
* The address is delivered as a notification email and nowhere else, so
* the inbox is the only copy of the list. `deliver()` is deliberately the
* single place that knows this — adding a database later means adding one
* call there, not touching the action or the form.
*/
/**
* Deliberately stricter than the browser's type=email (which accepts
* "a@b"), but not a full RFC 5322 parser — those reject addresses that
* work in practice. Requires a dotted domain and no whitespace.
*/
const EMAIL = /^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$/;
/** Crude in-process rate limit: same IP, at most 5 signups per hour. */
const RECENT = new Map<string, number[]>();
const WINDOW_MS = 60 * 60 * 1000;
const MAX_PER_WINDOW = 5;
function rateLimited(key: string): boolean {
const now = Date.now();
const hits = (RECENT.get(key) ?? []).filter((t) => now - t < WINDOW_MS);
hits.push(now);
RECENT.set(key, hits);
// Serverless instances are short-lived, so this map cannot grow without
// bound in practice — but trim it anyway for long-running instances.
if (RECENT.size > 5000) {
for (const [k, v] of RECENT) {
if (v.every((t) => now - t >= WINDOW_MS)) RECENT.delete(k);
}
}
return hits.length > MAX_PER_WINDOW;
}
async function deliver(email: string, meta: { at: string; source: string }) {
const host = process.env.SMTP_HOST;
const user = process.env.SMTP_USER;
const pass = process.env.SMTP_PASSWORD;
const to = process.env.WAITLIST_NOTIFY_TO ?? CONTACT_EMAIL;
const from = process.env.WAITLIST_FROM ?? user;
if (!host || !user || !pass) {
// Not configured yet. Log instead of throwing, so missing credentials
// in a preview deployment do not show the visitor an error — but make
// it loud enough to notice in the runtime logs.
console.warn(
"[waitlist] SMTP_HOST/SMTP_USER/SMTP_PASSWORD missing — signup not delivered:",
email,
);
return;
}
// Port 465 speaks TLS from the first byte; 587 starts plain and upgrades
// via STARTTLS. Getting `secure` wrong for the port is the single most
// common cause of a hanging connection.
const port = Number(process.env.SMTP_PORT ?? 587);
const secure = process.env.SMTP_SECURE
? process.env.SMTP_SECURE === "true"
: port === 465;
// Imported lazily so nodemailer stays out of any bundle that merely
// imports this module's types.
const nodemailer = (await import("nodemailer")).default;
const transporter = nodemailer.createTransport({
host,
port,
secure,
auth: { user, pass },
// Serverless functions are killed once the response is sent, so a
// hanging socket must fail fast rather than stall the request.
connectionTimeout: 10_000,
greetingTimeout: 10_000,
socketTimeout: 15_000,
});
try {
await transporter.sendMail({
from,
to,
// So a reply goes straight to the person who signed up.
replyTo: email,
subject: `Warteliste: ${email}`,
text: [
`Neue Anmeldung für die ${SITE_NAME}-Warteliste.`,
"",
`E-Mail: ${email}`,
`Zeitpunkt: ${meta.at}`,
`Formular: ${meta.source}`,
"",
"Die Person hat der Kontaktaufnahme per E-Mail zugestimmt",
"(Checkbox im Formular, Single Opt-in).",
].join("\n"),
});
} finally {
// Without this the pooled connection can keep the function alive
// until the platform times it out.
transporter.close();
}
}
export async function joinWaitlist(
_previous: WaitlistState,
formData: FormData,
): Promise<WaitlistState> {
const email = String(formData.get("email") ?? "").trim();
const consent = formData.get("consent");
const source = String(formData.get("source") ?? "unbekannt");
// Bots fill every field they find; humans never see this one.
const honeypot = String(formData.get("website") ?? "");
if (honeypot) {
// Claim success rather than explain the trap.
return { status: "success" };
}
if (!email) {
return {
status: "error",
message: "Bitte trag deine E-Mail-Adresse ein.",
email,
};
}
if (email.length > 254 || !EMAIL.test(email)) {
return {
status: "error",
message: "Diese E-Mail-Adresse sieht nicht gültig aus.",
email,
};
}
if (!consent) {
return {
status: "error",
message: "Bitte bestätige, dass wir dich per E-Mail informieren dürfen.",
email,
};
}
const { headers } = await import("next/headers");
const headerList = await headers();
const ip =
headerList.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unbekannt";
if (rateLimited(ip)) {
return {
status: "error",
message: "Zu viele Versuche. Bitte probier es später noch einmal.",
email,
};
}
try {
await deliver(email, {
at: new Date().toLocaleString("de-DE", { timeZone: "Europe/Berlin" }),
source,
});
} catch (error) {
// The address is lost if this throws, so make it findable in the logs.
console.error("[waitlist] Zustellung fehlgeschlagen für", email, error);
return {
status: "error",
message:
"Das hat gerade nicht geklappt. Schreib uns kurz an " +
`${CONTACT_EMAIL} — dann tragen wir dich von Hand ein.`,
email,
};
}
return { status: "success" };
}

34
app/manifest.ts Normal file
View file

@ -0,0 +1,34 @@
import type { MetadataRoute } from "next";
import { SITE_NAME } from "./lib/site";
export default function manifest(): MetadataRoute.Manifest {
return {
name: "Kumasy — Kursverwaltung und Buchungssystem",
short_name: SITE_NAME,
description:
"Kurse, Räume, Trainer, Buchungen und Zahlungen für Kursbetriebe in einer Oberfläche.",
start_url: "/",
display: "standalone",
background_color: "#f9fafb",
theme_color: "#f9fafb",
lang: "de",
dir: "ltr",
categories: ["business", "productivity"],
icons: [
{ src: "/icons/icon-192.png", sizes: "192x192", type: "image/png" },
{ src: "/icons/icon-512.png", sizes: "512x512", type: "image/png" },
{
src: "/icons/icon-maskable-192.png",
sizes: "192x192",
type: "image/png",
purpose: "maskable",
},
{
src: "/icons/icon-maskable-512.png",
sizes: "512x512",
type: "image/png",
purpose: "maskable",
},
],
};
}

78
app/not-found.tsx Normal file
View file

@ -0,0 +1,78 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Footer } from "./components/sections";
import { Eyebrow, Nav, WaitlistForm } from "./components/ui";
export const metadata: Metadata = {
title: "Seite nicht gefunden",
description:
"Diese Seite gibt es nicht — oder noch nicht. Zurück zur Startseite oder auf die Warteliste.",
robots: { index: false, follow: true },
};
/**
* Catches both notFound() and any URL the app does not route. The root
* layout renders only {children}, so the chrome has to come from here —
* without it the 404 would be the one page on the site with no way out
* of it.
*/
export default function NotFound() {
return (
<>
<Nav />
<main
id="hauptinhalt"
className="relative isolate flex flex-1 items-center overflow-hidden px-4 pb-24 pt-36 sm:pt-44"
>
{/* The hero's backdrop, quieted down. Same room, lights lower. */}
<div
aria-hidden
className="pointer-events-none absolute inset-0 -z-10 overflow-hidden"
>
<div className="aurora absolute inset-0 opacity-60" />
<div
className="blob absolute left-1/2 top-[-30%] size-[720px] -translate-x-1/2 rounded-full opacity-40"
style={{
background:
"radial-gradient(circle, rgba(244,192,12,0.45) 0%, transparent 70%)",
filter: "blur(90px)",
}}
/>
</div>
<div className="mx-auto w-full max-w-[640px] text-center">
<Eyebrow>Fehler 404</Eyebrow>
<h1 className="mt-7 font-display text-[clamp(2.5rem,7vw,4rem)] font-semibold leading-[1] tracking-[-0.05em] text-ink">
Diese Seite gibt es{" "}
<span className="brand-text italic">nicht.</span>
</h1>
<p className="mx-auto mt-6 max-w-[460px] text-body-lg text-slate-body">
Vielleicht ein alter Link, vielleicht ein Tippfehler — vielleicht
auch etwas, das wir noch bauen. Kumasy ist in der Pilotphase, es
kommt also laufend etwas dazu.
</p>
<div className="mx-auto mt-9 max-w-[520px]">
<WaitlistForm source="404-Seite" />
</div>
<p className="mt-8 text-body text-slate-helper">
Oder{" "}
<Link
href="/"
className="font-medium text-ink underline decoration-amber decoration-2 underline-offset-4 transition-opacity duration-200 hover:opacity-60"
>
zurück zur Startseite
</Link>
.
</p>
</div>
</main>
<Footer />
</>
);
}

19
app/robots.ts Normal file
View file

@ -0,0 +1,19 @@
import type { MetadataRoute } from "next";
import { SITE_URL } from "./lib/site";
/**
* Nothing on this site is private, so everything is crawlable. The one
* exclusion is Next's build output under /_next/, which contains chunk
* files that carry no content worth indexing.
*/
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: "*",
allow: "/",
disallow: "/_next/",
},
sitemap: `${SITE_URL}/sitemap.xml`,
host: SITE_URL,
};
}

21
app/sitemap.ts Normal file
View file

@ -0,0 +1,21 @@
import type { MetadataRoute } from "next";
import { SITE_URL } from "./lib/site";
/**
* Only routes that actually exist belong here — listing a URL that 404s
* is worse than omitting it. The legal pages are linked from the footer
* but not yet built; add them here the moment they ship.
*/
export default function sitemap(): MetadataRoute.Sitemap {
const lastModified = new Date();
return [
{
url: SITE_URL,
lastModified,
changeFrequency: "weekly",
priority: 1,
images: [`${SITE_URL}/dashboard.png`],
},
];
}

View file

@ -14,4 +14,8 @@ const nextConfig: NextConfig = {
},
};
module.exports = {
allowedDevOrigins: ['192.168.2.34'],
}
export default nextConfig;

21
package-lock.json generated
View file

@ -12,12 +12,14 @@
"lucide-react": "^1.47.0",
"motion": "^13.4.0",
"next": "16.3.5",
"nodemailer": "^10.0.10",
"react": "19.2.8",
"react-dom": "19.2.8"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/nodemailer": "^8.0.2",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
@ -1646,6 +1648,16 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/nodemailer": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.2.tgz",
"integrity": "sha512-c7M5ox8p0nEOfbJ2E9Qcmt8/QCu/VzsiAhzBiZbvky2PhKZDHpKB3sQHBM5MEZNkCfBOaS6S9//AGPRTNB7IaA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/react": {
"version": "19.3.0",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
@ -5385,6 +5397,15 @@
"node": ">=18"
}
},
"node_modules/nodemailer": {
"version": "10.0.10",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.0.10.tgz",
"integrity": "sha512-He9XskOFms62SyAKkLu8CcGcYHAo+BSHSsORI8s4PJH8qZgZY4L4lDfvyN0twvmbpyG+eGrxpyBlskj9d9rJ8A==",
"license": "MIT-0",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",

View file

@ -13,12 +13,14 @@
"lucide-react": "^1.47.0",
"motion": "^13.4.0",
"next": "16.3.5",
"nodemailer": "^10.0.10",
"react": "19.2.8",
"react-dom": "19.2.8"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/nodemailer": "^8.0.2",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",