mirror of
https://github.com/Since-3/Kumasy-Marketing.git
synced 2026-10-12 20:51:18 +00:00
fix website, basic seo, waitlist functionality
This commit is contained in:
parent
eb5aca3a87
commit
a369c20f22
25 changed files with 1305 additions and 115 deletions
37
app/agb/page.tsx
Normal file
37
app/agb/page.tsx
Normal 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
BIN
app/apple-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
|
|
@ -137,7 +137,11 @@ export function Hero() {
|
||||||
|
|
||||||
{/* Per-character entrance. The <h1> carries the readable text for
|
{/* Per-character entrance. The <h1> carries the readable text for
|
||||||
assistive tech; the animated spans are aria-hidden. */}
|
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">
|
<span className="sr-only">
|
||||||
Deine Kurse füllen und bezahlen sich von selbst.
|
Deine Kurse füllen und bezahlen sich von selbst.
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -171,7 +175,7 @@ export function Hero() {
|
||||||
|
|
||||||
<Reveal delay={0.65}>
|
<Reveal delay={0.65}>
|
||||||
<div className="mx-auto mt-9 max-w-[560px]" id="warteliste">
|
<div className="mx-auto mt-9 max-w-[560px]" id="warteliste">
|
||||||
<WaitlistForm />
|
<WaitlistForm source="Hero" />
|
||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
|
|
|
||||||
60
app/components/legal.tsx
Normal file
60
app/components/legal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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({
|
function GlassStat({
|
||||||
|
|
@ -65,7 +73,7 @@ function GlassStat({
|
||||||
tone: "mint" | "amber";
|
tone: "mint" | "amber";
|
||||||
}) {
|
}) {
|
||||||
return (
|
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">
|
<div className="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
className={`grid size-7 place-items-center rounded-full ${
|
className={`grid size-7 place-items-center rounded-full ${
|
||||||
|
|
|
||||||
|
|
@ -149,6 +149,14 @@ export function MaskedWords({
|
||||||
* KineticHeadline — per-character entrance for the hero only
|
* 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 = {
|
const charVariants: Variants = {
|
||||||
hidden: { opacity: 0, y: "70%", rotateX: -70 },
|
hidden: { opacity: 0, y: "70%", rotateX: -70 },
|
||||||
shown: {
|
shown: {
|
||||||
|
|
@ -222,15 +230,23 @@ export function KineticLine({
|
||||||
whileInView="shown"
|
whileInView="shown"
|
||||||
viewport={{ once: true, amount: 0.3 }}
|
viewport={{ once: true, amount: 0.3 }}
|
||||||
>
|
>
|
||||||
{Array.from(text).map((char, index) => (
|
{/* Grouped per word, not per character: a bare list of inline-block
|
||||||
<motion.span
|
letters lets the browser break inside a word ("fülle/n"), which it
|
||||||
key={index}
|
does on narrow phones. Each word is whitespace-nowrap, so it travels
|
||||||
variants={charVariants}
|
as one unit while the stagger still runs across the whole line. */}
|
||||||
className="inline-block origin-bottom"
|
{splitWords(text).map((word, wordIndex) => (
|
||||||
style={{ transformStyle: "preserve-3d" }}
|
<span key={wordIndex} className="inline-block whitespace-nowrap">
|
||||||
>
|
{Array.from(word).map((char, index) => (
|
||||||
{char === " " ? " " : char}
|
<motion.span
|
||||||
</motion.span>
|
key={index}
|
||||||
|
variants={charVariants}
|
||||||
|
className="inline-block origin-bottom"
|
||||||
|
style={{ transformStyle: "preserve-3d" }}
|
||||||
|
>
|
||||||
|
{char === " " ? " " : char}
|
||||||
|
</motion.span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
))}
|
))}
|
||||||
</motion.span>
|
</motion.span>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
import type { ComponentType, ReactNode } from "react";
|
import type { ComponentType, ReactNode } from "react";
|
||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
|
|
@ -30,6 +31,8 @@ import {
|
||||||
import { BrowserFrame, ParallaxLayer } from "./mockup";
|
import { BrowserFrame, ParallaxLayer } from "./mockup";
|
||||||
import { BrandButton, Eyebrow, Faq, GhostLink, WaitlistForm } from "./ui";
|
import { BrandButton, Eyebrow, Faq, GhostLink, WaitlistForm } from "./ui";
|
||||||
import { Logo } from "./logo";
|
import { Logo } from "./logo";
|
||||||
|
import { FAQ_ENTRIES } from "../lib/faq";
|
||||||
|
import { CONTACT_EMAIL } from "../lib/site";
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ *
|
/* ------------------------------------------------------------------ *
|
||||||
* Shared section scaffolding
|
* Shared section scaffolding
|
||||||
|
|
@ -497,7 +500,15 @@ export function Pricing() {
|
||||||
Platz auf der Warteliste sichern
|
Platz auf der Warteliste sichern
|
||||||
</BrandButton>
|
</BrandButton>
|
||||||
<p className="text-[13px] text-slate-helper">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -620,15 +631,15 @@ export function Pilot() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<blockquote className="mt-8 border-l-2 border-hairline pl-5">
|
{/* Das Testimonial folgt, sobald 120gradStudio ein freigegebenes
|
||||||
<p className="text-body-lg italic text-ink">
|
Zitat geliefert hat. Bis dahin steht hier die belegbare
|
||||||
„[Hier das Zitat von 120gradStudio einsetzen — ein bis zwei
|
Aussage statt eines erfundenen Zitats — und im Markup kein
|
||||||
Sätze, am besten mit konkreter Zahl.]“
|
Review-Schema, das Google als Manipulation werten würde. */}
|
||||||
</p>
|
<p className="mt-8 border-l-2 border-amber pl-5 text-body-lg text-ink">
|
||||||
<footer className="mt-3 text-body text-slate-helper">
|
Seit dem Start laufen dort Kursplanung, Buchungen und Zahlungen
|
||||||
[Name], 120gradStudio
|
vollständig über Kumasy — im laufenden Betrieb, nicht in einer
|
||||||
</footer>
|
Testumgebung.
|
||||||
</blockquote>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
|
|
@ -685,33 +696,26 @@ export function FaqSection() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Reveal delay={0.1}>
|
<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">
|
<div className="border-t border-hairline">
|
||||||
<Faq question="Brauchen meine Kundinnen einen Account?">
|
{FAQ_ENTRIES.map(({ question, answer, link }) => (
|
||||||
Nein. Gastbuchung ohne Registrierung ist Standard — genau da
|
<Faq key={question} question={question}>
|
||||||
springen sonst die meisten ab. Wer will, legt sich trotzdem ein
|
{answer}
|
||||||
Konto an und sieht seine Buchungshistorie.
|
{link ? (
|
||||||
</Faq>
|
<>
|
||||||
<Faq question="Was passiert mit meinen bestehenden Daten?">
|
{" "}
|
||||||
Unser Migrationstool pflegt Kurse, Kundinnen und Termine aus
|
<Link
|
||||||
deinem alten System automatisch in Kumasy ein. Du musst nichts
|
href={link.href}
|
||||||
abtippen und kannst vorher prüfen, was übernommen wird.
|
className="font-medium text-ink underline decoration-amber decoration-2 underline-offset-4 transition-opacity duration-200 hover:opacity-60"
|
||||||
</Faq>
|
>
|
||||||
<Faq question="Wer zahlt die 0,50 € pro Buchung?">
|
{link.label}
|
||||||
Deine Kundin, beim Checkout. Dein monatlicher Betrag bleibt bei
|
</Link>
|
||||||
150 € — unabhängig davon, ob du 20 oder 2.000 Buchungen im Monat
|
</>
|
||||||
hast. Dazu kommen nur die Transaktionsgebühren von Stripe bzw.
|
) : null}
|
||||||
PayPal.
|
</Faq>
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -765,7 +769,7 @@ export function ClosingCta() {
|
||||||
</Reveal>
|
</Reveal>
|
||||||
<Reveal delay={0.32}>
|
<Reveal delay={0.32}>
|
||||||
<div className="mx-auto mt-9 max-w-[520px] text-left">
|
<div className="mx-auto mt-9 max-w-[520px] text-left">
|
||||||
<WaitlistForm compact />
|
<WaitlistForm compact source="Abschluss-CTA" />
|
||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -778,6 +782,12 @@ export function ClosingCta() {
|
||||||
* Footer
|
* Footer
|
||||||
* ------------------------------------------------------------------ */
|
* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
const LEGAL_LINKS = [
|
||||||
|
{ href: "/impressum", label: "Impressum" },
|
||||||
|
{ href: "/datenschutz", label: "Datenschutz" },
|
||||||
|
{ href: "/agb", label: "AGB" },
|
||||||
|
];
|
||||||
|
|
||||||
export function Footer() {
|
export function Footer() {
|
||||||
return (
|
return (
|
||||||
<footer className="border-t border-hairline px-4 py-10">
|
<footer className="border-t border-hairline px-4 py-10">
|
||||||
|
|
@ -787,25 +797,24 @@ export function Footer() {
|
||||||
<nav aria-label="Rechtliches">
|
<nav aria-label="Rechtliches">
|
||||||
<ul className="flex flex-wrap items-center gap-x-6 gap-y-2 text-body text-slate-helper">
|
<ul className="flex flex-wrap items-center gap-x-6 gap-y-2 text-body text-slate-helper">
|
||||||
<li>
|
<li>
|
||||||
<a href="mailto:info@kumasy.de" className="hover:text-ink">
|
<a
|
||||||
info@kumasy.de
|
href={`mailto:${CONTACT_EMAIL}`}
|
||||||
</a>
|
translate="no"
|
||||||
</li>
|
className="transition-colors duration-200 hover:text-ink"
|
||||||
<li>
|
>
|
||||||
<a href="/impressum" className="hover:text-ink">
|
{CONTACT_EMAIL}
|
||||||
Impressum
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="/datenschutz" className="hover:text-ink">
|
|
||||||
Datenschutz
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="/agb" className="hover:text-ink">
|
|
||||||
AGB
|
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
{LEGAL_LINKS.map(({ href, label }) => (
|
||||||
|
<li key={href}>
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className="transition-colors duration-200 hover:text-ink"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,21 @@
|
||||||
"use client";
|
"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 { 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 { Logo } from "./logo";
|
||||||
import { useReducedMotion } from "./motion";
|
import { useReducedMotion } from "./motion";
|
||||||
|
import { joinWaitlist } from "../lib/waitlist";
|
||||||
|
import { initialWaitlistState } from "../lib/waitlist-state";
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ *
|
/* ------------------------------------------------------------------ *
|
||||||
* Eyebrow label pill
|
* 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
|
* Floating glass nav
|
||||||
* ------------------------------------------------------------------ */
|
* ------------------------------------------------------------------ */
|
||||||
|
|
@ -153,8 +306,16 @@ const NAV_LINKS = [
|
||||||
export function Nav() {
|
export function Nav() {
|
||||||
const [scrolled, setScrolled] = useState(false);
|
const [scrolled, setScrolled] = useState(false);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [loginOpen, setLoginOpen] = useState(false);
|
||||||
const reduced = useReducedMotion();
|
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(() => {
|
useEffect(() => {
|
||||||
const onScroll = () => setScrolled(window.scrollY > 24);
|
const onScroll = () => setScrolled(window.scrollY > 24);
|
||||||
onScroll();
|
onScroll();
|
||||||
|
|
@ -172,6 +333,7 @@ export function Nav() {
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<header className="pointer-events-none fixed inset-x-0 top-4 z-50 flex justify-center px-4">
|
<header className="pointer-events-none fixed inset-x-0 top-4 z-50 flex justify-center px-4">
|
||||||
<motion.nav
|
<motion.nav
|
||||||
aria-label="Hauptnavigation"
|
aria-label="Hauptnavigation"
|
||||||
|
|
@ -201,9 +363,13 @@ export function Nav() {
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div className="hidden items-center gap-3 md:flex">
|
<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
|
Login
|
||||||
</GhostLink>
|
</button>
|
||||||
<BrandButton href="#warteliste" className="!py-2 !text-[14px]">
|
<BrandButton href="#warteliste" className="!py-2 !text-[14px]">
|
||||||
Auf die Warteliste
|
Auf die Warteliste
|
||||||
</BrandButton>
|
</BrandButton>
|
||||||
|
|
@ -249,7 +415,13 @@ export function Nav() {
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
<li className="mt-2 flex items-center justify-between gap-3 px-3 pb-2">
|
<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]">
|
<BrandButton href="#warteliste" className="!py-2 !text-[14px]">
|
||||||
Auf die Warteliste
|
Auf die Warteliste
|
||||||
</BrandButton>
|
</BrandButton>
|
||||||
|
|
@ -260,6 +432,12 @@ export function Nav() {
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</motion.nav>
|
</motion.nav>
|
||||||
</header>
|
</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
|
* Waitlist form
|
||||||
* ------------------------------------------------------------------ */
|
* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
export function WaitlistForm({ compact = false }: { compact?: boolean }) {
|
export function WaitlistForm({
|
||||||
const [email, setEmail] = useState("");
|
compact = false,
|
||||||
const [state, setState] = useState<"idle" | "sending" | "done">("idle");
|
/** 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 fieldId = useId();
|
||||||
const statusId = `${fieldId}-status`;
|
const statusId = `${fieldId}-status`;
|
||||||
|
const errorId = `${fieldId}-error`;
|
||||||
|
const consentId = `${fieldId}-consent`;
|
||||||
|
|
||||||
// No backend is wired up yet — this validates and acknowledges locally.
|
if (state.status === "success") {
|
||||||
const onSubmit = (event: React.FormEvent) => {
|
|
||||||
event.preventDefault();
|
|
||||||
if (state !== "idle") return;
|
|
||||||
setState("sending");
|
|
||||||
window.setTimeout(() => setState("done"), 700);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (state === "done") {
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="glass glass-rim flex items-center gap-3 rounded-inputs px-5 py-4"
|
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 (
|
return (
|
||||||
<form onSubmit={onSubmit} className="w-full">
|
<form action={formAction} className="w-full">
|
||||||
<label htmlFor={fieldId} className="sr-only">
|
<label htmlFor={fieldId} className="sr-only">
|
||||||
E-Mail-Adresse
|
E-Mail-Adresse
|
||||||
</label>
|
</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
|
<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"
|
compact ? "" : "sm:p-2.5"
|
||||||
}`}
|
} ${failed ? "ring-2 ring-peach-ink/50" : ""}`}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
id={fieldId}
|
id={fieldId}
|
||||||
|
|
@ -318,18 +515,57 @@ export function WaitlistForm({ compact = false }: { compact?: boolean }) {
|
||||||
required
|
required
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
inputMode="email"
|
inputMode="email"
|
||||||
placeholder="E-Mail-Adresse"
|
spellCheck={false}
|
||||||
aria-describedby={statusId}
|
maxLength={254}
|
||||||
value={email}
|
placeholder="name@studio.de"
|
||||||
onChange={(event) => setEmail(event.target.value)}
|
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"
|
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"}>
|
<BrandButton type="submit" disabled={pending}>
|
||||||
{state === "sending" ? "Moment…" : "Platz sichern"}
|
<span className="min-w-[6.5rem] text-center">
|
||||||
|
{pending ? "Moment…" : "Platz sichern"}
|
||||||
|
</span>
|
||||||
</BrandButton>
|
</BrandButton>
|
||||||
</div>
|
</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
|
Du bekommst eine E-Mail, sobald dein Zugang bereit ist. Keine
|
||||||
Verpflichtung, keine Zahlungsdaten.
|
Verpflichtung, keine Zahlungsdaten.
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
123
app/datenschutz/page.tsx
Normal file
123
app/datenschutz/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -130,6 +130,16 @@ body {
|
||||||
border-radius: 6px;
|
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 ─────────────────────────────────────────
|
/* ── Glass system ─────────────────────────────────────────
|
||||||
The four ingredients from DESIGN-SYSTEM.md, bundled once:
|
The four ingredients from DESIGN-SYSTEM.md, bundled once:
|
||||||
translucent fill, backdrop blur, light edge, soft shadow.
|
translucent fill, backdrop blur, light edge, soft shadow.
|
||||||
|
|
|
||||||
16
app/icon.svg
Normal file
16
app/icon.svg
Normal 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
92
app/impressum/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import type { Metadata, Viewport } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
import { Manrope, Plus_Jakarta_Sans } from "next/font/google";
|
import { Manrope, Plus_Jakarta_Sans } from "next/font/google";
|
||||||
import "./globals.css";
|
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.
|
// DESIGN.md names Switzer / Basier Circle; neither ships on Google Fonts.
|
||||||
// These are the documented substitutes — Manrope for UI, Plus Jakarta Sans for
|
// These are the documented substitutes — Manrope for UI, Plus Jakarta Sans for
|
||||||
|
|
@ -20,51 +22,60 @@ const display = Plus_Jakarta_Sans({
|
||||||
display: "swap",
|
display: "swap",
|
||||||
});
|
});
|
||||||
|
|
||||||
const SITE_URL = "https://kumasy.de";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
metadataBase: new URL(SITE_URL),
|
metadataBase: new URL(SITE_URL),
|
||||||
title: {
|
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",
|
template: "%s · Kumasy",
|
||||||
},
|
},
|
||||||
description:
|
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.",
|
"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.",
|
||||||
keywords: [
|
applicationName: SITE_NAME,
|
||||||
"Kursverwaltung",
|
category: "Business Software",
|
||||||
"Buchungssystem",
|
authors: [{ name: SITE_NAME, url: SITE_URL }],
|
||||||
"Fitnessstudio Software",
|
creator: SITE_NAME,
|
||||||
"Yoga Studio Verwaltung",
|
publisher: SITE_NAME,
|
||||||
"Kursbuchung",
|
|
||||||
"Stripe Zahlungen",
|
|
||||||
"GoBD",
|
|
||||||
"DSGVO",
|
|
||||||
],
|
|
||||||
alternates: { canonical: "/" },
|
alternates: { canonical: "/" },
|
||||||
openGraph: {
|
openGraph: {
|
||||||
type: "website",
|
type: "website",
|
||||||
locale: "de_DE",
|
locale: "de_DE",
|
||||||
url: SITE_URL,
|
url: SITE_URL,
|
||||||
siteName: "Kumasy",
|
siteName: SITE_NAME,
|
||||||
title: "Deine Kurse füllen und bezahlen sich von selbst.",
|
title: "Deine Kurse füllen und bezahlen sich von selbst.",
|
||||||
description:
|
description:
|
||||||
"Kurse, Räume, Trainer, Buchungen und Zahlungen in einer Oberfläche. Jetzt auf die Warteliste.",
|
"Kurse, Räume, Trainer, Buchungen und Zahlungen in einer Oberfläche. Jetzt auf die Warteliste.",
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
card: "summary_large_image",
|
card: "summary_large_image",
|
||||||
title: "Kumasy — Deine Kurse füllen und bezahlen sich von selbst.",
|
title: "Kumasy — Kursverwaltung und Buchungssystem",
|
||||||
description:
|
description:
|
||||||
"Kurse, Räume, Trainer, Buchungen und Zahlungen in einer Oberfläche.",
|
"Kurse, Räume, Trainer, Buchungen und Zahlungen in einer Oberfläche.",
|
||||||
},
|
},
|
||||||
icons: {
|
// No `icons` key: app/favicon.ico, app/icon.svg and app/apple-icon.png
|
||||||
icon: "/favicon.ico",
|
// are picked up by the file convention. Declaring them here as well
|
||||||
apple: "/icons/apple-touch-icon.png",
|
// 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 = {
|
export const viewport: Viewport = {
|
||||||
themeColor: "#ffffff",
|
// Matches --color-canvas (#f9fafb), the actual body background.
|
||||||
|
themeColor: "#f9fafb",
|
||||||
colorScheme: "light",
|
colorScheme: "light",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -74,6 +85,18 @@ export default function RootLayout({ children }: LayoutProps<"/">) {
|
||||||
lang="de"
|
lang="de"
|
||||||
className={`${switzer.variable} ${display.variable} h-full antialiased`}
|
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">
|
<body className="min-h-full flex flex-col bg-canvas text-ink">
|
||||||
<a
|
<a
|
||||||
href="#hauptinhalt"
|
href="#hauptinhalt"
|
||||||
|
|
|
||||||
45
app/lib/faq.ts
Normal file
45
app/lib/faq.ts
Normal 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
32
app/lib/site.ts
Normal 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
104
app/lib/structured-data.ts
Normal 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
15
app/lib/waitlist-state.ts
Normal 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
181
app/lib/waitlist.ts
Normal 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
34
app/manifest.ts
Normal 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
78
app/not-found.tsx
Normal 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
19
app/robots.ts
Normal 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
21
app/sitemap.ts
Normal 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`],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
@ -14,4 +14,8 @@ const nextConfig: NextConfig = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
allowedDevOrigins: ['192.168.2.34'],
|
||||||
|
}
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|
|
||||||
21
package-lock.json
generated
21
package-lock.json
generated
|
|
@ -12,12 +12,14 @@
|
||||||
"lucide-react": "^1.47.0",
|
"lucide-react": "^1.47.0",
|
||||||
"motion": "^13.4.0",
|
"motion": "^13.4.0",
|
||||||
"next": "16.3.5",
|
"next": "16.3.5",
|
||||||
|
"nodemailer": "^10.0.10",
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
"react-dom": "19.2.8"
|
"react-dom": "19.2.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
"@types/nodemailer": "^8.0.2",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
|
|
@ -1646,6 +1648,16 @@
|
||||||
"undici-types": "~6.21.0"
|
"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": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.3.0",
|
"version": "19.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
|
||||||
|
|
@ -5385,6 +5397,15 @@
|
||||||
"node": ">=18"
|
"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": {
|
"node_modules/object-assign": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -13,12 +13,14 @@
|
||||||
"lucide-react": "^1.47.0",
|
"lucide-react": "^1.47.0",
|
||||||
"motion": "^13.4.0",
|
"motion": "^13.4.0",
|
||||||
"next": "16.3.5",
|
"next": "16.3.5",
|
||||||
|
"nodemailer": "^10.0.10",
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
"react-dom": "19.2.8"
|
"react-dom": "19.2.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
"@types/nodemailer": "^8.0.2",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue