diff --git a/src/app/dashboard/adressbuch/page.tsx b/src/app/dashboard/adressbuch/page.tsx index 3b79a5d..56998a7 100644 --- a/src/app/dashboard/adressbuch/page.tsx +++ b/src/app/dashboard/adressbuch/page.tsx @@ -124,7 +124,7 @@ export default async function AdressbuchPage() {

Adressbuch

- Haushalte und Kontaktdaten, die explizit fuer das interne + Haushalte und Kontaktdaten, die explizit für das interne Kita-Adressbuch freigegeben wurden.

diff --git a/src/app/dashboard/profil/actions.ts b/src/app/dashboard/profil/actions.ts index 42aad51..31e2dda 100644 --- a/src/app/dashboard/profil/actions.ts +++ b/src/app/dashboard/profil/actions.ts @@ -6,20 +6,76 @@ import { z } from "zod"; import { requireKitaSession } from "@/lib/auth-utils"; import { prisma } from "@/lib/prisma"; -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(), -}); +// 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, 3–10 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 { diff --git a/src/app/invite/[token]/actions.ts b/src/app/invite/[token]/actions.ts index 0eed9aa..07cf879 100644 --- a/src/app/invite/[token]/actions.ts +++ b/src/app/invite/[token]/actions.ts @@ -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
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 { + 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 }; } diff --git a/src/app/invite/[token]/invite-form.tsx b/src/app/invite/[token]/invite-form.tsx index b115cc5..d27b6bc 100644 --- a/src/app/invite/[token]/invite-form.tsx +++ b/src/app/invite/[token]/invite-form.tsx @@ -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 ( {/* Token als verstecktes Feld */} @@ -45,17 +48,30 @@ export function InviteForm({
{state.errors?.password?.[0] ? ( -

{state.errors.password[0]}

+ ) : ( -

Mindestens 8 Zeichen.

+

+ Mindestens 8 Zeichen. +

)}
@@ -63,15 +79,26 @@ export function InviteForm({
{state.errors?.confirmPassword?.[0] && ( -

+

)} @@ -85,10 +112,17 @@ export function InviteForm({
{state.errors?.acceptPrivacyPolicy?.[0] && ( -

+

)} @@ -113,8 +152,10 @@ export function InviteForm({
@@ -128,7 +169,11 @@ export function InviteForm({ {/* Globaler Fehler */} {state.errors?._form?.[0] && ( -

+

{state.errors._form[0]}

)} diff --git a/src/emails/AlertEmail.tsx b/src/emails/AlertEmail.tsx index 69aa99f..6b0c3c1 100644 --- a/src/emails/AlertEmail.tsx +++ b/src/emails/AlertEmail.tsx @@ -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 ( - Dringender Notdienst-Alarm fuer {date} + Dringender Notdienst-Alarm für {date}
Dringend - Notdienst heute bestaetigen + Notdienst heute bestätigen - Eine Fachkraft ist ausgefallen. Fuer {childName} ist ein + Eine Fachkraft ist ausgefallen. Für {childName} ist ein Notdienst-Einsatz am {date} hinterlegt.
- 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.
@@ -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, }, }; diff --git a/src/emails/ContactConfirmationEmail.tsx b/src/emails/ContactConfirmationEmail.tsx index dc22158..7e78434 100644 --- a/src/emails/ContactConfirmationEmail.tsx +++ b/src/emails/ContactConfirmationEmail.tsx @@ -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, }; diff --git a/src/emails/ContactRequestEmail.tsx b/src/emails/ContactRequestEmail.tsx index af5b94a..ae21c90 100644 --- a/src/emails/ContactRequestEmail.tsx +++ b/src/emails/ContactRequestEmail.tsx @@ -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, }; diff --git a/src/emails/DutyReminderEmail.tsx b/src/emails/DutyReminderEmail.tsx index ed9128f..4983e83 100644 --- a/src/emails/DutyReminderEmail.tsx +++ b/src/emails/DutyReminderEmail.tsx @@ -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({ - Erinnerung: {familyName} ist diese Woche fuer {dutyName} eingeteilt. + Erinnerung: {familyName} ist diese Woche für {dutyName} eingeteilt.
Elterndienst - Danke fuer eure Hilfe + Danke für eure Hilfe - Hallo {familyName}, diese Woche seid ihr fuer den Dienst{" "} + Hallo {familyName}, diese Woche seid ihr für den Dienst{" "} {dutyName} eingeteilt.
@@ -43,7 +45,7 @@ export function DutyReminderEmail({ Zeitraum: {weekLabel} - Danke, dass ihr mithelft, den Kita-Alltag verlaesslich zu + Danke, dass ihr mithelft, den Kita-Alltag verlässlich zu organisieren.
@@ -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, }, }; diff --git a/src/emails/InviteEmail.tsx b/src/emails/InviteEmail.tsx index 5207e01..b0528d5 100644 --- a/src/emails/InviteEmail.tsx +++ b/src/emails/InviteEmail.tsx @@ -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 ( - Aktiviere deinen Kita-Planer Account fuer {kitaName} + Aktiviere deinen Kita-Planer Account für {kitaName}
@@ -38,7 +40,7 @@ export function InviteEmail({
- 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. @@ -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, }, }; diff --git a/src/emails/NewsEmail.tsx b/src/emails/NewsEmail.tsx index 4ac393d..22892f8 100644 --- a/src/emails/NewsEmail.tsx +++ b/src/emails/NewsEmail.tsx @@ -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, }, }; diff --git a/src/emails/_styles.ts b/src/emails/_styles.ts new file mode 100644 index 0000000..5220e47 --- /dev/null +++ b/src/emails/_styles.ts @@ -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 +// ``, 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;