feat(bot): persist locale preferences across mini app and replies

This commit is contained in:
2026-03-09 13:17:46 +04:00
parent 9de6bcc31b
commit 2d8e0491cc
19 changed files with 904 additions and 77 deletions

View File

@@ -7,6 +7,7 @@ import {
fetchMiniAppPendingMembers,
fetchMiniAppSession,
joinMiniAppHousehold,
updateMiniAppLocalePreference,
type MiniAppDashboard,
type MiniAppPendingMember
} from './miniapp-api'
@@ -36,6 +37,8 @@ type SessionState =
member: {
displayName: string
isAdmin: boolean
preferredLocale: Locale | null
householdDefaultLocale: Locale
}
telegramUser: {
firstName: string | null
@@ -51,7 +54,9 @@ const demoSession: Extract<SessionState, { status: 'ready' }> = {
mode: 'demo',
member: {
displayName: 'Demo Resident',
isAdmin: false
isAdmin: false,
preferredLocale: 'en',
householdDefaultLocale: 'en'
},
telegramUser: {
firstName: 'Demo',
@@ -112,6 +117,8 @@ function App() {
const [pendingMembers, setPendingMembers] = createSignal<readonly MiniAppPendingMember[]>([])
const [joining, setJoining] = createSignal(false)
const [approvingTelegramUserId, setApprovingTelegramUserId] = createSignal<string | null>(null)
const [savingMemberLocale, setSavingMemberLocale] = createSignal(false)
const [savingHouseholdLocale, setSavingHouseholdLocale] = createSignal(false)
const copy = createMemo(() => dictionary[locale()])
const onboardingSession = createMemo(() => {
@@ -153,7 +160,8 @@ function App() {
}
async function bootstrap() {
setLocale(detectLocale())
const fallbackLocale = detectLocale()
setLocale(fallbackLocale)
webApp?.ready?.()
webApp?.expand?.()
@@ -175,6 +183,10 @@ function App() {
try {
const payload = await fetchMiniAppSession(initData, joinContext().joinToken)
if (!payload.authorized || !payload.member || !payload.telegramUser) {
setLocale(
payload.onboarding?.householdDefaultLocale ??
((payload.telegramUser?.languageCode ?? fallbackLocale).startsWith('ru') ? 'ru' : 'en')
)
setSession({
status: 'onboarding',
mode: payload.onboarding?.status ?? 'open_from_group',
@@ -192,6 +204,7 @@ function App() {
return
}
setLocale(payload.member.preferredLocale ?? payload.member.householdDefaultLocale)
setSession({
status: 'ready',
mode: 'live',
@@ -284,6 +297,7 @@ function App() {
try {
const payload = await joinMiniAppHousehold(initData, joinToken)
if (payload.authorized && payload.member && payload.telegramUser) {
setLocale(payload.member.preferredLocale ?? payload.member.householdDefaultLocale)
setSession({
status: 'ready',
mode: 'live',
@@ -297,6 +311,10 @@ function App() {
return
}
setLocale(
payload.onboarding?.householdDefaultLocale ??
((payload.telegramUser?.languageCode ?? locale()).startsWith('ru') ? 'ru' : 'en')
)
setSession({
status: 'onboarding',
mode: payload.onboarding?.status ?? 'pending',
@@ -339,6 +357,71 @@ function App() {
}
}
async function handleMemberLocaleChange(nextLocale: Locale) {
const initData = webApp?.initData?.trim()
const currentReady = readySession()
setLocale(nextLocale)
if (!initData || currentReady?.mode !== 'live') {
return
}
setSavingMemberLocale(true)
try {
const updated = await updateMiniAppLocalePreference(initData, nextLocale, 'member')
setSession((current) =>
current.status === 'ready'
? {
...current,
member: {
...current.member,
preferredLocale: updated.memberPreferredLocale,
householdDefaultLocale: updated.householdDefaultLocale
}
}
: current
)
setLocale(updated.effectiveLocale)
} finally {
setSavingMemberLocale(false)
}
}
async function handleHouseholdLocaleChange(nextLocale: Locale) {
const initData = webApp?.initData?.trim()
const currentReady = readySession()
if (!initData || currentReady?.mode !== 'live' || !currentReady.member.isAdmin) {
return
}
setSavingHouseholdLocale(true)
try {
const updated = await updateMiniAppLocalePreference(initData, nextLocale, 'household')
setSession((current) =>
current.status === 'ready'
? {
...current,
member: {
...current.member,
householdDefaultLocale: updated.householdDefaultLocale
}
}
: current
)
if (!currentReady.member.preferredLocale) {
setLocale(updated.effectiveLocale)
}
} finally {
setSavingHouseholdLocale(false)
}
}
const renderPanel = () => {
switch (activeNav()) {
case 'balances':
@@ -396,6 +479,34 @@ function App() {
case 'house':
return readySession()?.member.isAdmin ? (
<div class="balance-list">
<article class="balance-item">
<header>
<strong>{copy().householdLanguage}</strong>
<span>{readySession()?.member.householdDefaultLocale.toUpperCase()}</span>
</header>
<div class="locale-switch__buttons">
<button
classList={{
'is-active': readySession()?.member.householdDefaultLocale === 'en'
}}
type="button"
disabled={savingHouseholdLocale()}
onClick={() => void handleHouseholdLocaleChange('en')}
>
EN
</button>
<button
classList={{
'is-active': readySession()?.member.householdDefaultLocale === 'ru'
}}
type="button"
disabled={savingHouseholdLocale()}
onClick={() => void handleHouseholdLocaleChange('ru')}
>
RU
</button>
</div>
</article>
<article class="balance-item">
<header>
<strong>{copy().pendingMembersTitle}</strong>
@@ -470,14 +581,16 @@ function App() {
<button
classList={{ 'is-active': locale() === 'en' }}
type="button"
onClick={() => setLocale('en')}
disabled={savingMemberLocale()}
onClick={() => void handleMemberLocaleChange('en')}
>
EN
</button>
<button
classList={{ 'is-active': locale() === 'ru' }}
type="button"
onClick={() => setLocale('ru')}
disabled={savingMemberLocale()}
onClick={() => void handleMemberLocaleChange('ru')}
>
RU
</button>

View File

@@ -27,6 +27,8 @@ export const dictionary = {
'The real session gate needs Telegram mini app data. Local development falls back to a preview shell.',
reload: 'Retry',
language: 'Language',
householdLanguage: 'Household language',
savingLanguage: 'Saving…',
home: 'Home',
balances: 'Balances',
ledger: 'Ledger',
@@ -90,6 +92,8 @@ export const dictionary = {
'Настоящая проверка требует данные Telegram Mini App. Локально показывается демо-оболочка.',
reload: 'Повторить',
language: 'Язык',
householdLanguage: 'Язык дома',
savingLanguage: 'Сохраняем…',
home: 'Главная',
balances: 'Баланс',
ledger: 'Леджер',

View File

@@ -5,6 +5,8 @@ export interface MiniAppSession {
member?: {
displayName: string
isAdmin: boolean
preferredLocale: 'en' | 'ru' | null
householdDefaultLocale: 'en' | 'ru'
}
telegramUser?: {
firstName: string | null
@@ -14,9 +16,17 @@ export interface MiniAppSession {
onboarding?: {
status: 'join_required' | 'pending' | 'open_from_group'
householdName?: string
householdDefaultLocale?: 'en' | 'ru'
}
}
export interface MiniAppLocalePreference {
scope: 'member' | 'household'
effectiveLocale: 'en' | 'ru'
memberPreferredLocale: 'en' | 'ru' | null
householdDefaultLocale: 'en' | 'ru'
}
export interface MiniAppPendingMember {
telegramUserId: string
displayName: string
@@ -219,3 +229,34 @@ export async function approveMiniAppPendingMember(
throw new Error(payload.error ?? 'Failed to approve member')
}
}
export async function updateMiniAppLocalePreference(
initData: string,
locale: 'en' | 'ru',
scope: 'member' | 'household'
): Promise<MiniAppLocalePreference> {
const response = await fetch(`${apiBaseUrl()}/api/miniapp/preferences/locale`, {
method: 'POST',
headers: {
'content-type': 'application/json'
},
body: JSON.stringify({
initData,
locale,
scope
})
})
const payload = (await response.json()) as {
ok: boolean
authorized?: boolean
locale?: MiniAppLocalePreference
error?: string
}
if (!response.ok || !payload.authorized || !payload.locale) {
throw new Error(payload.error ?? 'Failed to update locale preference')
}
return payload.locale
}