d3ro-voice/apps/mobile-rn/src/screens/SettingsScreen.tsx
Yun Chan b6fe588a7c feat(web): serve the web app under /app and send every billing link there (WS-B)
apps/web was never deployed, so /billing on the public domain returned the
landing page and d3ro.dev (desktop "upgrade") did not resolve.

- apps/web runs with basePath /app and output standalone; /download and
  /releases redirect to the site's #download. A Dockerfile and a d3ro-web
  compose service (port 3002) deploy it to the NAS with the other images.
- The site bridge worker forwards /app/* to WEB_APP_ORIGIN (the tunnel host)
  and rewrites upstream redirects; everything else still goes to Pages.
  With no origin configured /app answers 503 instead of the landing page.
- Desktop upgrade, desktop Stripe return, mobile subscription management,
  the web checkout/portal returns and the site all use billingUrl(); the
  return query is success=1 / canceled=1, which the billing page reads.
  The billing page highlights ?tier=pro|pro_plus, and signing in from a
  billing link returns to the same plan.
- auth/callback pins the redirect origin in production and rejects
  protocol-relative next= values (open redirect).
- Mobile legal links use SITE_URLS (fixes the missing slash on /terms).
- Compose drops the unused NEXT_PUBLIC_API_URL and the dead wwwroot legal
  mounts; deploy scripts add the web image and the SUPABASE_* values the NAS
  compose already required; .dockerignore keeps app .env files out of images.
- Supabase auth redirects allow /app/** (remote dashboard must match).

Policy: docs/REFACTOR_POLICY.md Wave 3, W3-3 and W3-4.
2026-09-26 15:48:30 +09:00

818 lines
25 KiB
TypeScript

import { useState } from 'react'
import {
Alert,
Linking,
Pressable,
ScrollView,
StyleSheet,
Switch,
View,
} from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useNavigation } from '@react-navigation/native'
import { useI18n, type TranslationKey } from '@d3ro/i18n'
import { SITE_URLS } from '@d3ro/core/web-urls'
import { useAuth } from '../lib/auth-context'
import { useEntitlement } from '../lib/entitlement-context'
import {
useMobilePreferences,
type MobilePreferencesPatch,
type PreferencesErrorCode,
type PreferencesSyncStatus,
type SupportedMobileLocale,
} from '../lib/preferences-context'
import {
MOBILE_THEME_MODES,
type MobileThemeMode,
} from '../theme/mobile-theme'
import {
ThemeButton,
ThemeCard,
ThemeText,
} from '../theme/themed-components'
import { useMobileAds } from '../lib/mobile-ads-context'
import { useDevice } from '../lib/device-context'
import { currentAdminRole } from '../features/admin/admin-service'
import { performAccountLogout } from '../lib/account-exit'
type SavingPreference = 'theme' | 'locale' | 'autoPolish' | 'haptic' | null
const THEME_LABEL_KEYS: Record<MobileThemeMode, TranslationKey> = {
system: 'mobile.theme.system',
light: 'mobile.theme.light',
dark: 'mobile.theme.dark',
}
const SYNC_STATUS_KEYS: Record<PreferencesSyncStatus, TranslationKey> = {
loading: 'mobile.preferences.statusLoading',
local: 'mobile.preferences.statusLocal',
saving: 'mobile.preferences.statusSaving',
synced: 'mobile.preferences.statusSynced',
offline: 'mobile.preferences.statusOffline',
error: 'mobile.preferences.statusError',
}
const ERROR_KEYS: Record<PreferencesErrorCode, TranslationKey> = {
CACHE_READ_FAILED: 'mobile.preferences.cacheReadError',
CACHE_WRITE_FAILED: 'mobile.preferences.cacheWriteError',
SYNC_FAILED: 'mobile.preferences.syncError',
SYNC_CONFLICT: 'mobile.preferences.conflictError',
}
export default function SettingsScreen(): React.ReactElement {
const insets = useSafeAreaInsets()
const { t, locale, setLocale, formatTime } = useI18n()
const { user, purgeLocalSession } = useAuth()
const navigation = useNavigation<any>()
const {
preferences,
palette,
syncStatus,
errorCode,
lastSyncedAt,
updatePreferences,
retrySync,
} = useMobilePreferences()
const [savingPreference, setSavingPreference] = useState<SavingPreference>(null)
const [loggingOut, setLoggingOut] = useState(false)
const [retrying, setRetrying] = useState(false)
const { snapshot: entitlement } = useEntitlement()
const mobileAds = useMobileAds()
const { currentDevice } = useDevice()
const adminRole = currentAdminRole(user?.app_metadata)
const initials = user?.email
? user.email.substring(0, 2).toUpperCase()
: 'US'
const syncLabel = t(SYNC_STATUS_KEYS[syncStatus])
const syncError = errorCode === null ? null : t(ERROR_KEYS[errorCode])
const syncedTime = lastSyncedAt === null
? null
: formatTime(Date.parse(lastSyncedAt))
async function persistPreference(
key: Exclude<SavingPreference, null>,
patch: MobilePreferencesPatch,
): Promise<void> {
if (savingPreference !== null) return
setSavingPreference(key)
const result = await updatePreferences(patch)
setSavingPreference(null)
if (!result.localSaved) {
Alert.alert(
t('mobile.preferences.saveFailedTitle'),
t('mobile.preferences.cacheWriteError'),
)
}
}
function selectLocale(nextLocale: SupportedMobileLocale): void {
if (locale === nextLocale || savingPreference !== null) return
setLocale(nextLocale)
void persistPreference('locale', { locale: nextLocale })
}
async function performLogout(): Promise<void> {
if (loggingOut) return
setLoggingOut(true)
try {
await performAccountLogout({
userId: user?.id ?? null,
deviceId: currentDevice?.id ?? null,
purgeLocalSession,
})
} catch {
Alert.alert(t('mobile.auth.errorTitle'), t('mobile.auth.logoutFailed'))
} finally {
setLoggingOut(false)
}
}
function confirmLogout(): void {
Alert.alert(t('mobile.set.logout'), t('mobile.set.logoutConfirm'), [
{ text: t('common.cancel'), style: 'cancel' },
{
text: t('mobile.set.logout'),
style: 'destructive',
onPress: () => void performLogout(),
},
])
}
async function handleRetry(): Promise<void> {
if (retrying) return
setRetrying(true)
await retrySync()
setRetrying(false)
}
async function openAdPrivacyOptions(): Promise<void> {
const opened = await mobileAds.showPrivacyOptions()
if (!opened) {
Alert.alert(
t('mobile.set.adPrivacy'),
t('mobile.set.adPrivacyUnavailable'),
)
}
}
return (
<ScrollView
style={[styles.container, { backgroundColor: palette.bg.app }]}
contentContainerStyle={styles.content}
testID="settings-screen"
>
<ThemeText
variant="title"
color="primary"
accessibilityRole="header"
style={[
styles.title,
{
paddingTop: insets.top + 8,
borderBottomColor: palette.border.subtle,
},
]}
>
{t('mobile.set.title')}
</ThemeText>
<SectionLabel label={t('mobile.set.accountPlan')} />
<ThemeCard style={styles.section}>
<Pressable
accessibilityRole="button"
accessibilityLabel={t('mobile.set.openAccount')}
accessibilityHint={t('mobile.set.openAccountHint')}
testID="open-account"
onPress={() => navigation.navigate('Account')}
style={({ pressed }) => [
styles.profileRow,
{ borderBottomColor: palette.border.subtle },
pressed && { backgroundColor: palette.bg.cardHover },
]}
>
<View
style={[
styles.avatar,
{
backgroundColor: palette.bg.inset,
borderColor: palette.border.default,
},
]}
>
<ThemeText variant="body" color="accent">{initials}</ThemeText>
</View>
<View style={styles.profileInfo}>
<ThemeText variant="body" color="primary" numberOfLines={1}>
{user?.email?.split('@')[0] ?? t('mobile.set.userFallback')}
</ThemeText>
<ThemeText variant="small" color="muted" numberOfLines={1}>
{user?.email ?? '—'}
</ThemeText>
</View>
<ThemeText variant="label" color="accent">
{t('mobile.set.edit')}
</ThemeText>
</Pressable>
<View style={[styles.row, { backgroundColor: palette.bg.inset }]}>
<ThemeText variant="small" color="muted">
{t('mobile.dash.tier')}
</ThemeText>
<View style={styles.rowRight}>
<View style={[styles.statusDot, { backgroundColor: palette.accent.main }]} />
<ThemeText variant="body" color="accent" style={styles.rowValue}>
{entitlement.tier === 'pro_plus'
? 'PRO+'
: entitlement.tier === 'pro'
? 'PRO'
: t('mobile.dash.free')}
</ThemeText>
</View>
</View>
<Pressable
accessibilityRole="button"
accessibilityLabel={t('mobile.set.openPlans')}
accessibilityHint={t('mobile.set.openPlansHint')}
testID="open-paywall"
onPress={() => navigation.navigate('ProPaywall')}
style={({ pressed }) => [
styles.upgradeSettingRow,
{
borderTopColor: palette.accent.dim,
backgroundColor: pressed ? palette.bg.cardHover : palette.accent.dim,
},
]}
>
<ThemeText variant="body" color="accent" style={styles.upgradeText}>
{t('mobile.set.planAndRewards')}
</ThemeText>
<ThemeText variant="heading" color="accent">→</ThemeText>
</Pressable>
<Pressable
accessibilityRole="button"
accessibilityLabel={t('mobile.devices.title')}
accessibilityHint={t('mobile.devices.description')}
testID="open-devices"
onPress={() => navigation.navigate('Devices')}
style={({ pressed }) => [
styles.upgradeSettingRow,
{
borderTopColor: palette.border.subtle,
backgroundColor: pressed ? palette.bg.cardHover : palette.bg.card,
},
]}
>
<View style={styles.rowText}>
<ThemeText variant="body" color="primary">
{t('mobile.devices.title')}
</ThemeText>
<ThemeText variant="small" color="muted">
{t('mobile.devices.description')}
</ThemeText>
</View>
<ThemeText variant="heading" color="accent">→</ThemeText>
</Pressable>
{adminRole !== null && (
<Pressable
accessibilityRole="button"
accessibilityLabel={t('mobile.admin.title')}
accessibilityHint={t('mobile.admin.description', {
role: t(`mobile.admin.role.${adminRole}`),
})}
testID="open-admin"
onPress={() => navigation.navigate('Admin')}
style={({ pressed }) => [
styles.upgradeSettingRow,
{
borderTopColor: palette.border.subtle,
backgroundColor: pressed ? palette.bg.cardHover : palette.bg.card,
},
]}
>
<View style={styles.rowText}>
<ThemeText variant="body" color="primary">{t('mobile.admin.title')}</ThemeText>
<ThemeText variant="small" color="muted">
{t('mobile.admin.description', { role: t(`mobile.admin.role.${adminRole}`) })}
</ThemeText>
</View>
<ThemeText variant="heading" color="accent">→</ThemeText>
</Pressable>
)}
</ThemeCard>
<SectionLabel label={t('mobile.set.appearance')} />
<ThemeCard style={styles.section}>
<SettingHeader
title={t('mobile.set.theme')}
description={t('mobile.set.themeDesc')}
/>
<View
style={styles.segmentRow}
accessibilityRole="radiogroup"
accessibilityLabel={t('mobile.set.theme')}
>
{MOBILE_THEME_MODES.map((mode) => (
<SegmentOption
key={mode}
label={t(THEME_LABEL_KEYS[mode])}
selected={preferences.themeMode === mode}
disabled={savingPreference !== null}
testID={`settings-theme-${mode}`}
onPress={() => void persistPreference('theme', { themeMode: mode })}
/>
))}
</View>
<View
style={[
styles.settingBlock,
{ borderTopColor: palette.border.subtle },
]}
>
<SettingHeader
title={t('mobile.set.language')}
description={t('mobile.set.languageDesc')}
/>
<View
style={styles.segmentRow}
accessibilityRole="radiogroup"
accessibilityLabel={t('mobile.set.language')}
>
<SegmentOption
label="한국어"
selected={locale === 'ko'}
disabled={savingPreference !== null}
testID="settings-language-ko"
onPress={() => selectLocale('ko')}
/>
<SegmentOption
label="English"
selected={locale === 'en'}
disabled={savingPreference !== null}
testID="settings-language-en"
onPress={() => selectLocale('en')}
/>
</View>
</View>
</ThemeCard>
<SectionLabel label={t('mobile.set.backendConfig')} />
<ThemeCard style={styles.section}>
<View style={styles.row}>
<View style={styles.rowText}>
<ThemeText variant="body" color="primary">
{t('mobile.set.llmModel')}
</ThemeText>
<ThemeText variant="small" color="muted">
{t('mobile.set.llmDesc')}
</ThemeText>
</View>
<ThemeText variant="small" color="accent" numberOfLines={1}>
{(preferences.preferredLlmModel ?? t('mobile.set.automatic')).toUpperCase()}
</ThemeText>
</View>
<View
style={[
styles.row,
styles.rowBorder,
{ borderTopColor: palette.border.subtle },
]}
>
<View style={styles.rowText}>
<ThemeText variant="body" color="primary">
{t('mobile.set.cloudStt')}
</ThemeText>
<ThemeText variant="small" color="muted">
{t('mobile.set.cloudSttManagedDesc')}
</ThemeText>
</View>
<ThemeText variant="small" color="success">
{t('mobile.set.serverManaged')}
</ThemeText>
</View>
</ThemeCard>
<SectionLabel label={t('mobile.set.preferences')} />
<ThemeCard style={styles.section}>
<PreferenceSwitch
label={t('mobile.set.autoPolish')}
description={t('mobile.set.autoPolishDesc')}
value={preferences.autoPolishEnabled}
disabled={savingPreference !== null}
testID="settings-auto-polish"
onValueChange={(value) => void persistPreference(
'autoPolish',
{ autoPolishEnabled: value },
)}
/>
<PreferenceSwitch
label={t('mobile.set.haptic')}
description={t('mobile.set.hapticDesc')}
value={preferences.hapticEnabled}
disabled={savingPreference !== null}
testID="settings-haptic"
bordered
onValueChange={(value) => void persistPreference(
'haptic',
{ hapticEnabled: value },
)}
/>
<Pressable
accessibilityRole="button"
accessibilityLabel={t('mobile.set.replayTutorial')}
accessibilityHint={t('mobile.set.replayTutorialHint')}
testID="settings-replay-onboarding"
onPress={() => navigation.navigate('Onboarding', { replay: true })}
style={({ pressed }) => [
styles.replayRow,
{
borderTopColor: palette.border.subtle,
backgroundColor: pressed ? palette.bg.cardHover : 'transparent',
},
]}
>
<View style={styles.rowText}>
<ThemeText variant="body" color="primary">
{t('mobile.set.replayTutorial')}
</ThemeText>
<ThemeText variant="small" color="muted">
{t('mobile.set.replayTutorialDesc')}
</ThemeText>
</View>
<ThemeText variant="heading" color="accent">→</ThemeText>
</Pressable>
</ThemeCard>
{mobileAds.privacyOptionsRequired ? (
<>
<SectionLabel label={t('mobile.set.adPrivacy')} />
<ThemeCard style={styles.section}>
<View style={styles.privacyBlock}>
<View style={styles.rowText}>
<ThemeText variant="body" color="primary">
{t('mobile.set.adPrivacyRequired')}
</ThemeText>
<ThemeText variant="small" color="muted">
{t('mobile.set.adPrivacyDesc')}
</ThemeText>
</View>
<ThemeButton
label={t('mobile.set.adPrivacyOpen')}
variant="secondary"
onPress={() => void openAdPrivacyOptions()}
testID="settings-ad-privacy"
/>
</View>
</ThemeCard>
</>
) : null}
<SectionLabel label={t('mobile.set.legal')} />
<ThemeCard style={styles.section}>
<LegalLinkRow
label={t('mobile.set.privacyPolicy')}
hint={t('mobile.set.privacyPolicyHint')}
testID="settings-privacy-policy"
onPress={() => void Linking.openURL(SITE_URLS.privacy)}
/>
<LegalLinkRow
label={t('mobile.set.termsOfService')}
hint={t('mobile.set.termsOfServiceHint')}
testID="settings-terms-of-service"
bordered
onPress={() => void Linking.openURL(SITE_URLS.terms)}
/>
<LegalLinkRow
label={t('mobile.set.accountDeletion')}
hint={t('mobile.set.accountDeletionHint')}
testID="settings-account-deletion-web"
bordered
onPress={() => void Linking.openURL(SITE_URLS.deleteAccount)}
/>
</ThemeCard>
<SectionLabel label={t('mobile.preferences.syncTitle')} />
<ThemeCard
style={[
styles.section,
errorCode !== null && { borderColor: palette.tag.red },
]}
accessibilityRole={errorCode !== null ? 'alert' : undefined}
testID="settings-sync-status"
>
<View style={styles.syncHeader}>
<View
style={[
styles.syncDot,
{
backgroundColor: syncStatus === 'synced'
? palette.tag.green
: syncStatus === 'offline' || syncStatus === 'error'
? palette.tag.red
: palette.tag.orange,
},
]}
/>
<View style={styles.rowText}>
<ThemeText variant="body" color="primary">
{syncLabel}
</ThemeText>
<ThemeText variant="small" color={errorCode === null ? 'muted' : 'danger'}>
{syncError
?? (syncedTime === null
? t('mobile.preferences.notSyncedYet')
: t('mobile.preferences.lastSynced', { time: syncedTime }))}
</ThemeText>
</View>
</View>
{(syncStatus === 'offline' || syncStatus === 'error') && (
<ThemeButton
label={retrying
? t('mobile.preferences.retrying')
: t('mobile.preferences.retry')}
variant="secondary"
disabled={retrying}
onPress={() => void handleRetry()}
testID="settings-sync-retry"
style={styles.retryButton}
/>
)}
</ThemeCard>
<View style={styles.logoutWrap}>
<ThemeButton
label={loggingOut ? t('mobile.set.loggingOut') : t('mobile.set.logout')}
variant="secondary"
disabled={loggingOut}
onPress={confirmLogout}
testID="settings-logout"
/>
</View>
<View style={styles.footer}>
<View style={[styles.statusDot, { backgroundColor: palette.tag.green }]} />
<ThemeText variant="label" color="muted" style={styles.footerText}>
{t('mobile.set.statusFooter')}
</ThemeText>
</View>
</ScrollView>
)
}
function SectionLabel({ label }: { label: string }): React.ReactElement {
return (
<ThemeText variant="label" color="muted" style={styles.sectionLabel}>
{label}
</ThemeText>
)
}
function SettingHeader({
title,
description,
}: {
title: string
description: string
}): React.ReactElement {
return (
<View style={styles.settingHeader}>
<ThemeText variant="body" color="primary">{title}</ThemeText>
<ThemeText variant="small" color="muted">{description}</ThemeText>
</View>
)
}
function SegmentOption({
label,
selected,
disabled,
testID,
onPress,
}: {
label: string
selected: boolean
disabled: boolean
testID: string
onPress: () => void
}): React.ReactElement {
const { palette } = useMobilePreferences()
return (
<Pressable
accessibilityRole="radio"
accessibilityState={{ selected, disabled }}
accessibilityLabel={label}
testID={testID}
disabled={disabled}
onPress={onPress}
style={({ pressed }) => [
styles.segmentOption,
{
borderColor: selected ? palette.accent.main : palette.border.default,
backgroundColor: selected ? palette.accent.dim : palette.bg.inset,
opacity: disabled ? 0.5 : pressed ? 0.72 : 1,
},
]}
>
<ThemeText variant="small" color={selected ? 'accent' : 'primary'}>
{label}
</ThemeText>
</Pressable>
)
}
function PreferenceSwitch({
label,
description,
value,
disabled,
bordered = false,
testID,
onValueChange,
}: {
label: string
description: string
value: boolean
disabled: boolean
bordered?: boolean
testID: string
onValueChange: (value: boolean) => void
}): React.ReactElement {
const { palette } = useMobilePreferences()
return (
<View
style={[
styles.preferenceRow,
bordered && {
borderTopColor: palette.border.subtle,
borderTopWidth: 1,
},
]}
>
<View style={styles.rowText}>
<ThemeText variant="body" color="primary">{label}</ThemeText>
<ThemeText variant="small" color="muted">{description}</ThemeText>
</View>
<Switch
accessibilityLabel={label}
accessibilityState={{ checked: value, disabled }}
testID={testID}
value={value}
disabled={disabled}
onValueChange={onValueChange}
trackColor={{
false: palette.bg.inset,
true: palette.accent.main,
}}
ios_backgroundColor={palette.bg.inset}
thumbColor={palette.text.white}
/>
</View>
)
}
function LegalLinkRow({
label,
hint,
testID,
bordered = false,
onPress,
}: {
label: string
hint: string
testID: string
bordered?: boolean
onPress: () => void
}): React.ReactElement {
const { palette } = useMobilePreferences()
return (
<Pressable
accessibilityRole="link"
accessibilityLabel={label}
accessibilityHint={hint}
testID={testID}
onPress={onPress}
style={({ pressed }) => [
styles.legalRow,
bordered && {
borderTopColor: palette.border.subtle,
borderTopWidth: 1,
},
pressed && { backgroundColor: palette.bg.cardHover },
]}
>
<View style={styles.rowText}>
<ThemeText variant="body" color="primary">{label}</ThemeText>
<ThemeText variant="small" color="muted">{hint}</ThemeText>
</View>
<ThemeText variant="heading" color="accent">↗</ThemeText>
</Pressable>
)
}
const styles = StyleSheet.create({
container: { flex: 1 },
content: { paddingBottom: 120 },
title: {
paddingHorizontal: 20,
paddingBottom: 12,
borderBottomWidth: 1,
letterSpacing: 1.2,
},
sectionLabel: {
paddingHorizontal: 24,
paddingTop: 22,
paddingBottom: 8,
letterSpacing: 0.4,
},
section: { marginHorizontal: 20, padding: 0, overflow: 'hidden' },
profileRow: {
minHeight: 74,
flexDirection: 'row',
alignItems: 'center',
padding: 15,
gap: 12,
borderBottomWidth: 1,
},
avatar: {
width: 44,
height: 44,
borderRadius: 22,
borderWidth: 1,
justifyContent: 'center',
alignItems: 'center',
},
profileInfo: { flex: 1, gap: 3 },
row: {
minHeight: 64,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
gap: 14,
padding: 15,
},
rowBorder: { borderTopWidth: 1 },
rowRight: { flexDirection: 'row', alignItems: 'center' },
rowValue: { marginLeft: 8 },
rowText: { flex: 1, gap: 4 },
statusDot: { width: 7, height: 7, borderRadius: 4 },
upgradeSettingRow: {
minHeight: 58,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
gap: 12,
padding: 15,
borderTopWidth: 1,
},
upgradeText: { flex: 1 },
settingHeader: { gap: 4, padding: 15, paddingBottom: 10 },
settingBlock: { borderTopWidth: 1, marginTop: 15, paddingTop: 2 },
segmentRow: { flexDirection: 'row', gap: 8, paddingHorizontal: 15, paddingBottom: 15 },
segmentOption: {
minHeight: 48,
flex: 1,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
borderRadius: 9,
paddingHorizontal: 8,
},
preferenceRow: {
minHeight: 72,
flexDirection: 'row',
alignItems: 'center',
gap: 14,
padding: 15,
},
replayRow: {
minHeight: 72,
flexDirection: 'row',
alignItems: 'center',
gap: 12,
padding: 15,
borderTopWidth: 1,
},
legalRow: {
minHeight: 68,
flexDirection: 'row',
alignItems: 'center',
gap: 12,
padding: 15,
},
syncHeader: { flexDirection: 'row', alignItems: 'center', gap: 12, padding: 15 },
syncDot: { width: 10, height: 10, borderRadius: 5 },
retryButton: { marginHorizontal: 15, marginBottom: 15 },
privacyBlock: { gap: 14, padding: 15 },
logoutWrap: { margin: 20 },
footer: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
gap: 8,
paddingVertical: 8,
},
footerText: { fontSize: 8, letterSpacing: 0.4 },
})