diff --git a/.windsurf/workflows/translate.md b/.windsurf/workflows/translate.md index cd831171..4f0631d0 100644 --- a/.windsurf/workflows/translate.md +++ b/.windsurf/workflows/translate.md @@ -60,6 +60,14 @@ const t = useT() No exceptions. Do not manually access locale files. +For the backend, use + +```tsx +import {createT} from 'shared/locale' + +const t = createT(locale) +``` + --- ### 3️⃣ Replace Hardcoded Strings @@ -121,8 +129,8 @@ After updating the component: 1. Open: ``` - web/messages/fr.json - web/messages/de.json + common/src/messages/fr.json + common/src/messages/de.json ... ``` diff --git a/backend/api/metadata.json b/backend/api/metadata.json index f074740c..8712eba7 100644 --- a/backend/api/metadata.json +++ b/backend/api/metadata.json @@ -1,8 +1,8 @@ { "git": { - "revision": "704bcb4", - "commitDate": "2025-12-15 13:38:09 +0200", + "revision": "c085e8f", + "commitDate": "2026-02-22 21:51:08 +0100", "author": "MartinBraquet", - "message": "Increase API docs font size on mobile" + "message": "Add guidelines for adding translations to existing files" } } diff --git a/backend/api/package.json b/backend/api/package.json index f1092272..f65ab126 100644 --- a/backend/api/package.json +++ b/backend/api/package.json @@ -1,7 +1,7 @@ { "name": "@compass/api", "description": "Backend API endpoints", - "version": "1.12.0", + "version": "1.13.0", "private": true, "scripts": { "watch:serve": "tsx watch src/serve.ts", diff --git a/backend/api/src/app.ts b/backend/api/src/app.ts index d78b066d..0f389bb8 100644 --- a/backend/api/src/app.ts +++ b/backend/api/src/app.ts @@ -88,6 +88,7 @@ import {updateMe} from './update-me' import {updateNotifSettings} from './update-notif-setting' import {updatePrivateUserMessageChannel} from './update-private-user-message-channel' import {updateProfile} from './update-profile' +import {updateUserLocale} from './update-user-locale' // const corsOptions: CorsOptions = { // origin: ['*'], // Only allow requests from this domain @@ -371,6 +372,7 @@ const handlers: {[k in APIPath]: APIHandler} = { 'star-profile': starProfile, 'update-notif-settings': updateNotifSettings, 'update-options': updateOptions, + 'update-user-locale': updateUserLocale, 'update-private-user-message-channel': updatePrivateUserMessageChannel, 'update-profile': updateProfile, 'user/by-id/:id': getUser, diff --git a/backend/api/src/create-user.ts b/backend/api/src/create-user.ts index 082577b8..e32d2dd4 100644 --- a/backend/api/src/create-user.ts +++ b/backend/api/src/create-user.ts @@ -1,4 +1,5 @@ import {setLastOnlineTimeUser} from 'api/set-last-online-time' +import {defaultLocale} from 'common/constants' import {RESERVED_PATHS} from 'common/envs/constants' import {IS_LOCAL} from 'common/hosting/constants' import {convertPrivateUser, convertUser} from 'common/supabase/users' @@ -19,7 +20,7 @@ import {getUser, getUserByUsername, log} from 'shared/utils' import {APIError, APIHandler} from './helpers/endpoint' export const createUser: APIHandler<'create-user'> = async (props, auth, req) => { - const {deviceToken: preDeviceToken} = props + const {deviceToken: preDeviceToken, locale = defaultLocale} = props const firebaseUser = await admin.auth().getUser(auth.uid) const testUserAKAEmailPasswordUser = firebaseUser.providerData[0].providerId === 'password' @@ -93,6 +94,7 @@ export const createUser: APIHandler<'create-user'> = async (props, auth, req) => const privateUser: PrivateUser = { id: auth.uid, email, + locale, initialIpAddress: ip, initialDeviceToken: deviceToken, notificationPreferences: getDefaultNotificationPreferences(), diff --git a/backend/api/src/helpers/private-messages.ts b/backend/api/src/helpers/private-messages.ts index 7b0b7e67..4a4b4b34 100644 --- a/backend/api/src/helpers/private-messages.ts +++ b/backend/api/src/helpers/private-messages.ts @@ -186,9 +186,16 @@ const notifyOtherUserInChannelIfInactive = async ( body: textContent, url: `/messages/${channelId}`, } - await sendWebNotifications(pg, receiverId, JSON.stringify(payload)) - await sendMobileNotifications(pg, receiverId, payload) - + try { + await sendWebNotifications(pg, receiverId, JSON.stringify(payload)) + } catch (err) { + console.error('Failed to send web notification:', err) + } + try { + await sendMobileNotifications(pg, receiverId, payload) + } catch (err) { + console.error('Failed to send mobile notification:', err) + } const startOfDay = dayjs().tz('America/Los_Angeles').startOf('day').toISOString() const previousMessagesThisDayBetweenTheseUsers = await pg.one( `select count(*) diff --git a/backend/api/src/update-user-locale.ts b/backend/api/src/update-user-locale.ts new file mode 100644 index 00000000..8641af90 --- /dev/null +++ b/backend/api/src/update-user-locale.ts @@ -0,0 +1,18 @@ +import {createSupabaseDirectClient} from 'shared/supabase/init' +import {updatePrivateUser} from 'shared/supabase/users' +import {broadcastUpdatedPrivateUser} from 'shared/websockets/helpers' + +import {APIError, APIHandler} from './helpers/endpoint' + +export const updateUserLocale: APIHandler<'update-user-locale'> = async ({locale}, auth) => { + if (!auth?.uid) throw new APIError(401, 'Not authenticated') + + const pg = createSupabaseDirectClient() + + // Update the private user's data with the new locale + await updatePrivateUser(pg, auth.uid, { + locale, + }) + + broadcastUpdatedPrivateUser(auth.uid) +} diff --git a/backend/email/emails/functions/helpers.tsx b/backend/email/emails/functions/helpers.tsx index 557d1b59..fe2b13a0 100644 --- a/backend/email/emails/functions/helpers.tsx +++ b/backend/email/emails/functions/helpers.tsx @@ -15,6 +15,7 @@ import NewSearchAlertsEmail from 'email/new-search_alerts' import WelcomeEmail from 'email/welcome' import * as admin from 'firebase-admin' import {getOptionsIdsToLabels} from 'shared/supabase/options' +import {createT} from 'shared/locale' export const fromEmail = 'Compass ' @@ -65,9 +66,17 @@ export const sendNewMessageEmail = async ( return } + const locale = privateUser?.locale + const t = createT(locale) + console.log(`Sending email to ${privateUser.email} in ${locale}`) + + const subject = t('email.new_message.subject', '{creatorName} sent you a message!', { + creatorName: fromUser.name, + }) + return await sendEmail({ from: fromEmail, - subject: `${fromUser.name} sent you a message!`, + subject, to: privateUser.email, html: await render( , ), }) @@ -85,9 +95,16 @@ export const sendNewMessageEmail = async ( export const sendWelcomeEmail = async (toUser: User, privateUser: PrivateUser) => { if (!privateUser.email) return const verificationLink = await admin.auth().generateEmailVerificationLink(privateUser.email) + + const locale = privateUser?.locale + const t = createT(locale) + console.log(`Sending email to ${privateUser.email} in ${locale}`) + + const subject = t('email.welcome.subject', 'Welcome to Compass!') + return await sendEmail({ from: fromEmail, - subject: `Welcome to Compass!`, + subject, to: privateUser.email, html: await render( , ), }) @@ -112,14 +130,17 @@ export const sendSearchAlertsEmail = async ( const email = privateUser.email if (!email || !sendToEmail) return - // Determine locale (fallback to 'en') and load option labels before rendering - // TODO: add locale to user array - const locale = (toUser as any)?.locale ?? 'en' + const locale = privateUser?.locale + const t = createT(locale) + console.log(`Sending email to ${privateUser.email} in ${locale}`) + const optionIdsToLabels = await getOptionsIdsToLabels(locale) + const subject = t('email.search_alerts.subject', 'People aligned with your values just joined') + return await sendEmail({ from: fromEmail, - subject: `People aligned with your values just joined`, + subject, to: email, html: await render( , ), }) @@ -145,9 +167,17 @@ export const sendNewEndorsementEmail = async ( ) if (!privateUser.email || !sendToEmail) return + const locale = privateUser?.locale + const t = createT(locale) + console.log(`Sending email to ${privateUser.email} in ${locale}`) + + const subject = t('email.new_endorsement.subject', '{fromUserName} just endorsed you!', { + fromUserName: fromUser.name, + }) + return await sendEmail({ from: fromEmail, - subject: `${fromUser.name} just endorsed you!`, + subject, to: privateUser.email, html: await render( , ), }) diff --git a/backend/email/emails/functions/mock.ts b/backend/email/emails/functions/mock.ts index c8deaa08..e2c1f42a 100644 --- a/backend/email/emails/functions/mock.ts +++ b/backend/email/emails/functions/mock.ts @@ -4,76 +4,44 @@ import type {User} from 'common/user' // for email template testing // A subset of Profile with only essential fields for testing -export type PartialProfile = Pick & Partial +export type PartialProfile = Pick< + ProfileRow, + | 'id' + | 'user_id' + | 'created_time' + | 'last_modification_time' + | 'disabled' + | 'looking_for_matches' + | 'messaging_status' + | 'comments_enabled' + | 'visibility' +> & + Partial export const mockUser: User = { createdTime: 0, - bio: 'the futa in futarchy', - website: 'sincl.ai', - avatarUrl: - 'https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FSinclair%2FbqSXdzkn1z.JPG?alt=media&token=7779230a-9f5d-42b5-839f-fbdfef31a3ac', - idVerified: true, - discordHandle: 'sinclaether#5570', - twitterHandle: 'singularitttt', - verifiedPhone: true, - // sweepstakesVerified: true, + avatarUrl: 'https://martinbraquet.com/wp-content/uploads/BDo_Tbzj.jpeg', id: '0k1suGSJKVUnHbCPEhHNpgZPkUP2', - username: 'Sinclair', - name: 'Sinclair', - // isAdmin: true, - // isTrustworthy: false, + username: 'Martin', + name: 'Martin', link: { - site: 'sincl.ai', - x: 'singularitttt', - discord: 'sinclaether#5570', + site: 'martinbraquet.com', + x: 'martin', }, } -export const sinclairProfile: PartialProfile = { - // Required fields - id: 55, - user_id: '0k1suGSJKVUnHbCPEhHNpgZPkUP2', - created_time: '2023-10-27T00:41:59.851776+00:00', - last_modification_time: '2024-05-17T02:11:48.83+00:00', - disabled: false, - looking_for_matches: true, - messaging_status: 'open', - comments_enabled: true, - visibility: 'public', - - // Optional commonly used fields for testing - city: 'San Francisco', - gender: 'trans-female', - age: 25, -} - export const jamesUser: User = { createdTime: 0, - bio: 'Manifold cofounder! We got the AMM (What!?). We got the order book (What!?). We got the combination AMM and order book!', - website: 'https://calendly.com/jamesgrugett/manifold', - avatarUrl: - 'https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FJamesGrugett%2FefVzXKc9iz.png?alt=media&token=5c205402-04d5-4e64-be65-9d8b4836eb03', - idVerified: true, - // fromManifold: true, - discordHandle: '', - twitterHandle: 'jahooma', - verifiedPhone: true, - // sweepstakesVerified: true, + avatarUrl: 'https://martinbraquet.com/wp-content/uploads/BDo_Tbzj.jpeg', id: '5LZ4LgYuySdL1huCWe7bti02ghx2', - username: 'JamesGrugett', + username: 'James', name: 'James', link: { - x: 'jahooma', - discord: '', + x: 'james', }, } export const jamesProfile: PartialProfile = { - // Required fields id: 2, user_id: '5LZ4LgYuySdL1huCWe7bti02ghx2', created_time: '2023-10-21T21:18:26.691211+00:00', @@ -83,8 +51,6 @@ export const jamesProfile: PartialProfile = { messaging_status: 'open', comments_enabled: true, visibility: 'public', - - // Optional commonly used fields for testing city: 'San Francisco', gender: 'male', age: 32, diff --git a/backend/email/emails/new-endorsement.tsx b/backend/email/emails/new-endorsement.tsx index 19d210d3..922e2aba 100644 --- a/backend/email/emails/new-endorsement.tsx +++ b/backend/email/emails/new-endorsement.tsx @@ -15,6 +15,7 @@ import {type User} from 'common/user' import {DOMAIN} from 'common/envs/constants' import {jamesUser, mockUser} from './functions/mock' import {button, container, content, Footer, main, paragraph} from 'email/utils' +import {createT} from 'shared/locale' interface NewEndorsementEmailProps { fromUser: User @@ -22,6 +23,7 @@ interface NewEndorsementEmailProps { endorsementText: string unsubscribeUrl: string email?: string + locale?: string } export const NewEndorsementEmail = ({ @@ -30,15 +32,17 @@ export const NewEndorsementEmail = ({ endorsementText, unsubscribeUrl, email, + locale, }: NewEndorsementEmailProps) => { const name = onUser.name.split(' ')[0] + const t = createT(locale) const endorsementUrl = `https://${DOMAIN}/${onUser.username}` return ( - New endorsement from {fromUser.name} + {t('email.new_endorsement.preview', 'New endorsement from {fromUserName}', {fromUserName: fromUser.name})} {/*
*/} @@ -51,9 +55,9 @@ export const NewEndorsementEmail = ({ {/*
*/}
- Hi {name}, + {t('email.new_endorsement.greeting', 'Hi {name},', {name})} - {fromUser.name} just endorsed you! + {t('email.new_endorsement.message', '{fromUserName} endorsed you!', {fromUserName: fromUser.name})}
@@ -72,12 +76,12 @@ export const NewEndorsementEmail = ({
-