Add legal pages, ToS acceptance, and alpha banner

- Journal: Impressum (§5 TMG), Terms of Service, GDPR privacy page
- Registration flow: required ToS checkbox, termsAcceptedAt persisted
- Alpha banner on Journal (always visible); alpha badge on Planner hero
- Footer with legal links + GitHub on both apps
- Reduce PII: sendDefaultPii=false, Sentry init only after login (Journal)
- Drop Sentry from Planner (no login, no consent possible)
- Drop localStorage caching from i18n client detection
- Sync SSR i18n resources each request so locale edits HMR without restart

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-04-18 00:08:48 +02:00
parent 6c1cd23358
commit cc0f44258a
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
24 changed files with 773 additions and 206 deletions

View file

@ -29,6 +29,7 @@ export const users = journalSchema.table("users", {
displayName: text("display_name"),
bio: text("bio"),
domain: text("domain").notNull(),
termsAcceptedAt: timestamp("terms_accepted_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});

View file

@ -26,18 +26,34 @@ const commonOptions = {
/**
* Initialize i18next for server-side rendering.
* No language detector language is determined from the request.
*
* Re-adds resource bundles on every call so dev-mode HMR edits to the
* locale files are reflected without a Node restart (the i18n singleton
* otherwise caches the resources from the first init call).
*/
export function initI18nServer(lng: SupportedLng = "en") {
if (i18n.isInitialized) {
syncResources();
i18n.changeLanguage(lng);
return;
}
i18n.use(initReactI18next).init({ ...commonOptions, lng });
}
function syncResources() {
for (const [lang, namespaces] of Object.entries(resources)) {
for (const [ns, bundle] of Object.entries(namespaces)) {
i18n.addResourceBundle(lang, ns, bundle, true, true);
}
}
}
/**
* Initialize i18next for the client.
* Uses LanguageDetector with htmlTag priority so hydration matches the server.
* No localStorage caching we don't want to set non-essential storage
* without user consent. Language is resolved per-request from the server
* (Accept-Language htmlTag) or the browser's navigator as a fallback.
*/
export function initI18nClient() {
if (i18n.isInitialized) return;
@ -47,8 +63,8 @@ export function initI18nClient() {
.init({
...commonOptions,
detection: {
order: ["htmlTag", "localStorage", "navigator"],
caches: ["localStorage"],
order: ["htmlTag", "navigator"],
caches: [],
},
});
}

View file

@ -143,9 +143,12 @@ export default {
saveRoutesDesc: "Erstelle ein kostenloses Konto auf trails.cool, um Routen zu speichern, Aktivitäten zu verfolgen und mehr.",
goToJournal: "Zu trails.cool",
footer: {
imprint: "Impressum",
privacy: "Datenschutz",
terms: "Nutzungsbedingungen",
source: "Quellcode",
builtWith: "Erstellt mit BRouter und OpenStreetMap",
alpha: "Alpha-Version",
},
},
},
@ -250,6 +253,16 @@ export default {
settings: "Einstellungen",
logout: "Abmelden",
},
alpha: {
message: "trails.cool befindet sich in früher Entwicklung — deine Daten können zurückgesetzt werden.",
},
footer: {
imprint: "Impressum",
privacy: "Datenschutz",
terms: "Nutzungsbedingungen",
source: "Quellcode",
alpha: "Alpha-Version",
},
auth: {
login: "Anmelden",
register: "Registrieren",
@ -267,7 +280,14 @@ export default {
linkExpires: "Der Link ist 15 Minuten gültig.",
codeHelp: "Oder gib den 6-stelligen Code aus der E-Mail ein:",
verifyCode: "Code bestätigen",
termsBefore: "Ich habe die ",
termsLink: "Nutzungsbedingungen",
termsAfter: " gelesen und akzeptiere sie.",
termsRequired: "Du musst die Nutzungsbedingungen akzeptieren.",
noAccount: "Noch kein Konto?",
alreadyHaveAccount: "Bereits ein Konto?",
handleWillBe: "Dein Handle wird {{handle}} sein",
passkeyNotFound: "Für diese Seite wurde kein Passkey gefunden. Registriere ein neues Konto oder nutze stattdessen einen Magic Link.",
registerDescription: "Registriere dich mit einem Passkey — kein Passwort nötig.",
registerWithPasskey: "Mit Passkey registrieren",
creatingPasskey: "Erstelle Passkey...",

View file

@ -143,9 +143,12 @@ export default {
saveRoutesDesc: "Create a free account on trails.cool to keep your routes, track activities, and more.",
goToJournal: "Go to trails.cool",
footer: {
imprint: "Imprint",
privacy: "Privacy",
terms: "Terms",
source: "Source Code",
builtWith: "Built with BRouter and OpenStreetMap",
alpha: "Alpha version",
},
},
},
@ -250,6 +253,16 @@ export default {
settings: "Settings",
logout: "Log Out",
},
alpha: {
message: "trails.cool is in early development — your data may be reset.",
},
footer: {
imprint: "Imprint",
privacy: "Privacy",
terms: "Terms",
source: "Source Code",
alpha: "Alpha version",
},
auth: {
login: "Sign In",
register: "Register",
@ -267,7 +280,14 @@ export default {
linkExpires: "The link expires in 15 minutes.",
codeHelp: "Or enter the 6-digit code from the email:",
verifyCode: "Verify Code",
termsBefore: "I have read and accept the ",
termsLink: "Terms of Service",
termsAfter: ".",
termsRequired: "You must accept the Terms of Service to register.",
noAccount: "Don't have an account?",
alreadyHaveAccount: "Already have an account?",
handleWillBe: "Your handle will be {{handle}}",
passkeyNotFound: "No passkey found for this site. Register a new account or use a magic link instead.",
registerDescription: "Register with a passkey — no password needed.",
registerWithPasskey: "Register with Passkey",
creatingPasskey: "Creating passkey...",