fix: vitrine/marketing pages use visitor's browser locale, not always English (v0.78.0)
getMarketingLocale() only ever read the marketing_locale cookie, defaulting anonymous first-time visitors to English until they manually used the language switcher — even though the switcher and a full French translation already existed. Now falls back to parsing Accept-Language when no cookie is set; an explicit switcher choice still wins on every later visit. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,11 @@
|
|||||||
|
|
||||||
All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together.
|
All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together.
|
||||||
|
|
||||||
|
## 0.78.0 — 2026-07-24 13:40
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Marketing/vitrine pages now honor a first-time visitor's browser language (Accept-Language) instead of always defaulting to English until they manually switch. An explicit choice from the language switcher still wins on every later visit.
|
||||||
|
|
||||||
## 0.77.0 — 2026-07-24 13:00
|
## 0.77.0 — 2026-07-24 13:00
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ Status legend: **Exists** (fully working) · **Partial** (works but with a real
|
|||||||
|
|
||||||
**Model Prefs gating (closed 2026-07-24):** `Settings → AI`'s Model Prefs section previously had zero gate — every user could pick a raw provider + model ID with no fencing. Now behind `isByokEnabled` (same gate as BYOK itself, since picking a specific provider/model only makes sense once you have your own key to route it to) — hidden entirely for non-BYOK users, not just locked-and-teased. `GET`/`PUT /api/v1/users/me/model-prefs` gated server-side via `requireByok()` too.
|
**Model Prefs gating (closed 2026-07-24):** `Settings → AI`'s Model Prefs section previously had zero gate — every user could pick a raw provider + model ID with no fencing. Now behind `isByokEnabled` (same gate as BYOK itself, since picking a specific provider/model only makes sense once you have your own key to route it to) — hidden entirely for non-BYOK users, not just locked-and-teased. `GET`/`PUT /api/v1/users/me/model-prefs` gated server-side via `requireByok()` too.
|
||||||
|
|
||||||
|
**Marketing/vitrine locale (closed 2026-07-24):** `getMarketingLocale()` (`apps/web/lib/marketing-locale.ts`) previously hard-defaulted anonymous visitors to English until they clicked the language switcher, even though the switcher's `marketing_locale` cookie mechanism and a fully French-translated marketing site both already existed. Now falls back to parsing the `Accept-Language` header (first supported tag in the browser's stated preference order) when no cookie is set; an explicit switcher choice still wins on every later visit, and logged-in visitors still get their saved `users.locale` first, unchanged.
|
||||||
|
|
||||||
| Feature | Status | Description | Key files |
|
| Feature | Status | Description | Key files |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| Recipe CRUD | Exists | Create/get/list/update/delete; update snapshots the prior version first (`recipeSnapshots`) | `apps/web/app/api/v1/recipes/**` |
|
| Recipe CRUD | Exists | Create/get/list/update/delete; update snapshots the prior version first (`recipeSnapshots`) | `apps/web/app/api/v1/recipes/**` |
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Mirrors CHANGELOG.md at the repo root — update both together.
|
// Mirrors CHANGELOG.md at the repo root — update both together.
|
||||||
export const APP_VERSION = "0.77.0";
|
export const APP_VERSION = "0.78.0";
|
||||||
|
|
||||||
export type ChangelogEntry = {
|
export type ChangelogEntry = {
|
||||||
version: string;
|
version: string;
|
||||||
@@ -11,6 +11,13 @@ export type ChangelogEntry = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const CHANGELOG: ChangelogEntry[] = [
|
export const CHANGELOG: ChangelogEntry[] = [
|
||||||
|
{
|
||||||
|
version: "0.78.0",
|
||||||
|
date: "2026-07-24 13:40",
|
||||||
|
fixed: [
|
||||||
|
"Marketing/vitrine pages now honor a first-time visitor's browser language (Accept-Language) instead of always defaulting to English until they manually switch. An explicit choice from the language switcher still wins on every later visit.",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
version: "0.77.0",
|
version: "0.77.0",
|
||||||
date: "2026-07-24 13:00",
|
date: "2026-07-24 13:00",
|
||||||
|
|||||||
@@ -12,10 +12,31 @@ export { MARKETING_LOCALE_COOKIE };
|
|||||||
* which persists to `users.locale` via an authenticated PATCH that would
|
* which persists to `users.locale` via an authenticated PATCH that would
|
||||||
* just 401 for an anonymous visitor and revert the UI.
|
* just 401 for an anonymous visitor and revert the UI.
|
||||||
*/
|
*/
|
||||||
|
/** Parses an `Accept-Language` header and returns the first supported
|
||||||
|
* locale it lists, in the browser's stated preference order — ignoring
|
||||||
|
* unsupported languages (e.g. "de", "es") rather than stopping at them. */
|
||||||
|
function localeFromAcceptLanguage(header: string | null): Locale | null {
|
||||||
|
if (!header) return null;
|
||||||
|
const tags = header
|
||||||
|
.split(",")
|
||||||
|
.map((part) => part.split(";")[0]!.trim().toLowerCase())
|
||||||
|
.filter(Boolean);
|
||||||
|
for (const tag of tags) {
|
||||||
|
if (tag.startsWith("fr")) return "fr";
|
||||||
|
if (tag.startsWith("en")) return "en";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getMarketingLocale(): Promise<Locale> {
|
export async function getMarketingLocale(): Promise<Locale> {
|
||||||
const store = await cookies();
|
const store = await cookies();
|
||||||
const value = store.get(MARKETING_LOCALE_COOKIE)?.value;
|
const cookieValue = store.get(MARKETING_LOCALE_COOKIE)?.value;
|
||||||
return value === "fr" ? "fr" : "en";
|
if (cookieValue === "fr" || cookieValue === "en") return cookieValue;
|
||||||
|
|
||||||
|
// No explicit choice saved yet (first visit, or cookies cleared) — honor
|
||||||
|
// the visitor's browser language instead of always defaulting to English.
|
||||||
|
const headerStore = await headers();
|
||||||
|
return localeFromAcceptLanguage(headerStore.get("accept-language")) ?? "en";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A logged-in visitor's own saved locale wins (matches what they see
|
/** A logged-in visitor's own saved locale wins (matches what they see
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@epicure/web",
|
"name": "@epicure/web",
|
||||||
"version": "0.77.0",
|
"version": "0.78.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "epicure",
|
"name": "epicure",
|
||||||
"version": "0.77.0",
|
"version": "0.78.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "pnpm --filter web dev",
|
"dev": "pnpm --filter web dev",
|
||||||
|
|||||||
Reference in New Issue
Block a user