Compare commits

..

9 Commits

Author SHA1 Message Date
t.indorf b5eb288104 Merge branch 'codex/fix-public-landing-clean' 2026-05-18 16:56:45 +02:00
t.indorf ce1a5c9e42 Make landing page public and redirect auth users directly 2026-05-18 16:55:21 +02:00
t.indorf 6c79e0a8e1 Merge claude/profil-and-mail-fixes into main
Fix mail layouts, /invite form-reset, profile validation
2026-05-17 23:54:07 +02:00
t.indorf 935845ce56 Merge claude/onboarding-fixes into main
Persist values, tighten validation, and a11y on /onboarding
2026-05-17 23:53:57 +02:00
t.indorf cb4f7409bc Merge claude/register-fixes into main
Persist values, a11y errors, and aria-invalid on /register
2026-05-17 23:53:38 +02:00
t.indorf fa06da324c Fix mail layouts, /invite form-reset, profile validation
Mail templates (6 templates touched):
- New shared `_styles.ts` exports a robust font stack starting with
  Arial/Helvetica so mail readers that lack -apple-system and
  BlinkMacSystemFont don't fall back to Monospace (observed in at least
  one webmail client).
- `fontFamily` is now set on every text-bearing element, not only on
  `<Body>`, because some clients (Outlook, several webmail readers) do
  not inherit body CSS into nested sections.
- ASCII substitutions are gone: "Ueber" → "Über", "fuer" → "für",
  "bestaetigen" → "bestätigen", "uebernehmen" → "übernehmen",
  "verlaesslich" → "verlässlich".
