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:
1 parent
b6610ca7a7
commit
4d9727f0d1
2 files changed
+65
-50
No files matched your search
@@ -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
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user