mirror of
https://github.com/Since-3/Kumasy-Marketing.git
synced 2026-10-12 19:01:17 +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
|
||||
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
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({
|
||||
|
|
@ -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 ${
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
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;
|
||||
}
|
||||
|
||||
/* 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
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 { 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
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;
|
||||
|
|
|
|||
21
package-lock.json
generated
21
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue