Skip to content

extend Peels i18n across app, content, and emails#55

Merged
dnywh merged 19 commits into
mainfrom
dnywh/global-i18n-rollout
Apr 21, 2026
Merged

extend Peels i18n across app, content, and emails#55
dnywh merged 19 commits into
mainfrom
dnywh/global-i18n-rollout

Conversation

@dnywh
Copy link
Copy Markdown
Owner

@dnywh dnywh commented Apr 21, 2026

Summary

  • remove Crowdin and consolidate Peels i18n around in-repo next-intl message catalogues
  • add locale handling for guests, signed-in members, auth redirects, profile preferences, and transactional emails
  • localise newsletter and legal content loading, RSS metadata, newsletter issue emails, and contributor docs

Testing

  • npm run i18n:check
  • npm run check
  • npm run build

Notes

  • external newsletter broadcasts can now be split by locale with NEWSLETTER_AUDIENCE_ID_EN, NEWSLETTER_AUDIENCE_ID_ES, NEWSLETTER_AUDIENCE_ID_DE, NEWSLETTER_AUDIENCE_ID_PT_BR, and NEWSLETTER_AUDIENCE_ID_FR
  • locale-specific newsletter issue files can be full translations or localised wrappers with an explicit English fallback notice
  • fixes Support languages other than English #5

@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 21, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
peels Ready Ready Preview, Comment Apr 21, 2026 8:00am

@dnywh dnywh requested a review from Copilot April 21, 2026 04:40
@supabase
Copy link
Copy Markdown

supabase Bot commented Apr 21, 2026

Updates to Preview Branch (dnywh/global-i18n-rollout) ↗︎

Deployments Status Updated
Database Tue, 21 Apr 2026 07:59:48 UTC
Services Tue, 21 Apr 2026 07:59:48 UTC
APIs Tue, 21 Apr 2026 07:59:48 UTC

Tasks are run on every commit but only new migration files are pushed.
Close and reopen this PR if you want to apply changes from existing seed or migration files.

Tasks Status Updated
Configurations Tue, 21 Apr 2026 07:59:50 UTC
Migrations Tue, 21 Apr 2026 07:59:51 UTC
Seeding Tue, 21 Apr 2026 07:59:56 UTC
Edge Functions Tue, 21 Apr 2026 08:00:02 UTC

View logs for this Workflow Run ↗︎.
Learn more about Supabase for Git ↗︎.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Consolidates Peels internationalisation around in-repo next-intl catalogues and extends locale-aware behaviour across the web app, longform content (newsletter/legal), auth redirects, profile preferences, and transactional/newsletter emails.

Changes:

  • Adds locale configuration + persistence (cookies, profile preferred_locale, auth redirect propagation).
  • Introduces locale-aware content loading with per-locale MDX wrappers + fallback notices for newsletter and legal pages, plus locale-scoped RSS metadata.
  • Localises email subjects/bodies for auth flows, chat notifications, and newsletter issues (including Resend audience broadcasts).

Reviewed changes

Copilot reviewed 71 out of 71 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
supabase/migrations/20260421123000_add_profile_preferred_locale.sql Adds preferred_locale column + constraint and populates it on profile creation.
supabase/functions/send-email-for-newsletter-issue-supabase-users/index.ts Sends locale-aware newsletter issue emails to opted-in Peels users.
supabase/functions/send-email-for-newsletter-issue-resend-audience/index.ts Creates/sends locale-segmented Resend broadcasts for external audiences.
supabase/functions/send-email-for-new-chat-message/index.ts Localises chat notification email subject/body via shared i18n helpers.
supabase/functions/send-email-for-auth-action/index.ts Localises Supabase auth hook emails based on redirect/user preference.
supabase/functions/_templates/sign-up-email.tsx Adds locale-aware copy and passes locale into confirm URLs.
supabase/functions/_templates/reset-password-email.tsx Adds locale-aware copy and passes locale into confirm URLs.
supabase/functions/_templates/reauthentication-email.tsx Adds locale-aware copy for OTP/reauth flows.
supabase/functions/_templates/newsletter-issue-email.tsx New locale-aware newsletter issue email template linking to web issue.
supabase/functions/_templates/new-chat-message-email.tsx Localises chat email template + listing-context string.
supabase/functions/_templates/magic-link-email.tsx Adds locale-aware copy and passes locale into confirm URLs.
supabase/functions/_templates/invite-email.tsx Adds locale-aware copy and passes locale into confirm URLs.
supabase/functions/_templates/email-change-email.tsx Adds locale-aware copy and passes locale into confirm URLs.
supabase/functions/_templates/build-auth-confirm-url.ts Adds optional locale query param to auth confirm URLs.
supabase/functions/_shared/newsletter.ts New shared newsletter config: current issue + per-locale copy + audiences.
supabase/functions/_shared/i18n.ts New shared email i18n: locale resolution + auth/chat copy.
src/utils/authRedirects.ts Adds helpers to read/append locale in auth redirect URLs and resolve auth locale.
src/lib/content/utils.ts Adds locale-aware content imports + locale-based date formatting.
src/lib/content/types.ts Extends content types with locale + isFallback.
src/lib/content/handlers/newsletter.ts Loads newsletter MDX per locale with fallback and cached module access.
src/lib/content/handlers/legal.ts Loads legal MDX per locale with fallback and cached module access.
src/i18n/services/locale.ts Resolves user locale from profile/auth metadata/cookie/Accept-Language; sets cookie.
src/i18n/config.ts Expands supported locales, adds labels, cookie name, normalisation + Accept-Language parsing.
src/content/newsletter/pt-BR/celebrating-the-first-few-months.mdx Adds pt-BR wrapper with translated metadata + fallback notice.
src/content/newsletter/pt-BR/beginning-to-look-like-compost.mdx Adds pt-BR wrapper with translated metadata + fallback notice.
src/content/newsletter/fr/celebrating-the-first-few-months.mdx Adds fr wrapper with translated metadata + fallback notice.
src/content/newsletter/fr/beginning-to-look-like-compost.mdx Adds fr wrapper with translated metadata + fallback notice.
src/content/newsletter/es/celebrating-the-first-few-months.mdx Adds es wrapper with translated metadata + fallback notice.
src/content/newsletter/es/beginning-to-look-like-compost.mdx Adds es wrapper with translated metadata + fallback notice.
src/content/newsletter/de/celebrating-the-first-few-months.mdx Adds de wrapper with translated metadata + fallback notice.
src/content/newsletter/de/beginning-to-look-like-compost.mdx Adds de wrapper with translated metadata + fallback notice.
src/content/legal/pt-BR/terms.mdx Adds pt-BR legal translation.
src/content/legal/pt-BR/privacy.mdx Adds pt-BR legal translation.
src/content/legal/fr/terms.mdx Adds fr legal translation.
src/content/legal/fr/privacy.mdx Adds fr legal translation.
src/content/legal/es/terms.mdx Adds es legal translation.
src/content/legal/es/privacy.mdx Adds es legal translation.
src/content/legal/de/terms.mdx Adds de legal translation.
src/content/legal/de/privacy.mdx Adds de legal translation.
src/content/legal/colophon.mdx Updates translation tooling description to remove Crowdin reference.
src/config/site.ts Removes newsletter description from config (moves to messages).
src/components/TranslationNotice/index.ts Re-export for TranslationNotice component.
src/components/TranslationNotice/TranslationNotice.tsx New UI component to indicate English fallback on translated pages.
src/components/Select/Select.jsx Adds compact variant styling and adjusts padding for dropdown icon.
src/components/ProfileAccountSettings/ProfileAccountSettings.tsx Adds preferred language selector and persists it via server action.
src/components/NewsletterIssuesList/NewsletterIssuesList.tsx Loads issues using current locale and localises tile subtitle.
src/components/NewsletterIssueTile/NewsletterIssueTile.tsx Replaces hardcoded issue/date line with localised subtitle prop.
src/components/LocalePicker/index.ts Re-export for LocalePicker component.
src/components/LocalePicker/LocalePicker.tsx New client locale picker that updates cookie via server action.
src/components/LegalFooter/LegalFooter.tsx Shows locale picker for guests in footer.
src/components/DropdownIcon/DropdownIcon.jsx Adds compact variant sizing/positioning.
src/components/AuthHashCompletion.tsx Propagates locale through auth hash completion/session creation.
src/app/layout.tsx Localises site metadata and RSS alternate link per locale.
src/app/auth/session/route.ts Persists locale cookie during session completion.
src/app/auth/confirm/route.ts Persists locale cookie during OTP confirmation.
src/app/auth/callback/route.ts Persists locale cookie during OAuth code exchange and propagates locale forward.
src/app/actions.ts Adds locale-aware auth redirects; adds actions for display locale + preferred locale.
src/app/(forms)/sign-up/page.tsx Localises page metadata.
src/app/(forms)/sign-in/page.tsx Localises page metadata.
src/app/(core)/(static)/newsletter/page.tsx Localises newsletter page metadata and RSS link by locale.
src/app/(core)/(static)/newsletter/feed.xml/route.ts Makes RSS locale-aware and localises feed metadata/content strings.
src/app/(core)/(static)/newsletter/(issues)/[slug]/page.tsx Loads locale-specific issue module; shows fallback notice when needed.
src/app/(core)/(static)/(legal)/[slug]/page.tsx Loads locale-specific legal module; localises “last updated” + fallback notice.
src/app/(core)/(interact)/(centered)/profile/page.js Localises profile metadata; passes preferred locale into account settings.
messages/pt-BR.json Adds pt-BR next-intl message catalogue.
messages/fr.json Adds fr next-intl message catalogue.
messages/es.json Extends es catalogue for language + newsletter/legal keys.
messages/en.json Extends en catalogue for language + newsletter/legal keys.
messages/de.json Extends de catalogue for language + newsletter/legal keys.
crowdin.yml Removes Crowdin configuration.
README.md Documents in-repo i18n workflow, locale-aware content structure, and newsletter email process.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/i18n/services/locale.ts Outdated
Comment on lines +14 to +21
function isMissingPreferredLocaleColumn(error: {
code?: string | null;
message?: string | null;
}) {
return (
error.code === "PGRST204" && /preferred_locale/i.test(error.message ?? "")
);
}
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isMissingPreferredLocaleColumn is duplicated in multiple places (e.g. here and src/app/actions.ts). Consider centralising this PostgREST error check in a shared server util to reduce drift and keep behaviour consistent.

Copilot uses AI. Check for mistakes.
Comment thread src/lib/content/utils.ts Outdated
Comment on lines +45 to +47
} catch (_error) {
// Fall through to the default English content.
}
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The locale-specific import fallback catches all errors, which can silently hide real problems (e.g., a syntax/runtime error inside a translated MDX file) and unexpectedly serve English instead. Consider only swallowing “module not found” errors and rethrowing/logging other import failures so broken translations fail loudly during dev/build.

Copilot uses AI. Check for mistakes.
Comment thread src/app/layout.tsx Outdated
Comment on lines +187 to +191
export async function generateMetadata(): Promise<Metadata> {
const locale = await getLocale();
const t = await getTranslations();
const description = t("Index.subtitle");

Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using getLocale()/getTranslations() in the root generateMetadata() makes metadata request-dependent and can force dynamic rendering (reducing static optimisation/caching across the app). If this isn’t intentional, consider keeping root metadata static (or limiting locale-specific metadata to routes that need it) and/or adding caching/revalidation strategy.

Copilot uses AI. Check for mistakes.
Comment on lines +40 to 43
resolvedLocale,
"publishDate",
true // publishDate is required for legal pages
);
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This inline comment looks incorrect in the newsletter handler: it says “publishDate is required for legal pages” but this code path is formatting newsletter issues. Update the comment to avoid confusion.

Copilot uses AI. Check for mistakes.
Comment on lines 9 to 10
export const dynamic = "force-dynamic";

Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

