fix(i18n): fall back to Accept-Language and add a language switcher to onboarding

New visitors always got the English default because locale resolution
only ever read the NEXT_LOCALE cookie — never set until a user visits
a page with the LanguageSwitcher, which only lives in the authenticated
app Sidebar. /onboarding/pet runs before that, so a German browser
landed in English with no way to change it (reported live after
deploying the upload-consent step from Gitea #3).

Adds an Accept-Language fallback in src/i18n/request.ts (only consulted
when no cookie is set) and renders the existing LanguageSwitcher on the
onboarding page so users can override the guess.
This commit is contained in:
admin committed 2026-08-11 10:27:58 +02:00
1 parent b6610ca7a7
commit 4d9727f0d1
2 files changed
+65 -50

No files matched your search

+42 -46
View File
@@ -9,6 +9,7 @@ import { Button } from "@/components/ui/button";
import { useActivePet } from "@/context/ActivePetContext";
import { OnboardingGuidelines } from "@/components/legal/OnboardingGuidelines";
import { UploadConsent } from "@/components/legal/UploadConsent";
import { LanguageSwitcher } from "@/components/i18n/LanguageSwitcher";
/**
* Onboarding pet page — /onboarding/pet
@@ -32,55 +33,41 @@ export default function OnboardingPetPage() {
const [guidelinesAcknowledged, setGuidelinesAcknowledged] = useState(false);
const [uploadConsentGiven, setUploadConsentGiven] = useState(false);
let content: React.ReactNode;
if (!guidelinesAcknowledged) {
return (
<main className="min-h-screen flex items-center justify-center bg-background px-4">
<OnboardingGuidelines onContinue={() => setGuidelinesAcknowledged(true)} />
</main>
);
}
if (!uploadConsentGiven) {
return (
<main className="min-h-screen flex items-center justify-center bg-background px-4">
<UploadConsent onContinue={() => setUploadConsentGiven(true)} />
</main>
);
}
if (createdPetId) {
return (
<main className="min-h-screen flex items-center justify-center bg-background px-4">
<div className="w-full max-w-[512px] p-8 flex flex-col items-center gap-6">
<div className="text-center">
<h1 className="text-xl font-semibold mb-2">{t("addPhoto")}</h1>
<p className="text-sm text-muted-foreground">{t("addPhotoDesc")}</p>
</div>
<AvatarUpload petId={createdPetId} onUploadComplete={() => {}} />
<div className="flex flex-col gap-2 w-full">
<Button
className="w-full"
onClick={() => router.push(`/pets/${createdPetId}`)}
>
{t("done")}
</Button>
<Button
variant="ghost"
className="w-full text-muted-foreground"
onClick={() => router.push(`/pets/${createdPetId}`)}
>
{t("skipForNow")}
</Button>
</div>
content = <OnboardingGuidelines onContinue={() => setGuidelinesAcknowledged(true)} />;
} else if (!uploadConsentGiven) {
content = <UploadConsent onContinue={() => setUploadConsentGiven(true)} />;
} else if (createdPetId) {
content = (
<div className="w-full max-w-[512px] p-8 flex flex-col items-center gap-6">
<div className="text-center">
<h1 className="text-xl font-semibold mb-2">{t("addPhoto")}</h1>
<p className="text-sm text-muted-foreground">{t("addPhotoDesc")}</p>
</div>
</main>
);
}
return (
<main className="min-h-screen flex items-center justify-center bg-background px-4">
<AvatarUpload petId={createdPetId} onUploadComplete={() => {}} />
<div className="flex flex-col gap-2 w-full">
<Button
className="w-full"
onClick={() => router.push(`/pets/${createdPetId}`)}
>
{t("done")}
</Button>
<Button
variant="ghost"
className="w-full text-muted-foreground"
onClick={() => router.push(`/pets/${createdPetId}`)}
>
{t("skipForNow")}
</Button>
</div>
</div>
);
} else {
content = (
<div className="w-full max-w-[512px] p-8">
<h1 className="text-xl font-semibold mb-2">{t("addFirstPet")}</h1>
<p className="text-sm text-muted-foreground mb-8">{t("addFirstPetDesc")}</p>
@@ -92,6 +79,15 @@ export default function OnboardingPetPage() {
}}
/>
</div>
);
}
return (
<main className="relative min-h-screen flex items-center justify-center bg-background px-4">
<div className="absolute top-4 right-4">
<LanguageSwitcher />
</div>
{content}
</main>
);
}
+23 -4
View File
@@ -1,16 +1,35 @@
import { getRequestConfig } from "next-intl/server";
import { cookies } from "next/headers";
import { cookies, headers } from "next/headers";
export const LOCALES = ["en", "de"] as const;
export type Locale = (typeof LOCALES)[number];
export const defaultLocale: Locale = "en";
/**
* Picks the first Accept-Language entry (browsers already send these in
* preference order) whose primary subtag matches a supported locale. Used
* only when the visitor has never set NEXT_LOCALE — e.g. a fresh sign-up
* mid-onboarding, which has no in-app language switcher available yet.
*/
function localeFromAcceptLanguage(acceptLanguage: string | null): Locale | null {
if (!acceptLanguage) return null;
const tags = acceptLanguage
.split(",")
.map((part) => part.split(";")[0].trim().split("-")[0].toLowerCase());
const match = tags.find((tag) => (LOCALES as readonly string[]).includes(tag));
return (match as Locale) ?? null;
}
export default getRequestConfig(async () => {
const cookieStore = await cookies();
const raw = cookieStore.get("NEXT_LOCALE")?.value ?? "";
const locale: Locale = (LOCALES as readonly string[]).includes(raw)
? (raw as Locale)
: defaultLocale;
let locale: Locale;
if ((LOCALES as readonly string[]).includes(raw)) {
locale = raw as Locale;
} else {
const headerStore = await headers();
locale = localeFromAcceptLanguage(headerStore.get("accept-language")) ?? defaultLocale;
}
return {
locale,