Merge claude/profil-and-mail-fixes into main

Fix mail layouts, /invite form-reset, profile validation
This commit is contained in:
t.indorf
2026-05-17 23:54:07 +02:00
11 changed files with 304 additions and 69 deletions
+1 -1
View File
@@ -124,7 +124,7 @@ export default async function AdressbuchPage() {
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<h1 className="text-2xl font-bold tracking-tight">Adressbuch</h1> <h1 className="text-2xl font-bold tracking-tight">Adressbuch</h1>
<p className="max-w-2xl text-sm text-muted-foreground"> <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. Kita-Adressbuch freigegeben wurden.
</p> </p>
</div> </div>
+76 -10
View File
@@ -6,20 +6,76 @@ import { z } from "zod";
import { requireKitaSession } from "@/lib/auth-utils"; import { requireKitaSession } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
const childSchema = z.object({ // Telefonnummern: führendes "+" optional, dann Ziffern, Leerzeichen, Bindestriche,
firstName: z.string().min(1, "Vorname ist erforderlich.").max(100).trim(), // Klammern oder Schrägstriche. Mindestens 6 Ziffern damit es plausibel ist.
lastName: z.string().min(1, "Nachname ist erforderlich.").max(100).trim(), // Schließt damit "abc-123-not-a-phone" aus, lässt aber internationale Formate zu.
dateOfBirth: z.string().optional(), 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({ const familySchema = z.object({
familyName: z.string().min(1, "Familienname ist erforderlich.").max(120).trim(), familyName: z.string().min(1, "Familienname ist erforderlich.").max(120).trim(),
}); });
const contactSchema = z.object({ 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(), 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(), city: z.string().trim().max(100).optional(),
}); });
@@ -30,6 +86,16 @@ function parseDateInput(value?: string) {
return Number.isNaN(date.getTime()) ? null : date; 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( async function requireOwnFamilyChild(
childId: string, childId: string,
familyId: string | null, familyId: string | null,
@@ -51,7 +117,7 @@ export async function createMyChild(rawPayload: unknown) {
} }
if (!parsed.success) { if (!parsed.success) {
return { error: "Ungültige Eingabedaten." }; return { error: firstZodMessage(parsed.error, "Ungültige Eingabedaten.") };
} }
try { try {
@@ -117,7 +183,7 @@ export async function updateMyContact(rawPayload: unknown) {
const parsed = contactSchema.safeParse(rawPayload); const parsed = contactSchema.safeParse(rawPayload);
if (!parsed.success) { if (!parsed.success) {
return { error: "Ungültige Kontaktdaten." }; return { error: firstZodMessage(parsed.error, "Ungültige Kontaktdaten.") };
} }
try { try {
@@ -146,7 +212,7 @@ export async function updateMyChild(childId: string, rawPayload: unknown) {
const parsed = childSchema.safeParse(rawPayload); const parsed = childSchema.safeParse(rawPayload);
if (!parsed.success) { if (!parsed.success) {
return { error: "Ungültige Eingabedaten." }; return { error: firstZodMessage(parsed.error, "Ungültige Eingabedaten.") };
} }
try { try {
+39 -5
View File
@@ -34,6 +34,16 @@ const inviteSchema = z
const PRIVACY_POLICY_VERSION = "2026-05-01"; const PRIVACY_POLICY_VERSION = "2026-05-01";
export type InviteState = { 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?: { errors?: {
password?: string[]; password?: string[];
confirmPassword?: 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( export async function acceptInviteAction(
_prev: InviteState, prev: InviteState,
formData: FormData, formData: FormData,
): Promise<InviteState> { ): Promise<InviteState> {
const attempt = prev.attempt + 1;
const values = echoCheckboxValues(formData);
const parsed = inviteSchema.safeParse({ const parsed = inviteSchema.safeParse({
token: formData.get("token"), token: formData.get("token"),
password: formData.get("password"), password: formData.get("password"),
@@ -56,7 +78,7 @@ export async function acceptInviteAction(
}); });
if (!parsed.success) { if (!parsed.success) {
return { errors: parsed.error.flatten().fieldErrors }; return { attempt, values, errors: parsed.error.flatten().fieldErrors };
} }
const { token, password, directoryOptIn } = parsed.data; const { token, password, directoryOptIn } = parsed.data;
@@ -68,10 +90,16 @@ export async function acceptInviteAction(
}); });
if (!verificationToken) { if (!verificationToken) {
return { errors: { _form: ["Einladungslink ist ungültig."] } }; return {
attempt,
values,
errors: { _form: ["Einladungslink ist ungültig."] },
};
} }
if (verificationToken.expires < now) { if (verificationToken.expires < now) {
return { return {
attempt,
values,
errors: { errors: {
_form: [ _form: [
"Dieser Einladungslink ist abgelaufen. Bitte wende dich an deinen Administrator.", "Dieser Einladungslink ist abgelaufen. Bitte wende dich an deinen Administrator.",
@@ -89,11 +117,17 @@ export async function acceptInviteAction(
}); });
if (!user) { if (!user) {
return { errors: { _form: ["Benutzer nicht gefunden."] } }; return {
attempt,
values,
errors: { _form: ["Benutzer nicht gefunden."] },
};
} }
if (user.passwordHash !== "") { if (user.passwordHash !== "") {
// Invite wurde bereits eingelöst // Invite wurde bereits eingelöst
return { return {
attempt,
values,
errors: { errors: {
_form: [ _form: [
"Dieser Einladungslink wurde bereits verwendet. Bitte melde dich an.", "Dieser Einladungslink wurde bereits verwendet. Bitte melde dich an.",
@@ -131,5 +165,5 @@ export async function acceptInviteAction(
}); });
// Unreachable signIn redirected. // 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 { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { acceptInviteAction, type InviteState } from "./actions"; import { acceptInviteAction, initialInviteState } from "./actions";
const initialState: InviteState = {};
// ===================================================================== // =====================================================================
// InviteForm · Client Component // InviteForm · Client Component
@@ -27,9 +25,14 @@ export function InviteForm({
}) { }) {
const [state, formAction, pending] = useActionState( const [state, formAction, pending] = useActionState(
acceptInviteAction, 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 ( return (
<form action={formAction} className="space-y-5"> <form action={formAction} className="space-y-5">
{/* Token als verstecktes Feld */} {/* Token als verstecktes Feld */}
@@ -45,17 +48,30 @@ export function InviteForm({
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="password">Passwort wählen</Label> <Label htmlFor="password">Passwort wählen</Label>
<Input <Input
key={`password-${attemptKey}`}
id="password" id="password"
name="password" name="password"
type="password" type="password"
autoComplete="new-password" autoComplete="new-password"
required required
aria-invalid={!!state.errors?.password} aria-invalid={!!state.errors?.password}
aria-describedby={
state.errors?.password ? "password-error" : "password-helper"
}
/> />
{state.errors?.password?.[0] ? ( {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> </div>
@@ -63,15 +79,26 @@ export function InviteForm({
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="confirmPassword">Passwort bestätigen</Label> <Label htmlFor="confirmPassword">Passwort bestätigen</Label>
<Input <Input
key={`confirmPassword-${attemptKey}`}
id="confirmPassword" id="confirmPassword"
name="confirmPassword" name="confirmPassword"
type="password" type="password"
autoComplete="new-password" autoComplete="new-password"
required required
aria-invalid={!!state.errors?.confirmPassword} aria-invalid={!!state.errors?.confirmPassword}
aria-describedby={
state.errors?.confirmPassword
? "confirmPassword-error"
: undefined
}
/> />
{state.errors?.confirmPassword?.[0] && ( {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]} {state.errors.confirmPassword[0]}
</p> </p>
)} )}
@@ -85,10 +112,17 @@ export function InviteForm({
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<Checkbox <Checkbox
key={`acceptPrivacyPolicy-${attemptKey}`}
id="acceptPrivacyPolicy" id="acceptPrivacyPolicy"
name="acceptPrivacyPolicy" name="acceptPrivacyPolicy"
required required
defaultChecked={state.values?.acceptPrivacyPolicy === true}
aria-invalid={!!state.errors?.acceptPrivacyPolicy} aria-invalid={!!state.errors?.acceptPrivacyPolicy}
aria-describedby={
state.errors?.acceptPrivacyPolicy
? "acceptPrivacyPolicy-error"
: undefined
}
/> />
<div className="space-y-0.5"> <div className="space-y-0.5">
<Label htmlFor="acceptPrivacyPolicy" className="text-sm font-normal"> <Label htmlFor="acceptPrivacyPolicy" className="text-sm font-normal">
@@ -104,7 +138,12 @@ export function InviteForm({
gelesen und akzeptiere sie. <span className="text-destructive">*</span> gelesen und akzeptiere sie. <span className="text-destructive">*</span>
</Label> </Label>
{state.errors?.acceptPrivacyPolicy?.[0] && ( {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]} {state.errors.acceptPrivacyPolicy[0]}
</p> </p>
)} )}
@@ -113,8 +152,10 @@ export function InviteForm({
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<Checkbox <Checkbox
key={`directoryOptIn-${attemptKey}`}
id="directoryOptIn" id="directoryOptIn"
name="directoryOptIn" name="directoryOptIn"
defaultChecked={state.values?.directoryOptIn === true}
aria-invalid={!!state.errors?.directoryOptIn} aria-invalid={!!state.errors?.directoryOptIn}
/> />
<div className="space-y-0.5"> <div className="space-y-0.5">
@@ -128,7 +169,11 @@ export function InviteForm({
{/* Globaler Fehler */} {/* Globaler Fehler */}
{state.errors?._form?.[0] && ( {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]} {state.errors._form[0]}
</p> </p>
)} )}
+14 -8
View File
@@ -11,6 +11,8 @@ import {
Text, Text,
} from "@react-email/components"; } from "@react-email/components";
import { fontStack } from "./_styles";
type AlertEmailProps = { type AlertEmailProps = {
date: string; date: string;
childName: string; childName: string;
@@ -25,26 +27,26 @@ export function AlertEmail({
return ( return (
<Html lang="de"> <Html lang="de">
<Head /> <Head />
<Preview>Dringender Notdienst-Alarm fuer {date}</Preview> <Preview>Dringender Notdienst-Alarm für {date}</Preview>
<Body style={styles.body}> <Body style={styles.body}>
<Container style={styles.container}> <Container style={styles.container}>
<Section style={styles.alertBar}> <Section style={styles.alertBar}>
<Text style={styles.kicker}>Dringend</Text> <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}> <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. Notdienst-Einsatz am {date} hinterlegt.
</Text> </Text>
</Section> </Section>
<Section style={styles.card}> <Section style={styles.card}>
<Text style={styles.text}> <Text style={styles.text}>
Bitte bestaetige schnell, ob du den Notdienst uebernehmen kannst, Bitte bestätige schnell, ob du den Notdienst übernehmen kannst,
damit die Kita den Tag verlaesslich planen kann. damit die Kita den Tag verlässlich planen kann.
</Text> </Text>
<Button href={confirmLink} style={styles.button}> <Button href={confirmLink} style={styles.button}>
Notdienst bestaetigen Notdienst bestätigen
</Button> </Button>
<Section style={styles.detailBox}> <Section style={styles.detailBox}>
@@ -75,8 +77,7 @@ const styles = {
body: { body: {
margin: 0, margin: 0,
backgroundColor: "#fff7ed", backgroundColor: "#fff7ed",
fontFamily: fontFamily: fontStack,
'-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
color: "#30170f", color: "#30170f",
}, },
container: { container: {
@@ -108,12 +109,14 @@ const styles = {
fontSize: "30px", fontSize: "30px",
lineHeight: "1.16", lineHeight: "1.16",
fontWeight: 850, fontWeight: 850,
fontFamily: fontStack,
}, },
lead: { lead: {
margin: 0, margin: 0,
color: "#ffe4e6", color: "#ffe4e6",
fontSize: "16px", fontSize: "16px",
lineHeight: "1.55", lineHeight: "1.55",
fontFamily: fontStack,
}, },
card: { card: {
padding: "28px", padding: "28px",
@@ -127,6 +130,7 @@ const styles = {
color: "#44251a", color: "#44251a",
fontSize: "16px", fontSize: "16px",
lineHeight: "1.65", lineHeight: "1.65",
fontFamily: fontStack,
}, },
button: { button: {
display: "inline-block", display: "inline-block",
@@ -137,6 +141,7 @@ const styles = {
fontSize: "15px", fontSize: "15px",
fontWeight: 800, fontWeight: 800,
textDecoration: "none", textDecoration: "none",
fontFamily: fontStack,
}, },
detailBox: { detailBox: {
margin: "28px 0 0", margin: "28px 0 0",
@@ -182,5 +187,6 @@ const styles = {
fontSize: "12px", fontSize: "12px",
lineHeight: "1.5", lineHeight: "1.5",
textAlign: "center" as const, textAlign: "center" as const,
fontFamily: fontStack,
}, },
}; };
+11 -2
View File
@@ -13,6 +13,7 @@ import {
contactRequestTypeLabels, contactRequestTypeLabels,
type ContactRequestInput, type ContactRequestInput,
} from "@/lib/contact-schema"; } from "@/lib/contact-schema";
import { fontStack } from "./_styles";
type ContactConfirmationEmailProps = ContactRequestInput; type ContactConfirmationEmailProps = ContactRequestInput;
@@ -69,14 +70,14 @@ function InfoRow({ label, value }: { label: string; value: string }) {
const body = { const body = {
margin: 0, margin: 0,
backgroundColor: "#f8faf8", backgroundColor: "#f8faf8",
fontFamily: fontFamily: fontStack,
'-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
}; };
const container = { const container = {
margin: "0 auto", margin: "0 auto",
padding: "32px 24px", padding: "32px 24px",
maxWidth: "620px", maxWidth: "620px",
fontFamily: fontStack,
}; };
const heading = { const heading = {
@@ -84,6 +85,7 @@ const heading = {
color: "#0f172a", color: "#0f172a",
fontSize: "28px", fontSize: "28px",
lineHeight: "34px", lineHeight: "34px",
fontFamily: fontStack,
}; };
const intro = { const intro = {
@@ -91,6 +93,7 @@ const intro = {
color: "#475569", color: "#475569",
fontSize: "15px", fontSize: "15px",
lineHeight: "24px", lineHeight: "24px",
fontFamily: fontStack,
}; };
const summaryBox = { const summaryBox = {
@@ -98,6 +101,7 @@ const summaryBox = {
borderRadius: "10px", borderRadius: "10px",
backgroundColor: "#ffffff", backgroundColor: "#ffffff",
padding: "18px", padding: "18px",
fontFamily: fontStack,
}; };
const label = { const label = {
@@ -107,6 +111,7 @@ const label = {
fontWeight: 700, fontWeight: 700,
letterSpacing: "0.04em", letterSpacing: "0.04em",
textTransform: "uppercase" as const, textTransform: "uppercase" as const,
fontFamily: fontStack,
}; };
const row = { const row = {
@@ -114,6 +119,7 @@ const row = {
color: "#0f172a", color: "#0f172a",
fontSize: "15px", fontSize: "15px",
lineHeight: "22px", lineHeight: "22px",
fontFamily: fontStack,
}; };
const messageLabel = { const messageLabel = {
@@ -123,6 +129,7 @@ const messageLabel = {
fontWeight: 700, fontWeight: 700,
letterSpacing: "0.04em", letterSpacing: "0.04em",
textTransform: "uppercase" as const, textTransform: "uppercase" as const,
fontFamily: fontStack,
}; };
const messageText = { const messageText = {
@@ -131,6 +138,7 @@ const messageText = {
fontSize: "15px", fontSize: "15px",
lineHeight: "24px", lineHeight: "24px",
whiteSpace: "pre-wrap" as const, whiteSpace: "pre-wrap" as const,
fontFamily: fontStack,
}; };
const footer = { const footer = {
@@ -138,4 +146,5 @@ const footer = {
color: "#64748b", color: "#64748b",
fontSize: "13px", fontSize: "13px",
lineHeight: "20px", lineHeight: "20px",
fontFamily: fontStack,
}; };
+11 -2
View File
@@ -13,6 +13,7 @@ import {
contactRequestTypeLabels, contactRequestTypeLabels,
type ContactRequestInput, type ContactRequestInput,
} from "@/lib/contact-schema"; } from "@/lib/contact-schema";
import { fontStack } from "./_styles";
type ContactRequestEmailProps = ContactRequestInput; type ContactRequestEmailProps = ContactRequestInput;
@@ -72,14 +73,14 @@ function InfoRow({ label, value }: { label: string; value: string }) {
const body = { const body = {
margin: 0, margin: 0,
backgroundColor: "#f8faf8", backgroundColor: "#f8faf8",
fontFamily: fontFamily: fontStack,
'-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
}; };
const container = { const container = {
margin: "0 auto", margin: "0 auto",
padding: "32px 24px", padding: "32px 24px",
maxWidth: "620px", maxWidth: "620px",
fontFamily: fontStack,
}; };
const heading = { const heading = {
@@ -87,6 +88,7 @@ const heading = {
color: "#0f172a", color: "#0f172a",
fontSize: "28px", fontSize: "28px",
lineHeight: "34px", lineHeight: "34px",
fontFamily: fontStack,
}; };
const intro = { const intro = {
@@ -94,6 +96,7 @@ const intro = {
color: "#475569", color: "#475569",
fontSize: "15px", fontSize: "15px",
lineHeight: "24px", lineHeight: "24px",
fontFamily: fontStack,
}; };
const details = { const details = {
@@ -101,6 +104,7 @@ const details = {
borderRadius: "10px", borderRadius: "10px",
backgroundColor: "#ffffff", backgroundColor: "#ffffff",
padding: "18px", padding: "18px",
fontFamily: fontStack,
}; };
const row = { const row = {
@@ -108,6 +112,7 @@ const row = {
color: "#0f172a", color: "#0f172a",
fontSize: "15px", fontSize: "15px",
lineHeight: "22px", lineHeight: "22px",
fontFamily: fontStack,
}; };
const messageBox = { const messageBox = {
@@ -116,6 +121,7 @@ const messageBox = {
borderRadius: "10px", borderRadius: "10px",
backgroundColor: "#ffffff", backgroundColor: "#ffffff",
padding: "18px", padding: "18px",
fontFamily: fontStack,
}; };
const label = { const label = {
@@ -125,6 +131,7 @@ const label = {
fontWeight: 700, fontWeight: 700,
letterSpacing: "0.04em", letterSpacing: "0.04em",
textTransform: "uppercase" as const, textTransform: "uppercase" as const,
fontFamily: fontStack,
}; };
const messageText = { const messageText = {
@@ -133,6 +140,7 @@ const messageText = {
fontSize: "15px", fontSize: "15px",
lineHeight: "24px", lineHeight: "24px",
whiteSpace: "pre-wrap" as const, whiteSpace: "pre-wrap" as const,
fontFamily: fontStack,
}; };
const footer = { const footer = {
@@ -140,4 +148,5 @@ const footer = {
color: "#64748b", color: "#64748b",
fontSize: "13px", fontSize: "13px",
lineHeight: "20px", lineHeight: "20px",
fontFamily: fontStack,
}; };
+15 -5
View File
@@ -10,6 +10,8 @@ import {
Text, Text,
} from "@react-email/components"; } from "@react-email/components";
import { fontStack } from "./_styles";
type DutyReminderEmailProps = { type DutyReminderEmailProps = {
familyName: string; familyName: string;
dutyName: string; dutyName: string;
@@ -25,15 +27,15 @@ export function DutyReminderEmail({
<Html lang="de"> <Html lang="de">
<Head /> <Head />
<Preview> <Preview>
Erinnerung: {familyName} ist diese Woche fuer {dutyName} eingeteilt. Erinnerung: {familyName} ist diese Woche für {dutyName} eingeteilt.
</Preview> </Preview>
<Body style={styles.body}> <Body style={styles.body}>
<Container style={styles.container}> <Container style={styles.container}>
<Section style={styles.header}> <Section style={styles.header}>
<Text style={styles.kicker}>Elterndienst</Text> <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}> <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. <strong>{dutyName}</strong> eingeteilt.
</Text> </Text>
</Section> </Section>
@@ -43,7 +45,7 @@ export function DutyReminderEmail({
Zeitraum: <strong>{weekLabel}</strong> Zeitraum: <strong>{weekLabel}</strong>
</Text> </Text>
<Text style={styles.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. organisieren.
</Text> </Text>
</Section> </Section>
@@ -62,7 +64,7 @@ const styles = {
body: { body: {
margin: 0, margin: 0,
backgroundColor: "#f7f5ef", backgroundColor: "#f7f5ef",
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', fontFamily: fontStack,
color: "#24231f", color: "#24231f",
}, },
container: { container: {
@@ -70,11 +72,13 @@ const styles = {
maxWidth: "600px", maxWidth: "600px",
margin: "0 auto", margin: "0 auto",
padding: "32px 20px", padding: "32px 20px",
fontFamily: fontStack,
}, },
header: { header: {
padding: "28px", padding: "28px",
backgroundColor: "#27423a", backgroundColor: "#27423a",
borderRadius: "8px 8px 0 0", borderRadius: "8px 8px 0 0",
fontFamily: fontStack,
}, },
kicker: { kicker: {
margin: "0 0 20px", margin: "0 0 20px",
@@ -83,6 +87,7 @@ const styles = {
fontWeight: 800, fontWeight: 800,
letterSpacing: "0.08em", letterSpacing: "0.08em",
textTransform: "uppercase" as const, textTransform: "uppercase" as const,
fontFamily: fontStack,
}, },
heading: { heading: {
margin: "0 0 12px", margin: "0 0 12px",
@@ -90,12 +95,14 @@ const styles = {
fontSize: "30px", fontSize: "30px",
lineHeight: "1.18", lineHeight: "1.18",
fontWeight: 800, fontWeight: 800,
fontFamily: fontStack,
}, },
lead: { lead: {
margin: 0, margin: 0,
color: "#eef4eb", color: "#eef4eb",
fontSize: "16px", fontSize: "16px",
lineHeight: "1.55", lineHeight: "1.55",
fontFamily: fontStack,
}, },
card: { card: {
padding: "28px", padding: "28px",
@@ -103,12 +110,14 @@ const styles = {
border: "1px solid #dde3d7", border: "1px solid #dde3d7",
borderTop: "0", borderTop: "0",
borderRadius: "0 0 8px 8px", borderRadius: "0 0 8px 8px",
fontFamily: fontStack,
}, },
text: { text: {
margin: "0 0 16px", margin: "0 0 16px",
color: "#3d423b", color: "#3d423b",
fontSize: "16px", fontSize: "16px",
lineHeight: "1.65", lineHeight: "1.65",
fontFamily: fontStack,
}, },
hr: { hr: {
margin: "24px 0", margin: "24px 0",
@@ -120,5 +129,6 @@ const styles = {
fontSize: "12px", fontSize: "12px",
lineHeight: "1.5", lineHeight: "1.5",
textAlign: "center" as const, textAlign: "center" as const,
fontFamily: fontStack,
}, },
}; };
+30 -23
View File
@@ -11,6 +11,8 @@ import {
Text, Text,
} from "@react-email/components"; } from "@react-email/components";
import { baseBodyStyle, colors, fontStack } from "./_styles";
type InviteEmailProps = { type InviteEmailProps = {
parentName: string; parentName: string;
kitaName: string; kitaName: string;
@@ -25,7 +27,7 @@ export function InviteEmail({
return ( return (
<Html lang="de"> <Html lang="de">
<Head /> <Head />
<Preview>Aktiviere deinen Kita-Planer Account fuer {kitaName}</Preview> <Preview>Aktiviere deinen Kita-Planer Account für {kitaName}</Preview>
<Body style={styles.body}> <Body style={styles.body}>
<Container style={styles.container}> <Container style={styles.container}>
<Section style={styles.header}> <Section style={styles.header}>
@@ -38,7 +40,7 @@ export function InviteEmail({
<Section style={styles.card}> <Section style={styles.card}>
<Text style={styles.text}> <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, Account aktivieren. Danach hast du Zugriff auf die Kita-Planung,
Termine und deine Familiendaten. Termine und deine Familiendaten.
</Text> </Text>
@@ -66,90 +68,95 @@ export function InviteEmail({
} }
const styles = { const styles = {
body: { body: baseBodyStyle,
margin: 0,
backgroundColor: "#f6f7f2",
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
color: "#1f2a24",
},
container: { container: {
width: "100%", width: "100%",
maxWidth: "600px", maxWidth: "600px",
margin: "0 auto", margin: "0 auto",
padding: "32px 20px", padding: "32px 20px",
fontFamily: fontStack,
}, },
header: { header: {
padding: "28px 28px 20px", padding: "28px 28px 20px",
backgroundColor: "#1f3b2d", backgroundColor: colors.brandGreen,
borderRadius: "8px 8px 0 0", borderRadius: "8px 8px 0 0",
fontFamily: fontStack,
}, },
brand: { brand: {
margin: "0 0 28px", margin: "0 0 28px",
color: "#d8f2bd", color: colors.eyebrow,
fontSize: "13px", fontSize: "13px",
fontWeight: 700, fontWeight: 700,
letterSpacing: "0.08em", letterSpacing: "0.08em",
textTransform: "uppercase" as const, textTransform: "uppercase" as const,
fontFamily: fontStack,
}, },
heading: { heading: {
margin: "0 0 12px", margin: "0 0 12px",
color: "#ffffff", color: colors.textHeader,
fontSize: "30px", fontSize: "30px",
lineHeight: "1.18", lineHeight: "1.18",
fontWeight: 800, fontWeight: 800,
fontFamily: fontStack,
}, },
lead: { lead: {
margin: 0, margin: 0,
color: "#e7f3e6", color: colors.lead,
fontSize: "16px", fontSize: "16px",
lineHeight: "1.55", lineHeight: "1.55",
fontFamily: fontStack,
}, },
card: { card: {
padding: "28px", padding: "28px",
backgroundColor: "#ffffff", backgroundColor: colors.cardBg,
borderRadius: "0 0 8px 8px", borderRadius: "0 0 8px 8px",
border: "1px solid #e0e5dc", border: `1px solid ${colors.border}`,
borderTop: "0", borderTop: "0",
fontFamily: fontStack,
}, },
text: { text: {
margin: "0 0 24px", margin: "0 0 24px",
color: "#344139", color: colors.textBody,
fontSize: "16px", fontSize: "16px",
lineHeight: "1.65", lineHeight: "1.65",
fontFamily: fontStack,
}, },
button: { button: {
display: "inline-block", display: "inline-block",
padding: "14px 22px", padding: "14px 22px",
backgroundColor: "#f0b84b", backgroundColor: colors.brandGreen,
color: "#172119", color: "#ffffff",
borderRadius: "6px", borderRadius: "6px",
fontSize: "15px", fontSize: "15px",
fontWeight: 800, fontWeight: 700,
textDecoration: "none", textDecoration: "none",
fontFamily: fontStack,
}, },
fallbackText: { fallbackText: {
margin: "28px 0 8px", margin: "28px 0 8px",
color: "#66736b", color: colors.textMuted,
fontSize: "13px", fontSize: "13px",
lineHeight: "1.5", lineHeight: "1.5",
fontFamily: fontStack,
}, },
linkText: { linkText: {
margin: 0, margin: 0,
color: "#2f6b4f", color: colors.link,
fontSize: "13px", fontSize: "13px",
lineHeight: "1.5", lineHeight: "1.5",
wordBreak: "break-all" as const, wordBreak: "break-all" as const,
fontFamily: fontStack,
}, },
hr: { hr: {
margin: "24px 0", margin: "24px 0",
borderColor: "#dfe5d9", borderColor: colors.hr,
}, },
footer: { footer: {
margin: 0, margin: 0,
color: "#7b857d", color: colors.textFaint,
fontSize: "12px", fontSize: "12px",
lineHeight: "1.5", lineHeight: "1.5",
textAlign: "center" as const, textAlign: "center" as const,
fontFamily: fontStack,
}, },
}; };
+14 -4
View File
@@ -11,6 +11,8 @@ import {
Text, Text,
} from "@react-email/components"; } from "@react-email/components";
import { fontStack } from "./_styles";
type NewsEmailProps = { type NewsEmailProps = {
title: string; title: string;
content: string; content: string;
@@ -53,7 +55,7 @@ const styles = {
body: { body: {
margin: 0, margin: 0,
backgroundColor: "#f5f3ee", backgroundColor: "#f5f3ee",
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', fontFamily: fontStack,
color: "#25231f", color: "#25231f",
}, },
container: { container: {
@@ -61,11 +63,13 @@ const styles = {
maxWidth: "600px", maxWidth: "600px",
margin: "0 auto", margin: "0 auto",
padding: "32px 20px", padding: "32px 20px",
fontFamily: fontStack,
}, },
header: { header: {
padding: "28px", padding: "28px",
backgroundColor: "#243b36", backgroundColor: "#243b36",
borderRadius: "8px 8px 0 0", borderRadius: "8px 8px 0 0",
fontFamily: fontStack,
}, },
kicker: { kicker: {
margin: "0 0 20px", margin: "0 0 20px",
@@ -74,6 +78,7 @@ const styles = {
fontWeight: 800, fontWeight: 800,
letterSpacing: "0.08em", letterSpacing: "0.08em",
textTransform: "uppercase" as const, textTransform: "uppercase" as const,
fontFamily: fontStack,
}, },
heading: { heading: {
margin: 0, margin: 0,
@@ -81,6 +86,7 @@ const styles = {
fontSize: "28px", fontSize: "28px",
lineHeight: "1.2", lineHeight: "1.2",
fontWeight: 800, fontWeight: 800,
fontFamily: fontStack,
}, },
card: { card: {
padding: "28px", padding: "28px",
@@ -88,6 +94,7 @@ const styles = {
border: "1px solid #deded5", border: "1px solid #deded5",
borderTop: "0", borderTop: "0",
borderRadius: "0 0 8px 8px", borderRadius: "0 0 8px 8px",
fontFamily: fontStack,
}, },
text: { text: {
margin: "0 0 24px", margin: "0 0 24px",
@@ -95,16 +102,18 @@ const styles = {
fontSize: "15px", fontSize: "15px",
lineHeight: "1.65", lineHeight: "1.65",
whiteSpace: "pre-wrap" as const, whiteSpace: "pre-wrap" as const,
fontFamily: fontStack,
}, },
button: { button: {
display: "inline-block", display: "inline-block",
padding: "13px 20px", padding: "13px 20px",
backgroundColor: "#f0b84b", backgroundColor: "#243b36",
color: "#172119", color: "#ffffff",
borderRadius: "6px", borderRadius: "6px",
fontSize: "15px", fontSize: "15px",
fontWeight: 800, fontWeight: 700,
textDecoration: "none", textDecoration: "none",
fontFamily: fontStack,
}, },
hr: { hr: {
margin: "24px 0", margin: "24px 0",
@@ -116,5 +125,6 @@ const styles = {
fontSize: "12px", fontSize: "12px",
lineHeight: "1.5", lineHeight: "1.5",
textAlign: "center" as const, 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;