force-dynamic means this RSS feed is generated on every request; with multiple locales this can add avoidable load. Consider adding an explicit caching strategy (e.g., export const revalidate = … and/or Cache-Control with s-maxage) so the feed can be CDN-cached while still supporting the locale query param.

Copilot uses AI. Check for mistakes.
@dnywh dnywh marked this pull request as ready for review April 21, 2026 05:00
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 72 out of 72 changed files in this pull request and generated 5 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +45 to +47
const preferredLocale =
normaliseLocale(profile?.preferred_locale) ??
normaliseLocale(user?.user_metadata?.preferred_locale) ??
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

normaliseLocale(user?.user_metadata?.preferred_locale) can throw at runtime if preferred_locale is present but not a string (e.g. malformed/malicious user metadata), since normaliseLocale calls .trim(). Guard with a typeof ... === "string" check (like elsewhere in the PR) before passing the value in.

Suggested change
const preferredLocale =
normaliseLocale(profile?.preferred_locale) ??
normaliseLocale(user?.user_metadata?.preferred_locale) ??
const userMetadataPreferredLocale = user?.user_metadata?.preferred_locale;
const preferredLocale =
normaliseLocale(profile?.preferred_locale) ??
(typeof userMetadataPreferredLocale === "string"
? normaliseLocale(userMetadataPreferredLocale)
: undefined) ??

Copilot uses AI. Check for mistakes.
Comment thread src/utils/postgrest.ts
Comment on lines +10 to +13
return (
error.code === "PGRST204" &&
new RegExp(columnName, "i").test(error.message ?? "")
);
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

new RegExp(columnName, "i") treats columnName as a regex pattern, so callers with characters like . or - would change the match semantics. If this helper is meant to be generic, escape columnName before building the regex (or use a simple case-insensitive substring check).