- InviteEmail + NewsEmail buttons switch from the off-brand yellow
  (#f0b84b) to the brand dark green so the CTA matches the header.

/invite form:
- InviteState gains an `attempt` counter plus echoed checkbox `values`,
  mirroring the /register and /onboarding fix from earlier branches. A
  failed submit no longer wipes the Datenschutz / Adressbuch-Opt-In
  ticks. Passwords stay empty for security.
- Field error <p> elements gain role="alert" + aria-live="polite" so
  screen readers announce validation failures on submit. Inputs use
  aria-describedby to link to error / helper text.

Profile validation:
- Telephone numbers must match a permissive pattern that still rejects
  obvious garbage (previously "abc-123-not-a-phone" was accepted).
- Postal codes must match an alphanumeric pattern of 3–10 chars
  (previously "ABCDE" was accepted).
- Child date-of-birth is now validated via superRefine: it must be a
  parseable date, not in the future, and not before 1900. Previously a
  kid born in 2030 was accepted.
- The three Profil server actions now surface the first Zod issue
  message instead of generic "Ungültige Eingabedaten." toasts.

Misc:
- "fuer" → "für" in the Adressbuch page subtitle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 23:49:01 +02:00
t.indorf 211bb9e300 Add Codex branch workflow rule 2026-05-17 23:14:06 +02:00
t.indorf af2c3b245a Persist values, tighten validation, and a11y on /onboarding
- Server action echoes kitaName, the three module booleans, and the
  Mindest-Verfügbarkeiten value on every error path so failed submits no
  longer wipe what the user typed; OnboardingState gains an `attempt`
  counter that drives a per-field remount key on the client.
- Schema now rejects min=0 when the Notdienst module is active (via
  superRefine) — previously a kita could be created with 0 minimum days,
  which is fachlich sinnlos. When the module is off, 0 stays valid.
- Validation error messages reworded from terse fragments ("Nicht
  negativ.", "Bitte ganze Zahl.") to full sentences.
- Field error <p> elements gain role="alert" + aria-live="polite" so
  screen readers announce them on submit.
- CardTitle now accepts as="h1" and the onboarding page uses it for
  "Willkommen, …" — the page now has a real top-level heading.
- ModuleCheckbox uses aria-labelledby + aria-describedby so the
  accessible name is just the module title; the longer description is
  exposed as secondary info instead of being concatenated into the AN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:46:31 +02:00
t.indorf 8640001f6f Persist values, a11y errors, and aria-invalid on /register
- Server action now echoes firstName, lastName, email, and the privacy
  checkbox state on every error path so failed submits don't wipe the form.
- New `attempt` counter on RegisterState drives a remount key per field,
  bypassing React's default <form action> reset semantics.
- Error <p> elements gain role="alert" + aria-live="polite" so screen
  readers announce validation failures live.
- Privacy checkbox now flags aria-invalid when not accepted and points to
  the error message via aria-describedby.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:55:28 +02:00
21 changed files with 592 additions and 141 deletions
+6
View File
@@ -3,3 +3,9 @@
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->
<!-- BEGIN:codex-workflow-rules -->
# Codex workflow
Before making repository changes, Codex should work from a dedicated branch in the Codex worktree. Prefer a `codex/...` branch name when Git refs allow it; if an existing ref blocks that namespace, use a `codex-...` branch name instead.
<!-- END:codex-workflow-rules -->
+1 -1
View File
@@ -124,7 +124,7 @@ export default async function AdressbuchPage() {
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-bold tracking-tight">Adressbuch</h1>
<p className="max-w-2xl text-sm text-muted-foreground">
Haushalte und Kontaktdaten, die explizit fuer das interne
Haushalte und Kontaktdaten, die explizit für das interne
Kita-Adressbuch freigegeben wurden.
</p>
</div>
+73 -7
View File
@@ -6,20 +6,76 @@ import { z } from "zod";
import { requireKitaSession } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
const childSchema = z.object({
// Telefonnummern: führendes "+" optional, dann Ziffern, Leerzeichen, Bindestriche,
// Klammern oder Schrägstriche. Mindestens 6 Ziffern damit es plausibel ist.
// Schließt damit "abc-123-not-a-phone" aus, lässt aber internationale Formate zu.
const phonePattern = /^[+]?[0-9][\d\s\-()/.]{4,}$/;
// PLZ: Ziffern/Buchstaben/Leerzeichen/Bindestriche, 310 Zeichen.
// Kein striktes DE-Format (5 Ziffern), damit auch AT/CH/UK denkbar sind.
const postalCodePattern = /^[0-9A-Za-z][0-9A-Za-z\s-]{2,9}$/;
const childSchema = z
.object({
firstName: z.string().min(1, "Vorname ist erforderlich.").max(100).trim(),
lastName: z.string().min(1, "Nachname ist erforderlich.").max(100).trim(),
dateOfBirth: z.string().optional(),
});
})
.superRefine((data, ctx) => {
if (!data.dateOfBirth) return;
const parsed = new Date(`${data.dateOfBirth}T00:00:00`);
if (Number.isNaN(parsed.getTime())) {
ctx.addIssue({
path: ["dateOfBirth"],
code: "custom",
message: "Bitte ein gültiges Datum angeben.",
});
return;
}
// In der Zukunft geborene Kinder existieren nicht.
const today = new Date();
today.setHours(23, 59, 59, 999);
if (parsed > today) {
ctx.addIssue({
path: ["dateOfBirth"],
code: "custom",
message: "Geburtsdatum darf nicht in der Zukunft liegen.",
});
}
// Schutz vor offensichtlichen Tippfehlern (1899 statt 1989 o.ä.).
if (parsed.getFullYear() < 1900) {
ctx.addIssue({
path: ["dateOfBirth"],
code: "custom",
message: "Bitte ein realistisches Geburtsdatum angeben.",
});
}
});
const familySchema = z.object({
familyName: z.string().min(1, "Familienname ist erforderlich.").max(120).trim(),
});
const contactSchema = z.object({
phone: z.string().trim().max(50).optional(),
phone: z
.string()
.trim()
.max(50)
.optional()
.refine(
(v) => !v || phonePattern.test(v),
"Bitte eine gültige Telefonnummer angeben.",
),
street: z.string().trim().max(120).optional(),
postalCode: z.string().trim().max(20).optional(),
postalCode: z
.string()
.trim()
.max(20)
.optional()
.refine(
(v) => !v || postalCodePattern.test(v),
"Bitte eine gültige Postleitzahl angeben.",
),
city: z.string().trim().max(100).optional(),
});
@@ -30,6 +86,16 @@ function parseDateInput(value?: string) {
return Number.isNaN(date.getTime()) ? null : date;
}
// Gibt die erste konkrete Field-Error-Message aus einem Zod-Fehler zurück,
// damit der User nicht nur "Ungültige Eingabedaten" sieht.
function firstZodMessage(
error: z.ZodError,
fallback: string,
): string {
const issue = error.issues[0];
return issue?.message || fallback;
}
async function requireOwnFamilyChild(
childId: string,
familyId: string | null,
@@ -51,7 +117,7 @@ export async function createMyChild(rawPayload: unknown) {
}
if (!parsed.success) {
return { error: "Ungültige Eingabedaten." };
return { error: firstZodMessage(parsed.error, "Ungültige Eingabedaten.") };
}
try {
@@ -117,7 +183,7 @@ export async function updateMyContact(rawPayload: unknown) {
const parsed = contactSchema.safeParse(rawPayload);
if (!parsed.success) {
return { error: "Ungültige Kontaktdaten." };
return { error: firstZodMessage(parsed.error, "Ungültige Kontaktdaten.") };
}
try {
@@ -146,7 +212,7 @@ export async function updateMyChild(childId: string, rawPayload: unknown) {
const parsed = childSchema.safeParse(rawPayload);
if (!parsed.success) {
return { error: "Ungültige Eingabedaten." };
return { error: firstZodMessage(parsed.error, "Ungültige Eingabedaten.") };
}
try {
+39 -5
View File
@@ -34,6 +34,16 @@ const inviteSchema = z
const PRIVACY_POLICY_VERSION = "2026-05-01";
export type InviteState = {
// Bumped per call so the client can key uncontrolled inputs and force a
// remount after a failed submit (React's <form action> wipes them
// otherwise).
attempt: number;
// Echoed checkbox states so failed submits don't lose the user's consent
// ticks. Passwords are NEVER echoed back from the server.
values?: {
acceptPrivacyPolicy?: boolean;
directoryOptIn?: boolean;
};
errors?: {
password?: string[];
confirmPassword?: string[];
@@ -43,10 +53,22 @@ export type InviteState = {
};
};
export const initialInviteState: InviteState = { attempt: 0 };
function echoCheckboxValues(formData: FormData): InviteState["values"] {
return {
acceptPrivacyPolicy: formData.get("acceptPrivacyPolicy") === "on",
directoryOptIn: formData.get("directoryOptIn") === "on",
};
}
export async function acceptInviteAction(
_prev: InviteState,
prev: InviteState,
formData: FormData,
): Promise<InviteState> {
const attempt = prev.attempt + 1;
const values = echoCheckboxValues(formData);
const parsed = inviteSchema.safeParse({
token: formData.get("token"),
password: formData.get("password"),
@@ -56,7 +78,7 @@ export async function acceptInviteAction(
});
if (!parsed.success) {
return { errors: parsed.error.flatten().fieldErrors };
return { attempt, values, errors: parsed.error.flatten().fieldErrors };
}
const { token, password, directoryOptIn } = parsed.data;
@@ -68,10 +90,16 @@ export async function acceptInviteAction(
});
if (!verificationToken) {
return { errors: { _form: ["Einladungslink ist ungültig."] } };
return {
attempt,
values,
errors: { _form: ["Einladungslink ist ungültig."] },
};
}
if (verificationToken.expires < now) {
return {
attempt,
values,
errors: {
_form: [
"Dieser Einladungslink ist abgelaufen. Bitte wende dich an deinen Administrator.",
@@ -89,11 +117,17 @@ export async function acceptInviteAction(
});
if (!user) {
return { errors: { _form: ["Benutzer nicht gefunden."] } };
return {
attempt,
values,
errors: { _form: ["Benutzer nicht gefunden."] },
};
}
if (user.passwordHash !== "") {
// Invite wurde bereits eingelöst
return {
attempt,
values,
errors: {
_form: [
"Dieser Einladungslink wurde bereits verwendet. Bitte melde dich an.",
@@ -131,5 +165,5 @@ export async function acceptInviteAction(
});
// Unreachable signIn redirected.
return {};
return { attempt };
}
+54 -9
View File
@@ -6,9 +6,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { acceptInviteAction, type InviteState } from "./actions";
const initialState: InviteState = {};
import { acceptInviteAction, initialInviteState } from "./actions";
// =====================================================================
// InviteForm · Client Component
@@ -27,9 +25,14 @@ export function InviteForm({
}) {
const [state, formAction, pending] = useActionState(
acceptInviteAction,
initialState,
initialInviteState,
);
// Bumping the key on each attempt forces uncontrolled inputs (including
// checkboxes) to remount with the echoed defaultChecked values after a
// failed submit. Passwords stay empty for security.
const attemptKey = state.attempt;
return (
<form action={formAction} className="space-y-5">
{/* Token als verstecktes Feld */}
@@ -45,17 +48,30 @@ export function InviteForm({
<div className="space-y-1.5">
<Label htmlFor="password">Passwort wählen</Label>
<Input
key={`password-${attemptKey}`}
id="password"
name="password"
type="password"
autoComplete="new-password"
required
aria-invalid={!!state.errors?.password}
aria-describedby={
state.errors?.password ? "password-error" : "password-helper"
}
/>
{state.errors?.password?.[0] ? (
<p className="text-xs text-destructive">{state.errors.password[0]}</p>
<p
id="password-error"
role="alert"
aria-live="polite"
className="text-xs text-destructive"
>
{state.errors.password[0]}
</p>
) : (
<p className="text-xs text-muted-foreground">Mindestens 8 Zeichen.</p>
<p id="password-helper" className="text-xs text-muted-foreground">
Mindestens 8 Zeichen.
</p>
)}
</div>
@@ -63,15 +79,26 @@ export function InviteForm({
<div className="space-y-1.5">
<Label htmlFor="confirmPassword">Passwort bestätigen</Label>
<Input
key={`confirmPassword-${attemptKey}`}
id="confirmPassword"
name="confirmPassword"
type="password"
autoComplete="new-password"
required
aria-invalid={!!state.errors?.confirmPassword}
aria-describedby={
state.errors?.confirmPassword
? "confirmPassword-error"
: undefined
}
/>
{state.errors?.confirmPassword?.[0] && (
<p className="text-xs text-destructive">
<p
id="confirmPassword-error"
role="alert"
aria-live="polite"
className="text-xs text-destructive"
>
{state.errors.confirmPassword[0]}
</p>
)}
@@ -85,10 +112,17 @@ export function InviteForm({
<div className="flex items-start gap-3">
<Checkbox
key={`acceptPrivacyPolicy-${attemptKey}`}
id="acceptPrivacyPolicy"
name="acceptPrivacyPolicy"
required
defaultChecked={state.values?.acceptPrivacyPolicy === true}
aria-invalid={!!state.errors?.acceptPrivacyPolicy}
aria-describedby={
state.errors?.acceptPrivacyPolicy
? "acceptPrivacyPolicy-error"
: undefined
}
/>
<div className="space-y-0.5">
<Label htmlFor="acceptPrivacyPolicy" className="text-sm font-normal">
@@ -104,7 +138,12 @@ export function InviteForm({
gelesen und akzeptiere sie. <span className="text-destructive">*</span>
</Label>
{state.errors?.acceptPrivacyPolicy?.[0] && (
<p className="text-xs text-destructive">
<p
id="acceptPrivacyPolicy-error"
role="alert"
aria-live="polite"
className="text-xs text-destructive"
>
{state.errors.acceptPrivacyPolicy[0]}
</p>
)}
@@ -113,8 +152,10 @@ export function InviteForm({
<div className="flex items-start gap-3">
<Checkbox
key={`directoryOptIn-${attemptKey}`}
id="directoryOptIn"
name="directoryOptIn"
defaultChecked={state.values?.directoryOptIn === true}
aria-invalid={!!state.errors?.directoryOptIn}
/>
<div className="space-y-0.5">
@@ -128,7 +169,11 @@ export function InviteForm({
{/* Globaler Fehler */}
{state.errors?._form?.[0] && (
<p className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
<p
role="alert"
aria-live="polite"
className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"
>
{state.errors._form[0]}
</p>
)}
+1 -1
View File
@@ -16,7 +16,7 @@ const geistMono = Geist_Mono({
export const metadata: Metadata = {
metadataBase: new URL(
process.env.NEXT_PUBLIC_SITE_URL ?? "https://kita-planer.example.com",
process.env.NEXT_PUBLIC_SITE_URL ?? "https://mein-kitaplaner.de",
),
title: {
default: "Der digitale Kita-Planer für Elternvereine",
+55 -9
View File
@@ -16,21 +16,35 @@ import { prisma } from "@/lib/prisma";
// — kein "halb-eingerichteter" Zwischenzustand.
// =====================================================================
const onboardingSchema = z.object({
const onboardingSchema = z
.object({
kitaName: z
.string()
.min(2, "Mindestens 2 Zeichen.")
.max(120, "Maximal 120 Zeichen.")
.trim(),
.trim()
.min(2, "Bitte mindestens 2 Zeichen angeben.")
.max(120, "Maximal 120 Zeichen."),
notdienstModuleEnabled: z.preprocess(checkboxToBool, z.boolean()),
terminModuleEnabled: z.preprocess(checkboxToBool, z.boolean()),
adressbuchModuleEnabled: z.preprocess(checkboxToBool, z.boolean()),
notdienstMinPerChildPerMonth: z.coerce
.number()
.int("Bitte ganze Zahl.")
.min(0, "Nicht negativ.")
.int("Bitte eine ganze Zahl angeben.")
.min(0, "Bitte einen Wert von 0 oder mehr angeben.")
.max(31, "Maximal 31 Tage pro Monat."),
});
})
.superRefine((data, ctx) => {
// Mindest-Verfügbarkeit nur relevant, wenn das Notdienst-Modul aktiv ist.
// Bei aktivem Modul muss mindestens 1 Tag eingetragen sein — 0 wäre
// fachlich sinnlos und würde den Planer leerlaufen lassen.
if (data.notdienstModuleEnabled && data.notdienstMinPerChildPerMonth < 1) {
ctx.addIssue({
path: ["notdienstMinPerChildPerMonth"],
code: "custom",
message:
"Bei aktivem Notdienst-Modul: mindestens 1 Tag pro Monat angeben.",
});
}
});
function checkboxToBool(value: unknown): boolean {
// HTML-Checkbox: "on" wenn aktiviert, sonst nicht im FormData.
@@ -38,6 +52,17 @@ function checkboxToBool(value: unknown): boolean {
}
export type OnboardingState = {
// Bumped after every action call so the client can key uncontrolled inputs
// and force a remount with the echoed values from a failed submit.
attempt: number;
// Echoed inputs so a failed submit doesn't wipe the form.
values?: {
kitaName?: string;
notdienstModuleEnabled?: boolean;
terminModuleEnabled?: boolean;
adressbuchModuleEnabled?: boolean;
notdienstMinPerChildPerMonth?: number;
};
errors?: {
kitaName?: string[];
notdienstMinPerChildPerMonth?: string[];
@@ -45,10 +70,25 @@ export type OnboardingState = {
};
};
export const initialOnboardingState: OnboardingState = { attempt: 0 };
function echoValues(formData: FormData): OnboardingState["values"] {
const min = formData.get("notdienstMinPerChildPerMonth");
return {
kitaName: String(formData.get("kitaName") ?? ""),
notdienstModuleEnabled: formData.get("notdienstModuleEnabled") === "on",
terminModuleEnabled: formData.get("terminModuleEnabled") === "on",
adressbuchModuleEnabled: formData.get("adressbuchModuleEnabled") === "on",
notdienstMinPerChildPerMonth:
min === null || min === "" ? undefined : Number(min),
};
}
export async function completeOnboardingAction(
_prev: OnboardingState,
prev: OnboardingState,
formData: FormData,
): Promise<OnboardingState> {
const attempt = prev.attempt + 1;
const session = await auth();
if (!session?.user) {
redirect("/login");
@@ -61,7 +101,11 @@ export async function completeOnboardingAction(
const parsed = onboardingSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
return { errors: parsed.error.flatten().fieldErrors };
return {
attempt,
values: echoValues(formData),
errors: parsed.error.flatten().fieldErrors,
};
}
const data = parsed.data;
@@ -99,6 +143,8 @@ export async function completeOnboardingAction(
}
if (err instanceof Prisma.PrismaClientKnownRequestError) {
return {
attempt,
values: echoValues(formData),
errors: { _form: ["Datenbankfehler — bitte erneut versuchen."] },
};
}
+86 -22
View File
@@ -1,22 +1,31 @@
"use client";
import { useActionState } from "react";
import { useActionState, useId } from "react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { completeOnboardingAction, type OnboardingState } from "./actions";
const initialState: OnboardingState = {};
import {
completeOnboardingAction,
initialOnboardingState,
} from "./actions";
export function OnboardingForm() {
const [state, formAction, pending] = useActionState(
completeOnboardingAction,
initialState,
initialOnboardingState,
);
// Bumping the key on each attempt forces uncontrolled inputs to remount with
// the echoed defaultValue from the server — otherwise React's <form action>
// semantics would wipe them after a failed submit.
const attemptKey = state.attempt;
const kitaError = state.errors?.kitaName?.[0];
const minError = state.errors?.notdienstMinPerChildPerMonth?.[0];
return (
<form action={formAction} className="space-y-8">
{/* ----- Schritt 1: Name ----- */}
@@ -27,14 +36,24 @@ export function OnboardingForm() {
<div className="space-y-1.5">
<Label htmlFor="kitaName">Name des Elternvereins / der Kita</Label>
<Input
key={`kitaName-${attemptKey}`}
id="kitaName"
name="kitaName"
required
placeholder="z.B. Waldameisen e.V."
aria-invalid={!!state.errors?.kitaName}
defaultValue={state.values?.kitaName ?? ""}
aria-invalid={!!kitaError}
aria-describedby={kitaError ? "kitaName-error" : undefined}
/>
{state.errors?.kitaName?.[0] && (
<p className="text-xs text-destructive">{state.errors.kitaName[0]}</p>
{kitaError && (
<p
id="kitaName-error"
role="alert"
aria-live="polite"
className="text-xs text-destructive"
>
{kitaError}
</p>
)}
</div>
</fieldset>
@@ -45,22 +64,25 @@ export function OnboardingForm() {
Schritt 2 · Module aktivieren
</legend>
<ModuleCheckbox
key={`notdienstModuleEnabled-${attemptKey}`}
name="notdienstModuleEnabled"
label="Notdienst-Planung"
description="Verfügbarkeiten erfassen, Plan generieren, bei Krankheitsausfall alarmieren."
defaultChecked
defaultChecked={state.values?.notdienstModuleEnabled ?? true}
/>
<ModuleCheckbox
key={`terminModuleEnabled-${attemptKey}`}
name="terminModuleEnabled"
label="Terminkalender"
description="Kita-Feste, Schließtage und private Anfragen koordinieren."
defaultChecked
defaultChecked={state.values?.terminModuleEnabled ?? true}
/>
<ModuleCheckbox
key={`adressbuchModuleEnabled-${attemptKey}`}
name="adressbuchModuleEnabled"
label="Eltern-Adressbuch"
description="Eltern können sich auf Opt-In-Basis untereinander finden."
defaultChecked
defaultChecked={state.values?.adressbuchModuleEnabled ?? true}
/>
</fieldset>
@@ -74,29 +96,49 @@ export function OnboardingForm() {
Mindest-Verfügbarkeiten pro Kind und Monat
</Label>
<Input
key={`notdienstMinPerChildPerMonth-${attemptKey}`}
id="notdienstMinPerChildPerMonth"
name="notdienstMinPerChildPerMonth"
type="number"
min={0}
max={31}
defaultValue={2}
defaultValue={state.values?.notdienstMinPerChildPerMonth ?? 2}
required
aria-invalid={!!state.errors?.notdienstMinPerChildPerMonth}
aria-invalid={!!minError}
aria-describedby={
minError
? "notdienstMinPerChildPerMonth-error"
: "notdienstMinPerChildPerMonth-helper"
}
/>
<p className="text-xs text-muted-foreground">
{!minError && (
<p
id="notdienstMinPerChildPerMonth-helper"
className="text-xs text-muted-foreground"
>
Wie viele Tage müssen Eltern pro Monat als verfügbar markieren?
Diesen Wert kannst du später jederzeit ändern.
</p>
{state.errors?.notdienstMinPerChildPerMonth?.[0] && (
<p className="text-xs text-destructive">
{state.errors.notdienstMinPerChildPerMonth[0]}
)}
{minError && (
<p
id="notdienstMinPerChildPerMonth-error"
role="alert"
aria-live="polite"
className="text-xs text-destructive"
>
{minError}
</p>
)}
</div>
</fieldset>
{state.errors?._form?.[0] && (
<p className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
<p
role="alert"
aria-live="polite"
className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"
>
{state.errors._form[0]}
</p>
)}
@@ -119,12 +161,34 @@ function ModuleCheckbox({
description: string;
defaultChecked?: boolean;
}) {
const id = useId();
const titleId = `${id}-title`;
const descId = `${id}-desc`;
return (
<label className="flex items-start gap-3 rounded-md border p-4 transition-colors hover:bg-muted/40">
<Checkbox name={name} defaultChecked={defaultChecked} className="mt-0.5" />
// The wrapping <label htmlFor> keeps the whole card clickable to toggle
// the checkbox. aria-labelledby narrows the accessible name to just the
// module title, with the description exposed via aria-describedby so
// screen readers can present it as secondary info rather than as part of
// the primary label.
<label
htmlFor={id}
className="flex cursor-pointer items-start gap-3 rounded-md border p-4 transition-colors hover:bg-muted/40"
>
<Checkbox
id={id}
name={name}
defaultChecked={defaultChecked}
aria-labelledby={titleId}
aria-describedby={descId}
className="mt-0.5"
/>
<div className="grid gap-1 leading-none">
<span className="text-sm font-medium">{label}</span>
<span className="text-xs text-muted-foreground">{description}</span>
<span id={titleId} className="text-sm font-medium">
{label}
</span>
<span id={descId} className="text-xs text-muted-foreground">
{description}
</span>
</div>
</label>
);
+3 -1
View File
@@ -30,7 +30,9 @@ export default async function OnboardingPage() {
<div className="flex min-h-screen items-center justify-center bg-muted/30 px-4 py-12">
<Card className="w-full max-w-2xl">
<CardHeader className="space-y-2">
<CardTitle>Willkommen, {session.user.name?.split(" ")[0] ?? "Gründer:in"}!</CardTitle>
<CardTitle as="h1">
Willkommen, {session.user.name?.split(" ")[0] ?? "Gründer:in"}!
</CardTitle>
<CardDescription>
Lass uns deine Kita in 3 kurzen Schritten einrichten. Du kannst alle
Einstellungen später noch anpassen.
+4 -10
View File
@@ -1,7 +1,6 @@
import type { Metadata } from "next";
import Image from "next/image";
import Link from "next/link";
import { redirect } from "next/navigation";
import {
ArrowRight,
CheckCircle2,
@@ -23,8 +22,6 @@ import {
UsersRound,
} from "lucide-react";
import { auth } from "@/auth";
import { getPostLoginRedirect } from "@/lib/post-login-redirect";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@@ -41,6 +38,9 @@ export const metadata: Metadata = {
title: "Kita-Planer für Elterninitiativen und Kita-Vereine",
description:
"Die einfache Plattform für Elterninitiativen, freie Kitas und Kita-Vereine: organisiert Dienste, Termine, Krankmeldungen und offizielle Kommunikation an einem Ort.",
alternates: {
canonical: "/",
},
openGraph: {
title: "Kita-Planer für Elterninitiativen und Kita-Vereine",
description:
@@ -172,13 +172,7 @@ const onboardingSteps = [
},
];
// Eingeloggte User von der Landingpage direkt weiterleiten.
export default async function LandingPage() {
const session = await auth();
if (session?.user?.id) {
redirect(getPostLoginRedirect(session.user));
}
export default function LandingPage() {
return (
<div className="min-h-screen bg-[#f8faf8] text-slate-950">
<header className="absolute left-0 right-0 top-0 z-20">
+31 -3
View File
@@ -32,6 +32,16 @@ const registerSchema = z.object({
const PRIVACY_POLICY_VERSION = "2026-05-01";
export type RegisterState = {
// Bumped after every action call so the client can key uncontrolled inputs
// and force a remount with the echoed values from a failed submit.
attempt: number;
// Echoed inputs (sans password) so failed submits don't wipe the form.
values?: {
firstName?: string;
lastName?: string;
email?: string;
acceptPrivacyPolicy?: boolean;
};
errors?: {
email?: string[];
password?: string[];
@@ -42,13 +52,29 @@ export type RegisterState = {
};
};
export const initialRegisterState: RegisterState = { attempt: 0 };
function echoValues(formData: FormData): RegisterState["values"] {
return {
firstName: String(formData.get("firstName") ?? ""),
lastName: String(formData.get("lastName") ?? ""),
email: String(formData.get("email") ?? ""),
acceptPrivacyPolicy: formData.get("acceptPrivacyPolicy") === "on",
};
}
export async function registerAction(
_prevState: RegisterState,
prevState: RegisterState,
formData: FormData,
): Promise<RegisterState> {
const attempt = prevState.attempt + 1;
const parsed = registerSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
return { errors: parsed.error.flatten().fieldErrors };
return {
attempt,
values: echoValues(formData),
errors: parsed.error.flatten().fieldErrors,
};
}
const { email, password, firstName, lastName } = parsed.data;
@@ -77,6 +103,8 @@ export async function registerAction(
err.code === "P2002"
) {
return {
attempt,
values: echoValues(formData),
errors: {
email: ["Mit dieser E-Mail-Adresse existiert bereits ein Account."],
},
@@ -93,5 +121,5 @@ export async function registerAction(
});
// Unreachable signIn redirected.
return {};
return { attempt };
}
+66 -10
View File
@@ -7,45 +7,58 @@ import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { registerAction, type RegisterState } from "./actions";
const initialState: RegisterState = {};
import { initialRegisterState, registerAction } from "./actions";
export function RegisterForm() {
const [state, formAction, pending] = useActionState(registerAction, initialState);
const [state, formAction, pending] = useActionState(
registerAction,
initialRegisterState,
);
// Bumping the key on each attempt forces uncontrolled inputs to remount with
// the echoed defaultValue from the server — otherwise React's <form action>
// semantics would wipe them after a failed submit.
const attemptKey = state.attempt;
return (
<form action={formAction} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<FormField
key={`firstName-${attemptKey}`}
id="firstName"
name="firstName"
label="Vorname"
autoComplete="given-name"
required
defaultValue={state.values?.firstName ?? ""}
error={state.errors?.firstName?.[0]}
/>
<FormField
key={`lastName-${attemptKey}`}
id="lastName"
name="lastName"
label="Nachname"
autoComplete="family-name"
required
defaultValue={state.values?.lastName ?? ""}
error={state.errors?.lastName?.[0]}
/>
</div>
<FormField
key={`email-${attemptKey}`}
id="email"
name="email"
type="email"
label="E-Mail"
autoComplete="email"
required
defaultValue={state.values?.email ?? ""}
error={state.errors?.email?.[0]}
/>
<FormField
key={`password-${attemptKey}`}
id="password"
name="password"
type="password"
@@ -57,23 +70,51 @@ export function RegisterForm() {
/>
<div className="flex items-start gap-3 pt-2">
<Checkbox id="acceptPrivacyPolicy" name="acceptPrivacyPolicy" required />
<Checkbox
key={`acceptPrivacyPolicy-${attemptKey}`}
id="acceptPrivacyPolicy"
name="acceptPrivacyPolicy"
required
defaultChecked={state.values?.acceptPrivacyPolicy === true}
aria-invalid={!!state.errors?.acceptPrivacyPolicy?.[0]}
aria-describedby={
state.errors?.acceptPrivacyPolicy?.[0]
? "acceptPrivacyPolicy-error"
: undefined
}
/>
<div className="grid gap-1 leading-none">
<Label htmlFor="acceptPrivacyPolicy" className="text-sm font-normal">
Ich habe die{" "}
<a href="/datenschutz" className="underline" target="_blank" rel="noreferrer">
<a
href="/datenschutz"
className="underline"
target="_blank"
rel="noreferrer"
>
Datenschutzerklärung
</a>{" "}
gelesen und akzeptiere sie.
</Label>
{state.errors?.acceptPrivacyPolicy?.[0] && (
<p className="text-xs text-destructive">{state.errors.acceptPrivacyPolicy[0]}</p>
<p
id="acceptPrivacyPolicy-error"
role="alert"
aria-live="polite"
className="text-xs text-destructive"
>
{state.errors.acceptPrivacyPolicy[0]}
</p>
)}
</div>
</div>
{state.errors?._form?.[0] && (
<p className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
<p
role="alert"
aria-live="polite"
className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"
>
{state.errors._form[0]}
</p>
)}
@@ -93,6 +134,7 @@ function FormField({
required,
autoComplete,
helperText,
defaultValue,
error,
}: {
id: string;
@@ -102,8 +144,11 @@ function FormField({
required?: boolean;
autoComplete?: string;
helperText?: string;
defaultValue?: string;
error?: string;
}) {
const errorId = error ? `${id}-error` : undefined;
const helperId = !error && helperText ? `${id}-helper` : undefined;
return (
<div className="space-y-1.5">
<Label htmlFor={id}>{label}</Label>
@@ -113,12 +158,23 @@ function FormField({
type={type}
required={required}
autoComplete={autoComplete}
defaultValue={defaultValue}
aria-invalid={!!error}
aria-describedby={errorId ?? helperId}
/>
{error ? (
<p className="text-xs text-destructive">{error}</p>
<p
id={errorId}
role="alert"
aria-live="polite"
className="text-xs text-destructive"
>
{error}
</p>
) : helperText ? (
<p className="text-xs text-muted-foreground">{helperText}</p>
<p id={helperId} className="text-xs text-muted-foreground">
{helperText}
</p>
) : null}
</div>
);
+1 -1
View File
@@ -24,7 +24,7 @@ const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDiv
CardHeader.displayName = "CardHeader";
type CardTitleProps = React.HTMLAttributes<HTMLElement> & {
as?: "div" | "h2" | "h3" | "h4" | "h5" | "h6";
as?: "div" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
};
const CardTitle = React.forwardRef<HTMLElement, CardTitleProps>(
+14 -8
View File
@@ -11,6 +11,8 @@ import {
Text,
} from "@react-email/components";
import { fontStack } from "./_styles";
type AlertEmailProps = {
date: string;
childName: string;
@@ -25,26 +27,26 @@ export function AlertEmail({
return (
<Html lang="de">
<Head />
<Preview>Dringender Notdienst-Alarm fuer {date}</Preview>
<Preview>Dringender Notdienst-Alarm für {date}</Preview>
<Body style={styles.body}>
<Container style={styles.container}>
<Section style={styles.alertBar}>
<Text style={styles.kicker}>Dringend</Text>
<Heading style={styles.heading}>Notdienst heute bestaetigen</Heading>
<Heading style={styles.heading}>Notdienst heute bestätigen</Heading>
<Text style={styles.lead}>
Eine Fachkraft ist ausgefallen. Fuer {childName} ist ein
Eine Fachkraft ist ausgefallen. Für {childName} ist ein
Notdienst-Einsatz am {date} hinterlegt.
</Text>
</Section>
<Section style={styles.card}>
<Text style={styles.text}>
Bitte bestaetige schnell, ob du den Notdienst uebernehmen kannst,
damit die Kita den Tag verlaesslich planen kann.
Bitte bestätige schnell, ob du den Notdienst übernehmen kannst,
damit die Kita den Tag verlässlich planen kann.
</Text>
<Button href={confirmLink} style={styles.button}>
Notdienst bestaetigen
Notdienst bestätigen
</Button>
<Section style={styles.detailBox}>
@@ -75,8 +77,7 @@ const styles = {
body: {
margin: 0,
backgroundColor: "#fff7ed",
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
fontFamily: fontStack,
color: "#30170f",
},
container: {
@@ -108,12 +109,14 @@ const styles = {
fontSize: "30px",
lineHeight: "1.16",
fontWeight: 850,
fontFamily: fontStack,
},
lead: {
margin: 0,
color: "#ffe4e6",
fontSize: "16px",
lineHeight: "1.55",
fontFamily: fontStack,
},
card: {
padding: "28px",
@@ -127,6 +130,7 @@ const styles = {
color: "#44251a",
fontSize: "16px",
lineHeight: "1.65",
fontFamily: fontStack,
},
button: {
display: "inline-block",
@@ -137,6 +141,7 @@ const styles = {
fontSize: "15px",
fontWeight: 800,
textDecoration: "none",
fontFamily: fontStack,
},
detailBox: {
margin: "28px 0 0",
@@ -182,5 +187,6 @@ const styles = {
fontSize: "12px",
lineHeight: "1.5",
textAlign: "center" as const,
fontFamily: fontStack,
},
};
+11 -2
View File
@@ -13,6 +13,7 @@ import {
contactRequestTypeLabels,
type ContactRequestInput,
} from "@/lib/contact-schema";
import { fontStack } from "./_styles";
type ContactConfirmationEmailProps = ContactRequestInput;
@@ -69,14 +70,14 @@ function InfoRow({ label, value }: { label: string; value: string }) {
const body = {
margin: 0,
backgroundColor: "#f8faf8",
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
fontFamily: fontStack,
};
const container = {
margin: "0 auto",
padding: "32px 24px",
maxWidth: "620px",
fontFamily: fontStack,
};
const heading = {
@@ -84,6 +85,7 @@ const heading = {
color: "#0f172a",
fontSize: "28px",
lineHeight: "34px",
fontFamily: fontStack,
};
const intro = {
@@ -91,6 +93,7 @@ const intro = {
color: "#475569",
fontSize: "15px",
lineHeight: "24px",
fontFamily: fontStack,
};
const summaryBox = {
@@ -98,6 +101,7 @@ const summaryBox = {
borderRadius: "10px",
backgroundColor: "#ffffff",
padding: "18px",
fontFamily: fontStack,
};
const label = {
@@ -107,6 +111,7 @@ const label = {
fontWeight: 700,
letterSpacing: "0.04em",
textTransform: "uppercase" as const,
fontFamily: fontStack,
};
const row = {
@@ -114,6 +119,7 @@ const row = {
color: "#0f172a",
fontSize: "15px",
lineHeight: "22px",
fontFamily: fontStack,
};
const messageLabel = {
@@ -123,6 +129,7 @@ const messageLabel = {
fontWeight: 700,
letterSpacing: "0.04em",
textTransform: "uppercase" as const,
fontFamily: fontStack,
};
const messageText = {
@@ -131,6 +138,7 @@ const messageText = {
fontSize: "15px",
lineHeight: "24px",
whiteSpace: "pre-wrap" as const,
fontFamily: fontStack,
};
const footer = {
@@ -138,4 +146,5 @@ const footer = {
color: "#64748b",
fontSize: "13px",
lineHeight: "20px",
fontFamily: fontStack,
};
+11 -2
View File
@@ -13,6 +13,7 @@ import {
contactRequestTypeLabels,
type ContactRequestInput,
} from "@/lib/contact-schema";
import { fontStack } from "./_styles";
type ContactRequestEmailProps = ContactRequestInput;
@@ -72,14 +73,14 @@ function InfoRow({ label, value }: { label: string; value: string }) {
const body = {
margin: 0,
backgroundColor: "#f8faf8",
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
fontFamily: fontStack,
};
const container = {
margin: "0 auto",
padding: "32px 24px",
maxWidth: "620px",
fontFamily: fontStack,
};
const heading = {
@@ -87,6 +88,7 @@ const heading = {
color: "#0f172a",
fontSize: "28px",
lineHeight: "34px",
fontFamily: fontStack,
};
const intro = {
@@ -94,6 +96,7 @@ const intro = {
color: "#475569",
fontSize: "15px",
lineHeight: "24px",
fontFamily: fontStack,
};
const details = {
@@ -101,6 +104,7 @@ const details = {
borderRadius: "10px",
backgroundColor: "#ffffff",
padding: "18px",
fontFamily: fontStack,
};
const row = {
@@ -108,6 +112,7 @@ const row = {
color: "#0f172a",
fontSize: "15px",
lineHeight: "22px",
fontFamily: fontStack,
};
const messageBox = {
@@ -116,6 +121,7 @@ const messageBox = {
borderRadius: "10px",
backgroundColor: "#ffffff",
padding: "18px",
fontFamily: fontStack,
};
const label = {
@@ -125,6 +131,7 @@ const label = {
fontWeight: 700,
letterSpacing: "0.04em",
textTransform: "uppercase" as const,
fontFamily: fontStack,
};
const messageText = {
@@ -133,6 +140,7 @@ const messageText = {
fontSize: "15px",
lineHeight: "24px",
whiteSpace: "pre-wrap" as const,
fontFamily: fontStack,
};
const footer = {
@@ -140,4 +148,5 @@ const footer = {
color: "#64748b",
fontSize: "13px",
lineHeight: "20px",
fontFamily: fontStack,
};
+15 -5
View File
@@ -10,6 +10,8 @@ import {
Text,
} from "@react-email/components";
import { fontStack } from "./_styles";
type DutyReminderEmailProps = {
familyName: string;
dutyName: string;
@@ -25,15 +27,15 @@ export function DutyReminderEmail({
<Html lang="de">
<Head />
<Preview>
Erinnerung: {familyName} ist diese Woche fuer {dutyName} eingeteilt.
Erinnerung: {familyName} ist diese Woche für {dutyName} eingeteilt.
</Preview>
<Body style={styles.body}>
<Container style={styles.container}>
<Section style={styles.header}>
<Text style={styles.kicker}>Elterndienst</Text>
<Heading style={styles.heading}>Danke fuer eure Hilfe</Heading>
<Heading style={styles.heading}>Danke für eure Hilfe</Heading>
<Text style={styles.lead}>
Hallo {familyName}, diese Woche seid ihr fuer den Dienst{" "}
Hallo {familyName}, diese Woche seid ihr für den Dienst{" "}
<strong>{dutyName}</strong> eingeteilt.
</Text>
</Section>
@@ -43,7 +45,7 @@ export function DutyReminderEmail({
Zeitraum: <strong>{weekLabel}</strong>
</Text>
<Text style={styles.text}>
Danke, dass ihr mithelft, den Kita-Alltag verlaesslich zu
Danke, dass ihr mithelft, den Kita-Alltag verlässlich zu
organisieren.
</Text>
</Section>
@@ -62,7 +64,7 @@ const styles = {
body: {
margin: 0,
backgroundColor: "#f7f5ef",
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
fontFamily: fontStack,
color: "#24231f",
},
container: {
@@ -70,11 +72,13 @@ const styles = {
maxWidth: "600px",
margin: "0 auto",
padding: "32px 20px",
fontFamily: fontStack,
},
header: {
padding: "28px",
backgroundColor: "#27423a",
borderRadius: "8px 8px 0 0",
fontFamily: fontStack,
},
kicker: {
margin: "0 0 20px",
@@ -83,6 +87,7 @@ const styles = {
fontWeight: 800,
letterSpacing: "0.08em",
textTransform: "uppercase" as const,
fontFamily: fontStack,
},
heading: {
margin: "0 0 12px",
@@ -90,12 +95,14 @@ const styles = {
fontSize: "30px",
lineHeight: "1.18",
fontWeight: 800,
fontFamily: fontStack,
},
lead: {
margin: 0,
color: "#eef4eb",
fontSize: "16px",
lineHeight: "1.55",
fontFamily: fontStack,
},
card: {
padding: "28px",
@@ -103,12 +110,14 @@ const styles = {
border: "1px solid #dde3d7",
borderTop: "0",
borderRadius: "0 0 8px 8px",
fontFamily: fontStack,
},
text: {
margin: "0 0 16px",
color: "#3d423b",
fontSize: "16px",
lineHeight: "1.65",
fontFamily: fontStack,
},
hr: {
margin: "24px 0",
@@ -120,5 +129,6 @@ const styles = {
fontSize: "12px",
lineHeight: "1.5",
textAlign: "center" as const,
fontFamily: fontStack,
},
};
+30 -23
View File
@@ -11,6 +11,8 @@ import {
Text,
} from "@react-email/components";
import { baseBodyStyle, colors, fontStack } from "./_styles";
type InviteEmailProps = {
parentName: string;
kitaName: string;
@@ -25,7 +27,7 @@ export function InviteEmail({
return (
<Html lang="de">
<Head />
<Preview>Aktiviere deinen Kita-Planer Account fuer {kitaName}</Preview>
<Preview>Aktiviere deinen Kita-Planer Account für {kitaName}</Preview>
<Body style={styles.body}>
<Container style={styles.container}>
<Section style={styles.header}>
@@ -38,7 +40,7 @@ export function InviteEmail({
<Section style={styles.card}>
<Text style={styles.text}>
Ueber den folgenden Link kannst du dein Passwort setzen und deinen
Über den folgenden Link kannst du dein Passwort setzen und deinen
Account aktivieren. Danach hast du Zugriff auf die Kita-Planung,
Termine und deine Familiendaten.
</Text>
@@ -66,90 +68,95 @@ export function InviteEmail({
}
const styles = {
body: {
margin: 0,
backgroundColor: "#f6f7f2",
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
color: "#1f2a24",
},
body: baseBodyStyle,
container: {
width: "100%",
maxWidth: "600px",
margin: "0 auto",
padding: "32px 20px",
fontFamily: fontStack,
},
header: {
padding: "28px 28px 20px",
backgroundColor: "#1f3b2d",
backgroundColor: colors.brandGreen,
borderRadius: "8px 8px 0 0",
fontFamily: fontStack,
},
brand: {
margin: "0 0 28px",
color: "#d8f2bd",
color: colors.eyebrow,
fontSize: "13px",
fontWeight: 700,
letterSpacing: "0.08em",
textTransform: "uppercase" as const,
fontFamily: fontStack,
},
heading: {
margin: "0 0 12px",
color: "#ffffff",
color: colors.textHeader,
fontSize: "30px",
lineHeight: "1.18",
fontWeight: 800,
fontFamily: fontStack,
},
lead: {
margin: 0,
color: "#e7f3e6",
color: colors.lead,
fontSize: "16px",
lineHeight: "1.55",
fontFamily: fontStack,
},
card: {
padding: "28px",
backgroundColor: "#ffffff",
backgroundColor: colors.cardBg,
borderRadius: "0 0 8px 8px",
border: "1px solid #e0e5dc",
border: `1px solid ${colors.border}`,
borderTop: "0",
fontFamily: fontStack,
},
text: {
margin: "0 0 24px",
color: "#344139",
color: colors.textBody,
fontSize: "16px",
lineHeight: "1.65",
fontFamily: fontStack,
},
button: {
display: "inline-block",
padding: "14px 22px",
backgroundColor: "#f0b84b",
color: "#172119",
backgroundColor: colors.brandGreen,
color: "#ffffff",
borderRadius: "6px",
fontSize: "15px",
fontWeight: 800,
fontWeight: 700,
textDecoration: "none",
fontFamily: fontStack,
},
fallbackText: {
margin: "28px 0 8px",
color: "#66736b",
color: colors.textMuted,
fontSize: "13px",
lineHeight: "1.5",
fontFamily: fontStack,
},
linkText: {
margin: 0,
color: "#2f6b4f",
color: colors.link,
fontSize: "13px",
lineHeight: "1.5",
wordBreak: "break-all" as const,
fontFamily: fontStack,
},
hr: {
margin: "24px 0",
borderColor: "#dfe5d9",
borderColor: colors.hr,
},
footer: {
margin: 0,
color: "#7b857d",
color: colors.textFaint,
fontSize: "12px",
lineHeight: "1.5",
textAlign: "center" as const,
fontFamily: fontStack,
},
};
+14 -4
View File
@@ -11,6 +11,8 @@ import {
Text,
} from "@react-email/components";
import { fontStack } from "./_styles";
type NewsEmailProps = {
title: string;
content: string;
@@ -53,7 +55,7 @@ const styles = {
body: {
margin: 0,
backgroundColor: "#f5f3ee",
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
fontFamily: fontStack,
color: "#25231f",
},
container: {
@@ -61,11 +63,13 @@ const styles = {
maxWidth: "600px",
margin: "0 auto",
padding: "32px 20px",
fontFamily: fontStack,
},
header: {
padding: "28px",
backgroundColor: "#243b36",
borderRadius: "8px 8px 0 0",
fontFamily: fontStack,
},
kicker: {
margin: "0 0 20px",
@@ -74,6 +78,7 @@ const styles = {
fontWeight: 800,
letterSpacing: "0.08em",
textTransform: "uppercase" as const,
fontFamily: fontStack,
},
heading: {
margin: 0,
@@ -81,6 +86,7 @@ const styles = {
fontSize: "28px",
lineHeight: "1.2",
fontWeight: 800,
fontFamily: fontStack,
},
card: {
padding: "28px",
@@ -88,6 +94,7 @@ const styles = {
border: "1px solid #deded5",
borderTop: "0",
borderRadius: "0 0 8px 8px",
fontFamily: fontStack,
},
text: {
margin: "0 0 24px",
@@ -95,16 +102,18 @@ const styles = {
fontSize: "15px",
lineHeight: "1.65",
whiteSpace: "pre-wrap" as const,
fontFamily: fontStack,
},
button: {
display: "inline-block",
padding: "13px 20px",
backgroundColor: "#f0b84b",
color: "#172119",
backgroundColor: "#243b36",
color: "#ffffff",
borderRadius: "6px",
fontSize: "15px",
fontWeight: 800,
fontWeight: 700,
textDecoration: "none",
fontFamily: fontStack,
},
hr: {
margin: "24px 0",
@@ -116,5 +125,6 @@ const styles = {
fontSize: "12px",
lineHeight: "1.5",
textAlign: "center" as const,
fontFamily: fontStack,
},
};
+39
View File
@@ -0,0 +1,39 @@
// Gemeinsame Style-Tokens für alle E-Mail-Templates.
//
// Schriftart: Mail-Clients fallen unterschiedlich zurück, wenn ein Font nicht
// verfügbar ist. Manche (z.B. einige Web-Reader) wählen dann Monospace —
// deshalb erst Arial/Helvetica voranstellen, die praktisch überall existieren.
// Außerdem wird `fontFamily` auf einzelne Elemente gesetzt statt nur auf
// `<Body>`, weil Outlook & Co. die Body-CSS oft nicht erben.
export const fontStack =
'Arial, Helvetica, "Segoe UI", -apple-system, BlinkMacSystemFont, Roboto, sans-serif';
export const colors = {
brandGreen: "#1f3b2d",
brandGreenAccent: "#2f6b4f",
bgSoft: "#f6f7f2",
bgAlt: "#f8faf8",
cardBg: "#ffffff",
border: "#e0e5dc",
hr: "#dfe5d9",
textHeader: "#ffffff",
textPrimary: "#1f2a24",
textBody: "#344139",
textMuted: "#66736b",
textFaint: "#7b857d",
link: "#2f6b4f",
eyebrow: "#d8f2bd",
lead: "#e7f3e6",
};
export const baseBodyStyle = {
margin: 0,
backgroundColor: colors.bgSoft,
fontFamily: fontStack,
color: colors.textPrimary,
} as const;
export const baseTextStyle = {
fontFamily: fontStack,
color: colors.textBody,
} as const;
+24 -4
View File
@@ -14,14 +14,31 @@ import { auth } from "@/auth";
// Eingeloggt + kein kitaId + nicht /onboarding → /onboarding
// Eingeloggt + SUPERADMIN → /admin
// Eingeloggt + hat kitaId + auf /onboarding → /dashboard
// Eingeloggt + auf /login oder /register → /
// Eingeloggt + auf /login oder /register → Post-Login-Ziel
// =====================================================================
const ONBOARDING_ROUTE = "/onboarding";
const PROTECTED_PREFIX = ["/dashboard", "/admin", "/onboarding"];
const PUBLIC_PATHS = new Set(["/", "/impressum", "/datenschutz"]);
const PUBLIC_PREFIX = ["/features"];
function getPostLoginPath(user: { role?: string; kitaId?: string | null }) {
if (user.role === "SUPERADMIN") {
return "/admin";
}
return user.kitaId ? "/dashboard" : ONBOARDING_ROUTE;
}
export async function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
const isPublicRoute =
PUBLIC_PATHS.has(pathname) ||
PUBLIC_PREFIX.some((prefix) => pathname.startsWith(prefix));
if (isPublicRoute) {
return NextResponse.next();
}
// Auth.js liest den Session-JWT aus dem Cookie —
// kein DB-Call, pure Token-Verifikation (Edge-sicher).
@@ -43,10 +60,12 @@ export async function proxy(request: NextRequest) {
// ── 2. Eingeloggt ───────────────────────────────────────────────────
if (hasValidUser && user) {
// 2a. Eingeloggter User auf Login/Register-Seite → Startseite,
// die dann selbst zu /dashboard oder /onboarding redirectet.
// 2a. Eingeloggter User auf Login/Register-Seite → direkt zum Ziel,
// damit die öffentliche Startseite stabil indexierbar bleibt.
if (pathname === "/login" || pathname === "/register") {
return NextResponse.redirect(new URL("/", request.nextUrl));
return NextResponse.redirect(
new URL(getPostLoginPath(user), request.nextUrl),
);
}
if (user.role === "SUPERADMIN" && pathname === ONBOARDING_ROUTE) {
@@ -58,6 +77,7 @@ export async function proxy(request: NextRequest) {
if (
!user.kitaId &&
user.role !== "SUPERADMIN" &&
isProtectedRoute &&
!pathname.startsWith(ONBOARDING_ROUTE)
) {
return NextResponse.redirect(new URL(ONBOARDING_ROUTE, request.nextUrl));