Copilot uses AI. Check for mistakes.
Comment on lines 27 to 28
newsletterIssues.forEach((issue) => {
const issueLink = `${siteConfig.url}/newsletter/${issue.slug}`;
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The RSS feed is locale-specific (driven by ?locale=), but the issue URLs built from issue.slug don’t preserve that locale. This affects both issueLink (used in the item HTML) and feed.addItem({ link: ... }), so consumers can see localized feed metadata but land on a page rendered in a different language. Consider appending ?locale=${locale} to the generated issue URLs here.

Copilot uses AI. Check for mistakes.
Comment on lines +214 to +215
export const getNewsletterIssueUrl = () =>
`https://www.peels.app/newsletter/${currentIssue.slug}`;
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getNewsletterIssueUrl always returns a URL without any locale context. Since this URL is used in localized newsletter emails, consider accepting a locale arg and including it in the URL (e.g. as a ?locale= param) so the “view online”/CTA links consistently open the intended language experience.

Copilot uses AI. Check for mistakes.
Comment on lines +40 to +43
const copy = getNewsletterEmailCopy(locale);
const issue = getCurrentNewsletterIssue(locale);
const issueUrl = getNewsletterIssueUrl();
const profileUrl = "https://www.peels.app/profile";
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issueUrl is generated without any locale context, even though the email subject/body is localized. If you want recipients (especially external audience members without a saved locale) to land on the correct language version, pass locale through to the URL (e.g. via getNewsletterIssueUrl(locale)).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 73 out of 73 changed files in this pull request and generated 3 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 74 to 76
) : (
<>
You’re receiving this email because you originally reached out to{" "}
{senderName} on{" "}
<EmailLink href={`${siteUrl}/profile`}>Peels</EmailLink>.
</>
copy.initiatorFooter.replace("{senderName}", senderName)
)
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The initiator footer is now rendered as plain text (copy.initiatorFooter...) instead of including a link to Peels (previously an <EmailLink>). This is a UX regression in the email (no clickable link). Consider keeping the link by splitting the copy into before/link/after parts (similar to the owner footer) or by templating a placeholder that can be wrapped in <EmailLink>.

Copilot uses AI. Check for mistakes.
Comment on lines +17 to +21
const feed = new Feed({
title: `${siteConfig.name}: ${t("title")}`,
description: t("description"),
id: `${siteConfig.url}/newsletter`,
link: `${siteConfig.url}/newsletter/feed.xml?locale=${locale}`,
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that /newsletter/feed.xml can vary by ?locale=..., the RSS feed id is still the same for all locales (${siteConfig.url}/newsletter). Some RSS readers treat id as the stable identifier and may conflate different locale feeds. Consider making id (and/or title) locale-specific (e.g. include the locale in the id/link) so each localized feed is uniquely identified.

Suggested change
const feed = new Feed({
title: `${siteConfig.name}: ${t("title")}`,
description: t("description"),
id: `${siteConfig.url}/newsletter`,
link: `${siteConfig.url}/newsletter/feed.xml?locale=${locale}`,
const feedUrl = `${siteConfig.url}/newsletter/feed.xml?locale=${locale}`;
const feed = new Feed({
title: `${siteConfig.name}: ${t("title")}`,
description: t("description"),
id: feedUrl,
link: feedUrl,

Copilot uses AI. Check for mistakes.
Comment on lines +31 to +37
(new.raw_user_meta_data->>'first_name')::text,
(new.raw_user_meta_data->>'is_newsletter_subscribed')::boolean,
(new.raw_user_meta_data->>'preferred_locale')::text,
(new.raw_user_meta_data->>'http_referrer')::text,
(new.raw_user_meta_data->>'utm_source')::text,
(new.raw_user_meta_data->>'utm_medium')::text,
(new.raw_user_meta_data->>'utm_campaign')::text
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

handle_new_user inserts preferred_locale directly from raw_user_meta_data. If that value is anything other than the allowed set (e.g. en-US), the new profiles_preferred_locale_check constraint will reject the row and the trigger will fail, potentially blocking user creation. Consider sanitizing in the trigger (e.g. only insert when it matches the allowed locales, else insert NULL) or normalizing the value before insert.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 73 out of 73 changed files in this pull request and generated 2 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 153 to +156
const { data, error } = await resend.emails.send({
from: `Peels <${generalEmailAddress}>`,
to: [recipientEmail],
subject: `${senderName} just messaged you`,
subject: copy.subject.replace("{senderName}", senderName),
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

recipientEmail can be undefined (e.g., if the auth admin lookup fails or returns a user without an email), but it’s still passed to resend.emails.send as to: [recipientEmail]. This will cause the send call to fail at runtime.

Add an explicit guard before sending (and ideally treat it as a handled error/early return) when recipientEmail is missing, similar to how the newsletter sender checks userEmail.

Copilot uses AI. Check for mistakes.
Comment on lines +13 to +16
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LegalFooter now calls supabase.auth.getUser() during server rendering just to decide whether to render LocalePicker. This adds an extra Supabase request on every render of the footer (including for guests).

Consider avoiding the Supabase call here (e.g., render LocalePicker unconditionally, or move the signed-in check client-side / infer it from existing cookies) to reduce latency and load.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 73 out of 73 changed files in this pull request and generated 1 comment.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +16 to +38
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();

if (user?.id) {
const { data: profile, error: profileError } = await supabase
.from("profiles")
.select("preferred_locale")
.eq("id", user.id)
.single();

if (profileError && !isMissingPreferredLocaleColumn(profileError)) {
console.error(
"Error loading preferred locale from profile:",
profileError
);
}

const profileLocale = normaliseLocale(profile?.preferred_locale ?? null);
if (profileLocale) {
return profileLocale;
}
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getUserLocale now calls supabase.auth.getUser() (and potentially a profiles query) before checking the locale cookie or the Accept-Language header. Because this function is used by src/i18n/request.ts, this likely adds a Supabase network round-trip to many/most requests (including guests) even when a valid NEXT_LOCALE cookie is already present. Consider reordering the resolution so cookie/header are checked first, and only hitting Supabase when you actually need to (e.g. signed-in user + no valid locale cookie), or caching the profile locale per request.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 79 out of 79 changed files in this pull request and generated 3 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 46 to 52
<p>
{t.rich("rss", {
link: (chunks) => (
<Link href="/newsletter/feed.xml">{chunks}</Link>
<Link href={`/newsletter/feed.xml?locale=${locale}`}>
{chunks}
</Link>
),
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The RSS link always appends ?locale=${locale}. When locale is the default (e.g. en), this creates a second feed URL (/newsletter/feed.xml?locale=en) that serves the same content as /newsletter/feed.xml, which can lead to duplicate/canonical and caching inconsistencies. Consider omitting the locale query param for the default locale (or only adding it when locale !== defaultLocale).

Copilot uses AI. Check for mistakes.
Comment on lines 119 to 125
<p>
{t.rich("rss", {
link: (chunks) => (
<Link href="/newsletter/feed.xml">{chunks}</Link>
<Link href={`/newsletter/feed.xml?locale=${locale}`}>
{chunks}
</Link>
),
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as the newsletter index page: the RSS link always includes ?locale=${locale}. For the default locale this produces a duplicate feed URL (/newsletter/feed.xml?locale=en) even though the feed handler’s <link>/<id> values are based on /newsletter/feed.xml without the param. Prefer only adding the query param for non-default locales, or ensure the feed handler echoes the requested param consistently.

Copilot uses AI. Check for mistakes.
Comment on lines +12 to +31
const requestUrl = new URL(request.url);
const requestedLocale = requestUrl.searchParams.get("locale");
const locale = normaliseLocale(requestedLocale) ?? defaultLocale;
const t = await getTranslations({ locale, namespace: "Newsletter" });
const newsletterIssues = await getAllNewsletterIssues(locale);
const feedUrl = new URL("/newsletter/feed.xml", siteConfig.url);

export async function GET() {
const newsletterIssues = await getAllNewsletterIssues();
if (requestedLocale && locale !== defaultLocale) {
feedUrl.searchParams.set("locale", locale);
}

const feed = new Feed({
title: `${siteConfig.name}: ${t("title")}`,
description: t("description"),
id: feedUrl.toString(),
link: feedUrl.toString(),
favicon: `${siteConfig.url}/favicon.ico`,
language: locale,
copyright: `All rights reserved ${new Date().getFullYear()}, ${siteConfig.name}`,
});
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the request is /newsletter/feed.xml?locale=en, requestedLocale is truthy but locale === defaultLocale, so the generated feedUrl (and therefore <id>/<link>) omits the query param. This makes the returned feed metadata inconsistent with the requested URL and can cause duplicate feed identities. Either (a) only generate links that omit locale for the default locale, or (b) include the locale param whenever it was explicitly requested (including the default).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 79 out of 79 changed files in this pull request and generated 1 comment.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/lib/content/utils.ts
Comment on lines +22 to +28
export function formatContentDate(dateString: string, locale: Locale) {
return new Intl.DateTimeFormat(locale, {
year: "numeric",
month: "long",
day: "numeric",
}).format(new Date(dateString));
}
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

formatContentDate formats an ISO timestamp (e.g. 2025-06-03T09:00:00+10:00) using the server’s default timezone. On platforms that run in UTC, this can shift the rendered calendar day (e.g. June 3 becomes June 2), which will show incorrect publish/updated dates across newsletter/legal pages and RSS metadata. Consider formatting in a fixed timezone (e.g. the content’s intended zone) or parsing as a date-only value before formatting so the displayed day is stable.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 79 out of 79 changed files in this pull request and generated 1 comment.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +40 to +46
const handleChange = (nextLocale: Locale) => {
startTransition(async () => {
const formData = new FormData();
formData.set("locale", nextLocale);
await setDisplayLocaleAction(formData);
router.refresh();
});
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setDisplayLocaleAction’s return value (and potential thrown errors) is ignored here. If the action fails, the UI will still refresh with no feedback and the promise rejection may go unhandled. Consider wrapping the action call in try/catch and handling { error } results before calling router.refresh() (or showing a hint/message when the locale update fails).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 79 out of 79 changed files in this pull request and generated 2 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +31 to +45
for (const { locale, audienceId } of audienceConfigs) {
const email = NewsletterIssueEmail({
locale,
recipientName: "{{{FIRST_NAME|there}}}",
externalAudience: true,
});

if (broadcastError) {
throw broadcastError;
}
const { data: broadcast, error: broadcastError } =
await resend.broadcasts.create({
audienceId,
from: `Danny from Peels <${newsletterEmailAddress}>`,
subject: getNewsletterEmailSubject(locale),
react: email,
text: await render(email, { plainText: true }),
headers: {
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The plaintext text body is rendered from an email instance that contains the Resend merge tag ({{{FIRST_NAME|there}}}) as recipientName. If Resend doesn’t substitute merge tags in the text field for broadcasts, recipients will see the raw placeholder in their greeting. Consider rendering the text version with a non-templated fallback name (e.g., "there") while keeping merge tags in the HTML/React version.

Copilot uses AI. Check for mistakes.
Comment on lines 2 to 4
import { cache } from "react";
import type { Locale } from "@/i18n/config";
import type { NewsletterIssueData } from "../types";
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Locale is imported but never used. This adds noise and may trigger lint failures in stricter environments—remove the unused import.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 79 out of 79 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (1)

src/components/Select/Select.jsx:25

  • There’s duplicate styling in StyledSelect (color: theme.colors.text.ui.primary is declared twice). Removing the redundant declaration will reduce noise and avoid confusion when tweaking styles later.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/i18n/config.ts
Comment on lines +74 to +103
export function parseAcceptLanguageHeader(
headerValue: string | null
): Locale[] {
if (!headerValue) {
return [];
}

return headerValue
.split(",")
.map((entry) => {
const [languageTag, ...params] = entry.trim().split(";");
const qualityParam = params.find((param) =>
param.trim().startsWith("q=")
);
const quality = Number.parseFloat(
qualityParam?.trim().replace("q=", "") ?? "1"
);

return {
quality: Number.isFinite(quality) ? quality : 0,
locale: normaliseLocale(languageTag),
};
})
.filter(
(entry): entry is { quality: number; locale: Locale } =>
entry.locale !== null
)
.sort((left, right) => right.quality - left.quality)
.map((entry) => entry.locale);
}
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parseAcceptLanguageHeader includes locales even when the header explicitly sets q=0 ("not acceptable"), because entries are filtered only by locale !== null and not by quality > 0. This can cause getUserLocale() to pick a locale the browser said it won’t accept. Filter out entries with quality <= 0 (and consider clamping quality to [0,1]).

Copilot uses AI. Check for mistakes.
Comment on lines +23 to +30
function isMissingPreferredLocaleColumn(error: {
code?: string | null;
message?: string | null;
}) {
return (
error.code === "PGRST204" && /preferred_locale/i.test(error.message ?? "")
);
}
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isMissingPreferredLocaleColumn is implemented locally here, and a slightly different version exists in send-email-for-new-chat-message/index.ts. To keep behavior consistent (and avoid regex vs. includes drift), consider moving this helper into a shared module under supabase/functions/_shared/ and importing it in both edge functions.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 80 out of 80 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (2)

src/components/SiteFooter/index.js:1

  • src/components/SiteFooter/index.js re-exports ./SiteFooter, but this PR doesn’t add a SiteFooter module in that directory. This will cause a runtime/build failure when importing @/components/SiteFooter. Fix by either adding src/components/SiteFooter/SiteFooter.tsx|jsx (and moving/duplicating the footer implementation there) or changing this barrel to re-export the existing implementation file.
    src/i18n/services/locale.ts:1
  • supabase.auth.getUser() typically performs a network call to validate the JWT, which can add latency on every request where an auth cookie is present. If you only need user_metadata.preferred_locale, consider using supabase.auth.getSession() first (session read from cookies) and only falling back to getUser() / DB lookups when necessary; this keeps locale resolution cheaper on the hot path.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@dnywh dnywh merged commit 147aaf7 into main Apr 21, 2026
6 checks passed
@dnywh dnywh deleted the dnywh/global-i18n-rollout branch April 21, 2026 08:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants