chore(mobile): v0.2.0 source snapshot for reproducibility

- app.json: version 0.2.0 / versionCode 2, drop expo-screen-capture plugin
- add lib/i18n (ko/en/ja/zh), lib/roleTrack, LanguageContext + LanguageSelector
- profile/security hardening across screens (login/register/forgot/profile/tabs)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zio 2026-07-13 00:29:38 +09:00
parent 0add2cd417
commit 490b723a45
26 changed files with 1497 additions and 235 deletions

View File

@ -7,6 +7,19 @@ React Native + Expo(expo-router, TypeScript) 모바일 앱. 조회·승인·현
- Expo SDK 51 · expo-router ~3.5 · React Native 0.74 · TypeScript - Expo SDK 51 · expo-router ~3.5 · React Native 0.74 · TypeScript
- JWT는 `expo-secure-store`(네이티브 keychain/keystore)에 저장, 웹은 AsyncStorage 폴백 - JWT는 `expo-secure-store`(네이티브 keychain/keystore)에 저장, 웹은 AsyncStorage 폴백
- 디자인 토큰: `theme/`(docs/design.md §1 팔레트·타이포). 신규 토큰 없음. - 디자인 토큰: `theme/`(docs/design.md §1 팔레트·타이포). 신규 토큰 없음.
- 다국어: `react-i18next` + `expo-localization`(SDK51 호환). 지원 ko(기본/폴백)·en·zh·ja.
## 다국어(i18n)
- 초기화 `lib/i18n/`(동기 init — 디바이스 로케일 감지, 저장 선택은 부팅 시 반영). 리소스 `lib/i18n/locales/{ko,en,zh,ja}.ts`.
- **ko가 키 형상의 단일 출처** — en/zh/ja는 `Translations`(DeepString) 타입 구현이라 키 누락 시 tsc가 잡음(병렬 편집 함정 회피: 직렬·키 대조).
- 언어 선택 지속: AsyncStorage 키 `kintex_lang`. 전환 UI `components/LanguageSelector.tsx`(더보기·내정보에 배치), 상태 `context/LanguageContext.tsx`.
- 커버 화면(우선순위): 로그인/2FA·회원가입·비번찾기·홈·현장·갤러리·더보기·내정보·티켓 지갑·탭/스택 타이틀·역할 라벨. 미커버(폴백=현행 한국어): `checklist`·`inspection`·`tickets/select`·티켓 카드/필터탭 컴포넌트(`components/tickets/*`).
## 역할별 랜딩
- `lib/roleTrack.ts` — 웹 `src/frontend/src/lib/roleTrack.ts` 패리티(우선순위 admin>ops>business>agency>visitor, 미판정=visitor 기본).
- 로그인 응답 user에 roleCode가 없어 `hallManager`를 ops 신호로 사용(roleCode 아는 호출부는 옵션 전달).
- 모바일 랜딩 매핑: admin/ops/business→`/(tabs)` 홈 · agency(장치·시공)→`/(tabs)/field` · visitor(관람객)→`/tickets`.
- 콜드 부팅 시 workspaces 미복원 misroute 방지 위해 로그인 시 계산한 랜딩 경로를 secure-store에 지속(`AuthContext`).
## 실행 ## 실행
```bash ```bash
@ -48,5 +61,6 @@ API 베이스는 `app.json > extra.apiBase` 또는 `EXPO_PUBLIC_API_BASE` 환경
## 남은 작업 ## 남은 작업
- 실 화면 데이터 API 배선 확대(부스 목록·검수 요청 제출 — 백엔드 M6/C-4 확장 대기). - 실 화면 데이터 API 배선 확대(부스 목록·검수 요청 제출 — 백엔드 M6/C-4 확장 대기).
- 앱 아이콘/스플래시 에셋(`assets/`), i18n, 푸시 알림. - 앱 아이콘/스플래시 에셋(`assets/`), 푸시 알림.
- EAS 실빌드(eas.json 프로파일) — 본 스캐폴드 범위 밖. - i18n 잔여 화면 키 추출: `checklist`·`inspection`·`tickets/select`·`components/tickets/*`(현재 한국어 폴백).
- EAS 실빌드(eas.json 프로파일) — G3 게이트(소유자 승인).

View File

@ -4,7 +4,7 @@
"slug": "kintex", "slug": "kintex",
"owner": "zioinfo", "owner": "zioinfo",
"scheme": "kintex", "scheme": "kintex",
"version": "0.1.0", "version": "0.2.0",
"orientation": "portrait", "orientation": "portrait",
"userInterfaceStyle": "light", "userInterfaceStyle": "light",
"jsEngine": "hermes", "jsEngine": "hermes",
@ -28,7 +28,7 @@
}, },
"android": { "android": {
"package": "kr.co.zioinfo.kintex", "package": "kr.co.zioinfo.kintex",
"versionCode": 1, "versionCode": 2,
"adaptiveIcon": { "adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png", "foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#0066B3" "backgroundColor": "#0066B3"
@ -58,7 +58,6 @@
"expo-router", "expo-router",
"expo-secure-store", "expo-secure-store",
"expo-font", "expo-font",
"expo-screen-capture",
[ [
"expo-image-picker", "expo-image-picker",
{ {

View File

@ -5,10 +5,12 @@
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { Redirect, Tabs } from 'expo-router'; import { Redirect, Tabs } from 'expo-router';
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../../context/AuthContext'; import { useAuth } from '../../context/AuthContext';
import { colors } from '../../theme'; import { colors } from '../../theme';
export default function TabsLayout() { export default function TabsLayout() {
const { t } = useTranslation();
const { ready, token } = useAuth(); const { ready, token } = useAuth();
if (ready && !token) return <Redirect href="/login" />; if (ready && !token) return <Redirect href="/login" />;
@ -25,14 +27,14 @@ export default function TabsLayout() {
<Tabs.Screen <Tabs.Screen
name="index" name="index"
options={{ options={{
title: '홈', title: t('nav.tabHome'),
tabBarIcon: ({ color, size }) => <Ionicons name="home-outline" color={color} size={size} />, tabBarIcon: ({ color, size }) => <Ionicons name="home-outline" color={color} size={size} />,
}} }}
/> />
<Tabs.Screen <Tabs.Screen
name="field" name="field"
options={{ options={{
title: '현장', title: t('nav.tabField'),
tabBarIcon: ({ color, size }) => ( tabBarIcon: ({ color, size }) => (
<Ionicons name="clipboard-outline" color={color} size={size} /> <Ionicons name="clipboard-outline" color={color} size={size} />
), ),
@ -41,14 +43,14 @@ export default function TabsLayout() {
<Tabs.Screen <Tabs.Screen
name="gallery" name="gallery"
options={{ options={{
title: '갤러리', title: t('nav.tabGallery'),
tabBarIcon: ({ color, size }) => <Ionicons name="images-outline" color={color} size={size} />, tabBarIcon: ({ color, size }) => <Ionicons name="images-outline" color={color} size={size} />,
}} }}
/> />
<Tabs.Screen <Tabs.Screen
name="more" name="more"
options={{ options={{
title: '더보기', title: t('nav.tabMore'),
tabBarIcon: ({ color, size }) => ( tabBarIcon: ({ color, size }) => (
<Ionicons name="ellipsis-horizontal" color={color} size={size} /> <Ionicons name="ellipsis-horizontal" color={color} size={size} />
), ),

View File

@ -5,12 +5,14 @@
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { router } from 'expo-router'; import { router } from 'expo-router';
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next';
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { Banner } from '../../components/Banner'; import { Banner } from '../../components/Banner';
import { useAuth } from '../../context/AuthContext'; import { useAuth } from '../../context/AuthContext';
import { colors, radius, spacing, type } from '../../theme'; import { colors, radius, spacing, type } from '../../theme';
export default function FieldScreen() { export default function FieldScreen() {
const { t } = useTranslation();
const { activeWorkspace, user } = useAuth(); const { activeWorkspace, user } = useAuth();
const role = activeWorkspace?.myRole; const role = activeWorkspace?.myRole;
const isHallManager = user?.hallManager || role === 'HALL_MANAGER'; const isHallManager = user?.hallManager || role === 'HALL_MANAGER';
@ -18,19 +20,17 @@ export default function FieldScreen() {
return ( return (
<ScrollView contentContainerStyle={styles.scroll}> <ScrollView contentContainerStyle={styles.scroll}>
<Text style={styles.title}> </Text> <Text style={styles.title}>{t('field.title')}</Text>
{!isContractor && !isHallManager ? ( {!isContractor && !isHallManager ? (
<Banner tone="info"> <Banner tone="info">{t('field.guide')}</Banner>
·() () .
</Banner>
) : null} ) : null}
{isContractor ? ( {isContractor ? (
<Tile <Tile
icon="clipboard-outline" icon="clipboard-outline"
title="현장 체크리스트" title={t('field.checklistTitle')}
desc="장치 공정 확인 · 사진 첨부 · 검수 요청" desc={t('field.checklistDesc')}
onPress={() => router.push('/checklist')} onPress={() => router.push('/checklist')}
/> />
) : null} ) : null}
@ -38,8 +38,8 @@ export default function FieldScreen() {
{isHallManager ? ( {isHallManager ? (
<Tile <Tile
icon="shield-checkmark-outline" icon="shield-checkmark-outline"
title="현장 검수" title={t('field.inspectionTitle')}
desc="승인 도면 vs 현장 사진 · 적합/부적합 판정" desc={t('field.inspectionDesc')}
onPress={() => router.push('/inspection')} onPress={() => router.push('/inspection')}
/> />
) : null} ) : null}

View File

@ -4,7 +4,8 @@
* API , / degraded . * API , / degraded .
*/ */
import { router } from 'expo-router'; import { router } from 'expo-router';
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native'; import { RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native';
import { AiImage } from '../../components/AiImage'; import { AiImage } from '../../components/AiImage';
import { Banner } from '../../components/Banner'; import { Banner } from '../../components/Banner';
@ -18,14 +19,21 @@ import { colors, spacing, type } from '../../theme';
const DEMO_BOOTH = 'b-102'; const DEMO_BOOTH = 'b-102';
// degraded 폴백 샘플 (서버 미구현/오프라인 시 표시 — 항상 워터마크 유지) // degraded 폴백 샘플 (서버 미구현/오프라인 시 표시 — 항상 워터마크 유지)
const FALLBACK: RenderJobDto[] = [ function buildFallback(notice: string): RenderJobDto[] {
mkJob('job-s1', 'S1', 'DONE'), return [
mkJob('job-s2', 'S2', 'DONE'), mkJob('job-s1', 'S1', 'DONE', notice),
mkJob('job-s3', 'S3', 'RUNNING'), mkJob('job-s2', 'S2', 'DONE', notice),
mkJob('job-s7', 'S7', 'FAILED'), mkJob('job-s3', 'S3', 'RUNNING', notice),
]; mkJob('job-s7', 'S7', 'FAILED', notice),
];
}
function mkJob(jobId: string, shot: string, status: RenderJobDto['status']): RenderJobDto { function mkJob(
jobId: string,
shot: string,
status: RenderJobDto['status'],
notice: string,
): RenderJobDto {
return { return {
jobId, jobId,
boothId: DEMO_BOOTH, boothId: DEMO_BOOTH,
@ -36,7 +44,7 @@ function mkJob(jobId: string, shot: string, status: RenderJobDto['status']): Ren
modelVersion: null, modelVersion: null,
watermarkRequired: true, watermarkRequired: true,
watermarkText: AI_IMAGE_NOTICE, watermarkText: AI_IMAGE_NOTICE,
notice: 'AI 생성 이미지는 계약·심사 서류에 사용할 수 없습니다 (시공 기준은 도면)', notice,
}; };
} }
@ -51,15 +59,18 @@ const SHOT_LABEL: Record<string, string> = {
}; };
export default function GalleryScreen() { export default function GalleryScreen() {
const { t } = useTranslation();
const { activeWorkspace, token } = useAuth(); const { activeWorkspace, token } = useAuth();
const [jobs, setJobs] = useState<RenderJobDto[]>([]); const [jobs, setJobs] = useState<RenderJobDto[]>([]);
const [degraded, setDegraded] = useState(false); const [degraded, setDegraded] = useState(false);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const fallback = useMemo(() => buildFallback(t('gallery.notice')), [t]);
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true); setLoading(true);
if (!activeWorkspace || !token) { if (!activeWorkspace || !token) {
setJobs(FALLBACK); setJobs(fallback);
setDegraded(true); setDegraded(true);
setLoading(false); setLoading(false);
return; return;
@ -72,7 +83,7 @@ export default function GalleryScreen() {
setDegraded(false); setDegraded(false);
} catch (e) { } catch (e) {
if (isDegraded(e)) { if (isDegraded(e)) {
setJobs(FALLBACK); setJobs(fallback);
setDegraded(true); setDegraded(true);
} else { } else {
setJobs([]); setJobs([]);
@ -81,7 +92,7 @@ export default function GalleryScreen() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [activeWorkspace, token]); }, [activeWorkspace, token, fallback]);
useEffect(() => { useEffect(() => {
load(); load();
@ -91,9 +102,7 @@ export default function GalleryScreen() {
<View style={styles.flex}> <View style={styles.flex}>
{/* 상단 고정 고지 바 (제거 불가) */} {/* 상단 고정 고지 바 (제거 불가) */}
<View style={styles.infoBar}> <View style={styles.infoBar}>
<Text style={styles.infoBarText}> <Text style={styles.infoBarText}>{t('gallery.infoBar')}</Text>
AI ·
</Text>
</View> </View>
<ScrollView <ScrollView
@ -101,15 +110,11 @@ export default function GalleryScreen() {
refreshControl={<RefreshControl refreshing={loading} onRefresh={load} tintColor={colors.primary600} />} refreshControl={<RefreshControl refreshing={loading} onRefresh={load} tintColor={colors.primary600} />}
> >
{degraded ? ( {degraded ? (
<Banner tone="degraded"> <Banner tone="degraded">{t('gallery.degraded')}</Banner>
(degraded).
</Banner>
) : null} ) : null}
{jobs.length === 0 && !loading ? ( {jobs.length === 0 && !loading ? (
<Banner tone="info"> <Banner tone="info">{t('gallery.empty')}</Banner>
.
</Banner>
) : null} ) : null}
<View style={styles.grid}> <View style={styles.grid}>
@ -124,14 +129,14 @@ export default function GalleryScreen() {
height={130} height={130}
/> />
{j.status === 'FAILED' ? ( {j.status === 'FAILED' ? (
<Button label="다시 생성" variant="outline" onPress={load} style={styles.retry} /> <Button label={t('gallery.retry')} variant="outline" onPress={load} style={styles.retry} />
) : null} ) : null}
</View> </View>
))} ))}
</View> </View>
<Button <Button
label="설계 스튜디오는 데스크톱에서 편집" label={t('gallery.studioNote')}
variant="ghost" variant="ghost"
onPress={() => router.push('/(tabs)')} onPress={() => router.push('/(tabs)')}
/> />

View File

@ -5,6 +5,7 @@
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { router } from 'expo-router'; import { router } from 'expo-router';
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next';
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { Banner } from '../../components/Banner'; import { Banner } from '../../components/Banner';
import { Card } from '../../components/Card'; import { Card } from '../../components/Card';
@ -13,24 +14,18 @@ import { useAuth } from '../../context/AuthContext';
import type { EventRole, WorkspaceDto } from '../../lib/types'; import type { EventRole, WorkspaceDto } from '../../lib/types';
import { colors, radius, spacing, type } from '../../theme'; import { colors, radius, spacing, type } from '../../theme';
const roleLabel: Record<EventRole, string> = {
ORGANIZER: '주최자',
EXHIBITOR: '참가업체',
CONTRACTOR: '장치·시공업체',
HALL_MANAGER: '홀매니저',
};
export default function HomeScreen() { export default function HomeScreen() {
const { t } = useTranslation();
const { user, workspaces, activeWorkspace, selectWorkspace } = useAuth(); const { user, workspaces, activeWorkspace, selectWorkspace } = useAuth();
return ( return (
<ScrollView contentContainerStyle={styles.scroll}> <ScrollView contentContainerStyle={styles.scroll}>
<Text style={styles.hello}>{user?.displayName ?? '사용자'}, </Text> <Text style={styles.hello}>
{t('home.hello', { name: user?.displayName ?? t('common.user') })}
</Text>
{workspaces.length === 0 ? ( {workspaces.length === 0 ? (
<Banner tone="info"> <Banner tone="info">{t('home.noEvent')}</Banner>
.
</Banner>
) : null} ) : null}
{/* 행사 선택 */} {/* 행사 선택 */}
@ -63,8 +58,11 @@ export default function HomeScreen() {
{/* 진행 스테퍼 */} {/* 진행 스테퍼 */}
<Card> <Card>
<Text style={styles.cardTitle}> </Text> <Text style={styles.cardTitle}>{t('home.progress')}</Text>
<Stepper steps={['신청', '승인', '시공', '검수']} active={1} /> <Stepper
steps={[t('home.stepApply'), t('home.stepApprove'), t('home.stepBuild'), t('home.stepInspect')]}
active={1}
/>
</Card> </Card>
{/* 역할별 진입 */} {/* 역할별 진입 */}
@ -74,6 +72,7 @@ export default function HomeScreen() {
} }
function EventHeader({ ws }: { ws: WorkspaceDto }) { function EventHeader({ ws }: { ws: WorkspaceDto }) {
const { t } = useTranslation();
return ( return (
<Card accent="none"> <Card accent="none">
<View style={styles.eventHeaderTop}> <View style={styles.eventHeaderTop}>
@ -84,22 +83,23 @@ function EventHeader({ ws }: { ws: WorkspaceDto }) {
{ws.hallLabel} · {ws.startDate} ~ {ws.endDate} {ws.hallLabel} · {ws.startDate} ~ {ws.endDate}
</Text> </Text>
<View style={styles.roleBadge}> <View style={styles.roleBadge}>
<Text style={styles.roleBadgeText}>{roleLabel[ws.myRole]}</Text> <Text style={styles.roleBadgeText}>{t(`roles.${ws.myRole}` as const)}</Text>
</View> </View>
</Card> </Card>
); );
} }
function RoleActions({ role }: { role: EventRole }) { function RoleActions({ role }: { role: EventRole }) {
const { t } = useTranslation();
const actions: { label: string; icon: keyof typeof Ionicons.glyphMap; onPress: () => void }[] = []; const actions: { label: string; icon: keyof typeof Ionicons.glyphMap; onPress: () => void }[] = [];
if (role === 'CONTRACTOR') { if (role === 'CONTRACTOR') {
actions.push({ label: '현장 체크리스트', icon: 'clipboard-outline', onPress: () => router.push('/checklist') }); actions.push({ label: t('home.actionChecklist'), icon: 'clipboard-outline', onPress: () => router.push('/checklist') });
} }
if (role === 'HALL_MANAGER') { if (role === 'HALL_MANAGER') {
actions.push({ label: '현장 검수', icon: 'shield-checkmark-outline', onPress: () => router.push('/inspection') }); actions.push({ label: t('home.actionInspection'), icon: 'shield-checkmark-outline', onPress: () => router.push('/inspection') });
} }
actions.push({ label: '시각화 갤러리', icon: 'images-outline', onPress: () => router.push('/(tabs)/gallery') }); actions.push({ label: t('home.actionGallery'), icon: 'images-outline', onPress: () => router.push('/(tabs)/gallery') });
return ( return (
<View style={styles.actions}> <View style={styles.actions}>

View File

@ -4,9 +4,11 @@
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { router } from 'expo-router'; import { router } from 'expo-router';
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { Avatar } from '../../components/Avatar'; import { Avatar } from '../../components/Avatar';
import { Card } from '../../components/Card'; import { Card } from '../../components/Card';
import { LanguageSelector } from '../../components/LanguageSelector';
import { useAuth } from '../../context/AuthContext'; import { useAuth } from '../../context/AuthContext';
import { api, isDegraded } from '../../lib/api'; import { api, isDegraded } from '../../lib/api';
import { API_BASE } from '../../lib/config'; import { API_BASE } from '../../lib/config';
@ -14,17 +16,18 @@ import type { HealthDto } from '../../lib/types';
import { colors, radius, spacing, touch, type } from '../../theme'; import { colors, radius, spacing, touch, type } from '../../theme';
export default function MoreScreen() { export default function MoreScreen() {
const { t } = useTranslation();
const { user, signOut } = useAuth(); const { user, signOut } = useAuth();
const [health, setHealth] = useState<string>('확인 중…'); const [health, setHealth] = useState<string>(t('common.loading'));
const checkHealth = useCallback(async () => { const checkHealth = useCallback(async () => {
try { try {
const res = await api.get<HealthDto>('/health', { anonymous: true }); const res = await api.get<HealthDto>('/health', { anonymous: true });
setHealth(res?.status === 'UP' ? '정상 (UP)' : (res?.status ?? '알 수 없음')); setHealth(res?.status === 'UP' ? t('more.statusUp') : (res?.status ?? t('common.unknown')));
} catch (e) { } catch (e) {
setHealth(isDegraded(e) ? '연결 불가 (degraded)' : '오류'); setHealth(isDegraded(e) ? t('more.statusDown') : t('common.error'));
} }
}, []); }, [t]);
useEffect(() => { useEffect(() => {
checkHealth(); checkHealth();
@ -41,15 +44,15 @@ export default function MoreScreen() {
<Pressable <Pressable
onPress={() => router.push('/profile')} onPress={() => router.push('/profile')}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="내 정보 열기" accessibilityLabel={t('more.openProfile')}
> >
<Card> <Card>
<View style={styles.meRow}> <View style={styles.meRow}>
<Avatar name={user?.displayName} size={48} /> <Avatar name={user?.displayName} size={48} />
<View style={{ flex: 1, marginLeft: 12 }}> <View style={{ flex: 1, marginLeft: 12 }}>
<Text style={styles.name}>{user?.displayName ?? '사용자'}</Text> <Text style={styles.name}>{user?.displayName ?? t('common.user')}</Text>
<Text style={styles.meta}> <Text style={styles.meta}>
{user?.hallManager ? '킨텍스 내부 계정 (홀매니저)' : '일반 계정'} {user?.hallManager ? t('more.accountInternal') : t('more.accountGeneral')}
</Text> </Text>
</View> </View>
<Ionicons name="chevron-forward" size={20} color={colors.neutral500} /> <Ionicons name="chevron-forward" size={20} color={colors.neutral500} />
@ -57,21 +60,24 @@ export default function MoreScreen() {
</Card> </Card>
</Pressable> </Pressable>
{/* 언어 설정 — ko/en/zh/ja */}
<LanguageSelector />
<Card> <Card>
<Row label="API 서버" value={API_BASE} /> <Row label={t('more.apiServer')} value={API_BASE} />
<View style={styles.divider} /> <View style={styles.divider} />
<Pressable style={styles.healthRow} onPress={checkHealth}> <Pressable style={styles.healthRow} onPress={checkHealth}>
<Row label="서버 상태" value={health} /> <Row label={t('more.serverStatus')} value={health} />
<Ionicons name="refresh" size={18} color={colors.primary600} /> <Ionicons name="refresh" size={18} color={colors.primary600} />
</Pressable> </Pressable>
</Card> </Card>
<Pressable style={styles.signOut} onPress={onSignOut}> <Pressable style={styles.signOut} onPress={onSignOut}>
<Ionicons name="log-out-outline" size={20} color={colors.error} /> <Ionicons name="log-out-outline" size={20} color={colors.error} />
<Text style={styles.signOutText}></Text> <Text style={styles.signOutText}>{t('more.signOut')}</Text>
</Pressable> </Pressable>
<Text style={styles.version}>KINTEX AI · v0.1.0</Text> <Text style={styles.version}>{t('more.version')}</Text>
</ScrollView> </ScrollView>
); );
} }

View File

@ -5,19 +5,18 @@
import { Stack } from 'expo-router'; import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar'; import { StatusBar } from 'expo-status-bar';
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next';
import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { SafeAreaProvider } from 'react-native-safe-area-context'; import { SafeAreaProvider } from 'react-native-safe-area-context';
import { AuthProvider } from '../context/AuthContext'; import { AuthProvider } from '../context/AuthContext';
import { LanguageProvider } from '../context/LanguageContext';
import { SecureScreenProvider } from '../context/SecureScreenContext'; import { SecureScreenProvider } from '../context/SecureScreenContext';
import '../lib/i18n'; // i18next 동기 초기화(최초 렌더 전)
import { colors } from '../theme'; import { colors } from '../theme';
export default function RootLayout() { function RootStack() {
const { t } = useTranslation();
return ( return (
<GestureHandlerRootView style={{ flex: 1 }}>
<SafeAreaProvider>
<AuthProvider>
<SecureScreenProvider>
<StatusBar style="dark" />
<Stack <Stack
screenOptions={{ screenOptions={{
headerStyle: { backgroundColor: colors.white }, headerStyle: { backgroundColor: colors.white },
@ -28,17 +27,30 @@ export default function RootLayout() {
> >
<Stack.Screen name="index" options={{ headerShown: false }} /> <Stack.Screen name="index" options={{ headerShown: false }} />
<Stack.Screen name="login" options={{ headerShown: false }} /> <Stack.Screen name="login" options={{ headerShown: false }} />
<Stack.Screen name="register" options={{ title: '회원가입' }} /> <Stack.Screen name="register" options={{ title: t('nav.register') }} />
<Stack.Screen name="forgot-password" options={{ title: '비밀번호 찾기' }} /> <Stack.Screen name="forgot-password" options={{ title: t('nav.forgotPassword') }} />
<Stack.Screen name="(tabs)" options={{ headerShown: false }} /> <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="profile" options={{ title: '내 정보' }} /> <Stack.Screen name="profile" options={{ title: t('nav.profile') }} />
<Stack.Screen name="checklist" options={{ title: '현장 체크리스트' }} /> <Stack.Screen name="checklist" options={{ title: t('nav.checklist') }} />
<Stack.Screen name="inspection" options={{ title: '현장 검수' }} /> <Stack.Screen name="inspection" options={{ title: t('nav.inspection') }} />
<Stack.Screen name="tickets/index" options={{ title: '내 티켓' }} /> <Stack.Screen name="tickets/index" options={{ title: t('nav.ticketsIndex') }} />
<Stack.Screen name="tickets/select" options={{ title: '티켓 예매' }} /> <Stack.Screen name="tickets/select" options={{ title: t('nav.ticketsSelect') }} />
</Stack> </Stack>
);
}
export default function RootLayout() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<SafeAreaProvider>
<LanguageProvider>
<AuthProvider>
<SecureScreenProvider>
<StatusBar style="dark" />
<RootStack />
</SecureScreenProvider> </SecureScreenProvider>
</AuthProvider> </AuthProvider>
</LanguageProvider>
</SafeAreaProvider> </SafeAreaProvider>
</GestureHandlerRootView> </GestureHandlerRootView>
); );

View File

@ -5,6 +5,7 @@
*/ */
import { router } from 'expo-router'; import { router } from 'expo-router';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { KeyboardAvoidingView, Platform, ScrollView, StyleSheet, Text } from 'react-native'; import { KeyboardAvoidingView, Platform, ScrollView, StyleSheet, Text } from 'react-native';
import { Banner } from '../components/Banner'; import { Banner } from '../components/Banner';
import { Button } from '../components/Button'; import { Button } from '../components/Button';
@ -14,6 +15,7 @@ import { forgotPassword, resetPassword } from '../lib/auth';
import { colors, spacing, type } from '../theme'; import { colors, spacing, type } from '../theme';
export default function ForgotPasswordScreen() { export default function ForgotPasswordScreen() {
const { t } = useTranslation();
const [step, setStep] = useState<1 | 2>(1); const [step, setStep] = useState<1 | 2>(1);
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [code, setCode] = useState(''); const [code, setCode] = useState('');
@ -26,7 +28,7 @@ export default function ForgotPasswordScreen() {
setError(null); setError(null);
setInfo(null); setInfo(null);
if (!email.trim()) { if (!email.trim()) {
setError('이메일을 입력해 주세요.'); setError(t('forgot.errEmail'));
return; return;
} }
setLoading(true); setLoading(true);
@ -35,7 +37,7 @@ export default function ForgotPasswordScreen() {
setInfo(res.message); setInfo(res.message);
setStep(2); setStep(2);
} catch (e) { } catch (e) {
setError(e instanceof ApiRequestError ? e.message : '요청에 실패했습니다.'); setError(e instanceof ApiRequestError ? e.message : t('forgot.errRequest'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -44,7 +46,7 @@ export default function ForgotPasswordScreen() {
async function onReset() { async function onReset() {
setError(null); setError(null);
if (!code.trim() || newPassword.length < 8) { if (!code.trim() || newPassword.length < 8) {
setError('재설정 코드와 새 비밀번호(8자 이상)를 확인해 주세요.'); setError(t('forgot.errFields'));
return; return;
} }
setLoading(true); setLoading(true);
@ -54,9 +56,9 @@ export default function ForgotPasswordScreen() {
setTimeout(() => router.replace('/login'), 800); setTimeout(() => router.replace('/login'), 800);
} catch (e) { } catch (e) {
if (e instanceof ApiRequestError && e.code === 'OTP_INVALID') { if (e instanceof ApiRequestError && e.code === 'OTP_INVALID') {
setError('재설정 코드가 올바르지 않거나 만료되었습니다.'); setError(t('forgot.errCode'));
} else { } else {
setError(e instanceof ApiRequestError ? e.message : '초기화에 실패했습니다.'); setError(e instanceof ApiRequestError ? e.message : t('forgot.errReset'));
} }
} finally { } finally {
setLoading(false); setLoading(false);
@ -69,39 +71,39 @@ export default function ForgotPasswordScreen() {
behavior={Platform.OS === 'ios' ? 'padding' : undefined} behavior={Platform.OS === 'ios' ? 'padding' : undefined}
> >
<ScrollView contentContainerStyle={styles.scroll} keyboardShouldPersistTaps="handled"> <ScrollView contentContainerStyle={styles.scroll} keyboardShouldPersistTaps="handled">
<Text style={styles.title}> </Text> <Text style={styles.title}>{t('forgot.title')}</Text>
{error ? <Banner tone="error">{error}</Banner> : null} {error ? <Banner tone="error">{error}</Banner> : null}
{info ? <Banner tone="info">{info}</Banner> : null} {info ? <Banner tone="info">{info}</Banner> : null}
<Field <Field
label="이메일" label={t('forgot.email')}
value={email} value={email}
onChangeText={setEmail} onChangeText={setEmail}
placeholder="name@company.co.kr" placeholder={t('forgot.emailPlaceholder')}
keyboardType="email-address" keyboardType="email-address"
editable={step === 1} editable={step === 1}
/> />
{step === 1 ? ( {step === 1 ? (
<Button label="재설정 코드 요청" onPress={onRequest} loading={loading} /> <Button label={t('forgot.request')} onPress={onRequest} loading={loading} />
) : ( ) : (
<> <>
<Field <Field
label="재설정 코드" label={t('forgot.code')}
value={code} value={code}
onChangeText={setCode} onChangeText={setCode}
placeholder="6자리 코드" placeholder={t('forgot.codePlaceholder')}
keyboardType="number-pad" keyboardType="number-pad"
/> />
<Field <Field
label="새 비밀번호" label={t('forgot.newPassword')}
value={newPassword} value={newPassword}
onChangeText={setNewPassword} onChangeText={setNewPassword}
placeholder="8자 이상" placeholder={t('forgot.newPasswordPlaceholder')}
secureTextEntry secureTextEntry
helperText="8~100자" helperText={t('forgot.newPasswordHelper')}
/> />
<Button label="비밀번호 변경" onPress={onReset} loading={loading} /> <Button label={t('forgot.submit')} onPress={onReset} loading={loading} />
</> </>
)} )}
</ScrollView> </ScrollView>

View File

@ -8,7 +8,7 @@ import { useAuth } from '../context/AuthContext';
import { colors } from '../theme'; import { colors } from '../theme';
export default function Index() { export default function Index() {
const { ready, token } = useAuth(); const { ready, token, landingPath } = useAuth();
if (!ready) { if (!ready) {
return ( return (
@ -18,7 +18,8 @@ export default function Index() {
); );
} }
return <Redirect href={token ? '/(tabs)' : '/login'} />; // 인증됨 → 역할별 랜딩(지속된 값, 없으면 탭 홈) / 미인증 → 로그인.
return <Redirect href={token ? ((landingPath ?? '/(tabs)') as never) : '/login'} />;
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({

View File

@ -5,6 +5,7 @@
*/ */
import { Link, router } from 'expo-router'; import { Link, router } from 'expo-router';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { import {
KeyboardAvoidingView, KeyboardAvoidingView,
Platform, Platform,
@ -23,6 +24,7 @@ import { useSecureScreen } from '../context/SecureScreenContext';
import { ApiRequestError } from '../lib/api'; import { ApiRequestError } from '../lib/api';
import { isOtpRequired, login } from '../lib/auth'; import { isOtpRequired, login } from '../lib/auth';
import { useDeviceIntegrity } from '../lib/integrity'; import { useDeviceIntegrity } from '../lib/integrity';
import { landingPathFor } from '../lib/roleTrack';
import { prefDelete, prefGet, prefSet } from '../lib/secureStore'; import { prefDelete, prefGet, prefSet } from '../lib/secureStore';
import { colors, radius, spacing, touch, type } from '../theme'; import { colors, radius, spacing, touch, type } from '../theme';
@ -30,6 +32,7 @@ const REMEMBER_KEY = 'kintex.rememberedEmail';
export default function LoginScreen() { export default function LoginScreen() {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { t } = useTranslation();
const { signIn } = useAuth(); const { signIn } = useAuth();
// 2FA/자격증명 입력 화면 — 캡처 차단 + 백그라운드 마스킹(B4/B7). // 2FA/자격증명 입력 화면 — 캡처 차단 + 백그라운드 마스킹(B4/B7).
@ -58,11 +61,11 @@ export default function LoginScreen() {
async function onSubmit() { async function onSubmit() {
setError(null); setError(null);
if (!email.trim() || !password) { if (!email.trim() || !password) {
setError('이메일과 비밀번호를 입력해 주세요.'); setError(t('login.errEmpty'));
return; return;
} }
if (otpRequired && !otp.trim()) { if (otpRequired && !otp.trim()) {
setError('2차 인증 코드를 입력해 주세요.'); setError(t('login.errOtpEmpty'));
return; return;
} }
setLoading(true); setLoading(true);
@ -71,19 +74,21 @@ export default function LoginScreen() {
if (remember) await prefSet(REMEMBER_KEY, email.trim()); if (remember) await prefSet(REMEMBER_KEY, email.trim());
else await prefDelete(REMEMBER_KEY); else await prefDelete(REMEMBER_KEY);
await signIn(res); await signIn(res);
router.replace('/(tabs)'); // 역할 우선순위로 랜딩 결정(웹 roleTrack 패리티) — visitor=관람객 티켓, agency=현장, 그 외 홈.
const landing = landingPathFor({ user: res.user, workspaces: res.workspaces });
router.replace(landing as never);
} catch (e) { } catch (e) {
if (isOtpRequired(e)) { if (isOtpRequired(e)) {
setOtpRequired(true); setOtpRequired(true);
setError('2차 인증 코드가 필요합니다. 인증 앱의 코드를 입력해 주세요.'); setError(t('login.errOtpRequired'));
} else if (e instanceof ApiRequestError && e.code === 'OTP_INVALID') { } else if (e instanceof ApiRequestError && e.code === 'OTP_INVALID') {
setError('인증 코드가 올바르지 않거나 만료되었습니다.'); setError(t('login.errOtpInvalid'));
} else if (e instanceof ApiRequestError && e.code === 'ACCOUNT_LOCKED') { } else if (e instanceof ApiRequestError && e.code === 'ACCOUNT_LOCKED') {
setError('로그인 시도 초과로 계정이 잠겼습니다. 잠시 후 다시 시도해 주세요.'); setError(t('login.errLocked'));
} else if (e instanceof ApiRequestError) { } else if (e instanceof ApiRequestError) {
setError(e.message); setError(e.message);
} else { } else {
setError('로그인에 실패했습니다.'); setError(t('login.errGeneric'));
} }
} finally { } finally {
setLoading(false); setLoading(false);
@ -102,49 +107,46 @@ export default function LoginScreen() {
{/* 브랜드 패널 */} {/* 브랜드 패널 */}
<View style={styles.brand}> <View style={styles.brand}>
<Text style={styles.brandMark}>KINTEX</Text> <Text style={styles.brandMark}>KINTEX</Text>
<Text style={styles.brandTitle}> ,{'\n'} </Text> <Text style={styles.brandTitle}>{t('login.brandTitle')}</Text>
<Text style={styles.brandCaption}>AI · </Text> <Text style={styles.brandCaption}>{t('login.brandCaption')}</Text>
</View> </View>
{/* 위변조 단말 경고(비차단) — 공공 대민 정책: 경고 후 이용 제한 안내 */} {/* 위변조 단말 경고(비차단) — 공공 대민 정책: 경고 후 이용 제한 안내 */}
{integrity.compromised ? ( {integrity.compromised ? (
<Banner tone="warning"> <Banner tone="warning">{t('login.compromised')}</Banner>
(··). ·
.
</Banner>
) : null} ) : null}
{/* 로그인 카드 */} {/* 로그인 카드 */}
<View style={styles.card}> <View style={styles.card}>
<Text style={styles.cardTitle}></Text> <Text style={styles.cardTitle}>{t('login.title')}</Text>
{error ? ( {error ? (
<Banner tone={otpRequired ? 'warning' : 'error'}>{error}</Banner> <Banner tone={otpRequired ? 'warning' : 'error'}>{error}</Banner>
) : null} ) : null}
<Field <Field
label="이메일" label={t('login.email')}
value={email} value={email}
onChangeText={setEmail} onChangeText={setEmail}
placeholder="name@company.co.kr" placeholder={t('login.emailPlaceholder')}
keyboardType="email-address" keyboardType="email-address"
/> />
<Field <Field
label="비밀번호" label={t('login.password')}
value={password} value={password}
onChangeText={setPassword} onChangeText={setPassword}
placeholder="비밀번호" placeholder={t('login.passwordPlaceholder')}
secureTextEntry secureTextEntry
/> />
{otpRequired ? ( {otpRequired ? (
<Field <Field
label="2차 인증 코드" label={t('login.otpLabel')}
value={otp} value={otp}
onChangeText={setOtp} onChangeText={setOtp}
placeholder="6자리 코드" placeholder={t('login.otpPlaceholder')}
keyboardType="number-pad" keyboardType="number-pad"
helperText="인증 앱(TOTP)의 6자리 코드" helperText={t('login.otpHelper')}
/> />
) : null} ) : null}
@ -158,22 +160,22 @@ export default function LoginScreen() {
<View style={[styles.checkbox, remember && styles.checkboxOn]}> <View style={[styles.checkbox, remember && styles.checkboxOn]}>
{remember ? <Text style={styles.checkmark}></Text> : null} {remember ? <Text style={styles.checkmark}></Text> : null}
</View> </View>
<Text style={styles.rememberText}> </Text> <Text style={styles.rememberText}>{t('login.remember')}</Text>
</Pressable> </Pressable>
<Button <Button
label={otpRequired ? '인증 후 로그인' : '로그인'} label={otpRequired ? t('login.submitOtp') : t('login.submit')}
onPress={onSubmit} onPress={onSubmit}
loading={loading} loading={loading}
/> />
<View style={styles.links}> <View style={styles.links}>
<Link href="/forgot-password" style={styles.link}> <Link href="/forgot-password" style={styles.link}>
{t('login.forgot')}
</Link> </Link>
<Text style={styles.linkDivider}>·</Text> <Text style={styles.linkDivider}>·</Text>
<Link href="/register" style={styles.link}> <Link href="/register" style={styles.link}>
{t('login.register')}
</Link> </Link>
</View> </View>
</View> </View>

View File

@ -9,6 +9,7 @@
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { router, useFocusEffect } from 'expo-router'; import { router, useFocusEffect } from 'expo-router';
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { import {
ActivityIndicator, ActivityIndicator,
Alert, Alert,
@ -41,18 +42,12 @@ import {
pickFromLibrary, pickFromLibrary,
type PickedImage, type PickedImage,
} from '../lib/imagePick'; } from '../lib/imagePick';
import { LanguageSelector } from '../components/LanguageSelector';
import { avatarUrl, getMe, getOtpStatus, uploadAvatar } from '../lib/profile'; import { avatarUrl, getMe, getOtpStatus, uploadAvatar } from '../lib/profile';
import { prefGet, prefSet } from '../lib/secureStore'; import { prefGet, prefSet } from '../lib/secureStore';
import type { EventRole, MePrincipal, OtpStatusDto } from '../lib/types'; import type { MePrincipal, OtpStatusDto } from '../lib/types';
import { colors, radius, spacing, touch, type } from '../theme'; import { colors, radius, spacing, touch, type } from '../theme';
const roleLabel: Record<EventRole, string> = {
ORGANIZER: '주최자',
EXHIBITOR: '참가업체',
CONTRACTOR: '장치·시공업체',
HALL_MANAGER: '홀매니저',
};
// 환경설정(이 기기 로컬 저장 — 서버 동기화 계약 부재, web MyPage와 동일 정책). // 환경설정(이 기기 로컬 저장 — 서버 동기화 계약 부재, web MyPage와 동일 정책).
const PREFS_KEY = 'kintex.prefs'; const PREFS_KEY = 'kintex.prefs';
interface Prefs { interface Prefs {
@ -63,6 +58,7 @@ interface Prefs {
const DEFAULT_PREFS: Prefs = { notifyDeadline: true, notifyApproval: true, notifyPayment: true }; const DEFAULT_PREFS: Prefs = { notifyDeadline: true, notifyApproval: true, notifyPayment: true };
export default function ProfileScreen() { export default function ProfileScreen() {
const { t } = useTranslation();
const { user, signOut } = useAuth(); const { user, signOut } = useAuth();
// 개인정보 화면 — 캡처 차단 + 백그라운드 마스킹(B4/B7). // 개인정보 화면 — 캡처 차단 + 백그라운드 마스킹(B4/B7).
@ -88,7 +84,7 @@ export default function ProfileScreen() {
// 환경설정 // 환경설정
const [prefs, setPrefs] = useState<Prefs>(DEFAULT_PREFS); const [prefs, setPrefs] = useState<Prefs>(DEFAULT_PREFS);
const displayName = me?.displayName ?? user?.displayName ?? '사용자'; const displayName = me?.displayName ?? user?.displayName ?? t('common.user');
const loadProfile = useCallback(async () => { const loadProfile = useCallback(async () => {
setLoading(true); setLoading(true);
@ -100,7 +96,7 @@ export default function ProfileScreen() {
setPhotoPending(false); setPhotoPending(false);
} catch (e) { } catch (e) {
// /me 실패 시 최소한 로그인 세션 정보로 화면 유지(전면 오류 회피). // /me 실패 시 최소한 로그인 세션 정보로 화면 유지(전면 오류 회피).
setLoadErr(e instanceof ApiRequestError ? e.message : '내정보를 불러오지 못했습니다.'); setLoadErr(e instanceof ApiRequestError ? e.message : t('profile.loadErr'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -109,7 +105,7 @@ export default function ProfileScreen() {
} catch { } catch {
setOtp(null); // 상태 조회 실패는 무시(섹션은 안내로 degrade) setOtp(null); // 상태 조회 실패는 무시(섹션은 안내로 degrade)
} }
}, []); }, [t]);
// 환경설정 로드 // 환경설정 로드
useEffect(() => { useEffect(() => {
@ -162,7 +158,7 @@ export default function ProfileScreen() {
// 활성화 전 1회 본인 확인. // 활성화 전 1회 본인 확인.
const ok = await authenticateBiometric(); const ok = await authenticateBiometric();
if (!ok) { if (!ok) {
Alert.alert('생체인식', '생체 인증에 실패했습니다. 다시 시도해 주세요.'); Alert.alert(t('profile.biometric'), t('profile.bioFailed'));
return; return;
} }
markSessionUnlocked(); // 방금 인증했으므로 이 세션은 통과 처리(즉시 재프롬프트 방지). markSessionUnlocked(); // 방금 인증했으므로 이 세션은 통과 처리(즉시 재프롬프트 방지).
@ -175,7 +171,7 @@ export default function ProfileScreen() {
const pickForPreview = async (from: 'library' | 'camera') => { const pickForPreview = async (from: 'library' | 'camera') => {
if (photoBusy) return; if (photoBusy) return;
if (!isPickerAvailable()) { if (!isPickerAvailable()) {
Alert.alert('프로필 사진', '이미지 선택 기능을 사용할 수 없는 기기입니다.'); Alert.alert(t('profile.photoTitle'), t('profile.photoPickerUnavailable'));
return; return;
} }
const img = from === 'library' ? await pickFromLibrary() : await pickFromCamera(); const img = from === 'library' ? await pickFromLibrary() : await pickFromCamera();
@ -205,7 +201,7 @@ export default function ProfileScreen() {
setPhotoPending(true); setPhotoPending(true);
setPreviewImg(null); setPreviewImg(null);
} else { } else {
setPreviewErr(e instanceof ApiRequestError ? e.message : '사진 업로드에 실패했습니다.'); setPreviewErr(e instanceof ApiRequestError ? e.message : t('profile.uploadErr'));
} }
} finally { } finally {
setPhotoBusy(false); setPhotoBusy(false);
@ -220,18 +216,18 @@ export default function ProfileScreen() {
const onChangePhoto = () => { const onChangePhoto = () => {
if (photoBusy) return; if (photoBusy) return;
Alert.alert('프로필 사진', '사진을 선택하세요', [ Alert.alert(t('profile.photoTitle'), t('profile.photoSelect'), [
{ text: '갤러리', onPress: () => pickForPreview('library') }, { text: t('profile.photoLibrary'), onPress: () => pickForPreview('library') },
{ text: '카메라', onPress: () => pickForPreview('camera') }, { text: t('profile.photoCamera'), onPress: () => pickForPreview('camera') },
{ text: '취소', style: 'cancel' }, { text: t('common.cancel'), style: 'cancel' },
]); ]);
}; };
const onSignOut = () => { const onSignOut = () => {
Alert.alert('로그아웃', '로그아웃하시겠습니까?', [ Alert.alert(t('profile.signOut'), t('profile.signOutConfirm'), [
{ text: '취소', style: 'cancel' }, { text: t('common.cancel'), style: 'cancel' },
{ {
text: '로그아웃', text: t('profile.signOut'),
style: 'destructive', style: 'destructive',
onPress: async () => { onPress: async () => {
await signOut(); await signOut();
@ -246,15 +242,15 @@ export default function ProfileScreen() {
return ( return (
<View style={styles.lockWrap}> <View style={styles.lockWrap}>
<Ionicons name="finger-print" size={56} color={colors.primary600} /> <Ionicons name="finger-print" size={56} color={colors.primary600} />
<Text style={styles.lockTitle}> </Text> <Text style={styles.lockTitle}>{t('profile.lockTitle')}</Text>
<Text style={styles.lockHint}> Face로 .</Text> <Text style={styles.lockHint}>{t('profile.lockHint')}</Text>
<Pressable <Pressable
style={styles.lockBtn} style={styles.lockBtn}
onPress={runLockGate} onPress={runLockGate}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="생체 인증 다시 시도" accessibilityLabel={t('common.retry')}
> >
<Text style={styles.lockBtnText}> </Text> <Text style={styles.lockBtnText}>{t('common.retry')}</Text>
</Pressable> </Pressable>
</View> </View>
); );
@ -265,7 +261,7 @@ export default function ProfileScreen() {
return ( return (
<ScrollView style={styles.wrap} contentContainerStyle={styles.scroll}> <ScrollView style={styles.wrap} contentContainerStyle={styles.scroll}>
{/* ── 프로필 ── */} {/* ── 프로필 ── */}
<Text style={styles.section}> </Text> <Text style={styles.section}>{t('profile.sectionInfo')}</Text>
{loading ? ( {loading ? (
<ActivityIndicator color={colors.primary600} style={{ marginVertical: spacing.md }} /> <ActivityIndicator color={colors.primary600} style={{ marginVertical: spacing.md }} />
) : ( ) : (
@ -297,20 +293,18 @@ export default function ProfileScreen() {
</View> </View>
{photoPending ? ( {photoPending ? (
<Text style={styles.pendingNote}> <Text style={styles.pendingNote}>{t('profile.photoPending')}</Text>
.
</Text>
) : null} ) : null}
{loadErr ? <Banner tone="warning">{loadErr}</Banner> : null} {loadErr ? <Banner tone="warning">{loadErr}</Banner> : null}
<InfoRow label="이름" value={displayName} /> <InfoRow label={t('profile.labelName')} value={displayName} />
<InfoRow label="사용자 ID" value={me?.userId ?? user?.userId ?? null} testID="profile-userid" /> <InfoRow label={t('profile.labelUserId')} value={me?.userId ?? user?.userId ?? null} testID="profile-userid" />
<InfoRow label="전역 역할" value={me?.roleCode ?? null} /> <InfoRow label={t('profile.labelGlobalRole')} value={me?.roleCode ?? null} />
<InfoRow label="전시관(테넌트)" value={me?.tenantId ?? null} /> <InfoRow label={t('profile.labelTenant')} value={me?.tenantId ?? null} />
<InfoRow <InfoRow
label="구분" label={t('profile.labelType')}
value={me?.hallManager ?? user?.hallManager ? '킨텍스 내부(홀매니저)' : '일반 계정'} value={me?.hallManager ?? user?.hallManager ? t('profile.typeInternal') : t('profile.typeGeneral')}
/> />
</Card> </Card>
)} )}
@ -318,7 +312,7 @@ export default function ProfileScreen() {
{/* ── 참여 행사 역할 ── */} {/* ── 참여 행사 역할 ── */}
{roles.length > 0 ? ( {roles.length > 0 ? (
<> <>
<Text style={styles.section}> </Text> <Text style={styles.section}>{t('profile.sectionEventRoles')}</Text>
<Card> <Card>
{Object.entries(me!.eventRoles).map(([eventId, r]) => ( {Object.entries(me!.eventRoles).map(([eventId, r]) => (
<View key={eventId} style={styles.infoRow}> <View key={eventId} style={styles.infoRow}>
@ -326,7 +320,7 @@ export default function ProfileScreen() {
{eventId} {eventId}
</Text> </Text>
<View style={styles.rolePill}> <View style={styles.rolePill}>
<Text style={styles.rolePillText}>{roleLabel[r] ?? r}</Text> <Text style={styles.rolePillText}>{t(`roles.${r}` as const, { defaultValue: r })}</Text>
</View> </View>
</View> </View>
))} ))}
@ -335,35 +329,35 @@ export default function ProfileScreen() {
) : null} ) : null}
{/* ── 계정·보안(2차 인증) ── */} {/* ── 계정·보안(2차 인증) ── */}
<Text style={styles.section}> · </Text> <Text style={styles.section}>{t('profile.sectionSecurity')}</Text>
<Card> <Card>
<View style={styles.bioRow}> <View style={styles.bioRow}>
<View style={{ flex: 1, paddingRight: 12 }}> <View style={{ flex: 1, paddingRight: 12 }}>
<Text style={styles.rowTitle}>2 (OTP)</Text> <Text style={styles.rowTitle}>{t('profile.otpTitle')}</Text>
<Text style={styles.hint}> <Text style={styles.hint}>
{otp?.otpEnabled {otp?.otpEnabled
? `활성화됨 · ${otp.verifyMethod === 'OTP' ? 'Authenticator(OTP)' : otp.verifyMethod || 'OTP'}` ? otp.verifyMethod === 'OTP'
: '미설정 — 로그인 시 인증 앱(TOTP) 2단계 인증을 사용합니다.'} ? t('profile.otpOnAuth')
: t('profile.otpOnMethod', { method: otp.verifyMethod || 'OTP' })
: t('profile.otpOff')}
</Text> </Text>
</View> </View>
<View style={otp?.otpEnabled ? styles.badgeOn : styles.badgeOff}> <View style={otp?.otpEnabled ? styles.badgeOn : styles.badgeOff}>
<Text style={otp?.otpEnabled ? styles.badgeOnText : styles.badgeOffText}> <Text style={otp?.otpEnabled ? styles.badgeOnText : styles.badgeOffText}>
{otp?.otpEnabled ? '켜짐' : '꺼짐'} {otp?.otpEnabled ? t('profile.on') : t('profile.off')}
</Text> </Text>
</View> </View>
</View> </View>
</Card> </Card>
{/* ── 생체인식 잠금 ── */} {/* ── 생체인식 잠금 ── */}
<Text style={styles.section}> </Text> <Text style={styles.section}>{t('profile.sectionBiometric')}</Text>
<Card> <Card>
{bioSupported ? ( {bioSupported ? (
<View style={styles.bioRow}> <View style={styles.bioRow}>
<View style={{ flex: 1, paddingRight: 12 }}> <View style={{ flex: 1, paddingRight: 12 }}>
<Text style={styles.rowTitle}> · Face </Text> <Text style={styles.rowTitle}>{t('profile.bioTitle')}</Text>
<Text style={styles.hint}> <Text style={styles.hint}>{t('profile.bioDesc')}</Text>
Face로 . 2 .
</Text>
</View> </View>
<Switch <Switch
testID="profile-biometric-toggle" testID="profile-biometric-toggle"
@ -374,35 +368,36 @@ export default function ProfileScreen() {
/> />
</View> </View>
) : ( ) : (
<Text style={styles.hint}> <Text style={styles.hint}>{t('profile.bioUnsupported')}</Text>
/Face가 .
.
</Text>
)} )}
</Card> </Card>
{/* ── 언어 설정 ── */}
<Text style={styles.section}>{t('lang.title')}</Text>
<LanguageSelector />
{/* ── 환경설정(알림) ── */} {/* ── 환경설정(알림) ── */}
<Text style={styles.section}> </Text> <Text style={styles.section}>{t('profile.sectionNotify')}</Text>
<Card> <Card>
{( {(
[ [
['notifyDeadline', '마감 D-데이 알림'], ['notifyDeadline', t('profile.notifyDeadline')],
['notifyApproval', '승인·검수 알림'], ['notifyApproval', t('profile.notifyApproval')],
['notifyPayment', '결제·정산 알림'], ['notifyPayment', t('profile.notifyPayment')],
] as const ] as const
).map(([key, label]) => ( ).map(([key, label]) => (
<View key={key} style={styles.bioRow}> <View key={key} style={styles.bioRow}>
<Text style={[styles.rowTitle, { flex: 1 }]}>{label}</Text> <Text style={[styles.rowTitle, { flex: 1 }]}>{label}</Text>
<Switch <Switch
testID={`profile-pref-${key}`} testID={`profile-pref-${key}`}
value={prefs[key]} value={prefs[key as keyof Prefs]}
onValueChange={(v) => savePrefs({ ...prefs, [key]: v })} onValueChange={(v) => savePrefs({ ...prefs, [key]: v })}
trackColor={{ false: colors.neutral200, true: colors.primary600 }} trackColor={{ false: colors.neutral200, true: colors.primary600 }}
thumbColor={colors.white} thumbColor={colors.white}
/> />
</View> </View>
))} ))}
<Text style={styles.hint}> . .</Text> <Text style={styles.hint}>{t('profile.notifyNote')}</Text>
</Card> </Card>
{/* ── 로그아웃 ── */} {/* ── 로그아웃 ── */}
@ -410,26 +405,26 @@ export default function ProfileScreen() {
style={styles.logoutBtn} style={styles.logoutBtn}
onPress={onSignOut} onPress={onSignOut}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="로그아웃" accessibilityLabel={t('profile.signOut')}
> >
<Ionicons name="log-out-outline" size={18} color={colors.error} /> <Ionicons name="log-out-outline" size={18} color={colors.error} />
<Text style={styles.logoutText}></Text> <Text style={styles.logoutText}>{t('profile.signOut')}</Text>
</Pressable> </Pressable>
<Text style={styles.version}>KINTEX AI · </Text> <Text style={styles.version}>{t('profile.version')}</Text>
{/* ── 프로필 사진 미리보기 모달(선택→미리보기→적용 확정) ── */} {/* ── 프로필 사진 미리보기 모달(선택→미리보기→적용 확정) ── */}
<Modal visible={!!previewImg} transparent animationType="fade" onRequestClose={cancelPreview}> <Modal visible={!!previewImg} transparent animationType="fade" onRequestClose={cancelPreview}>
<Pressable style={styles.modalBackdrop} onPress={cancelPreview}> <Pressable style={styles.modalBackdrop} onPress={cancelPreview}>
<Pressable style={styles.modalSheet} onPress={() => {}}> <Pressable style={styles.modalSheet} onPress={() => {}}>
<View style={styles.modalHeaderRow}> <View style={styles.modalHeaderRow}>
<Text style={styles.modalTitle}> </Text> <Text style={styles.modalTitle}>{t('profile.photoChange')}</Text>
<Pressable <Pressable
testID="photo-cancel-x" testID="photo-cancel-x"
onPress={cancelPreview} onPress={cancelPreview}
disabled={photoBusy} disabled={photoBusy}
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
accessibilityLabel="닫기" accessibilityLabel={t('common.close')}
> >
<Ionicons name="close" size={22} color={colors.neutral500} /> <Ionicons name="close" size={22} color={colors.neutral500} />
</Pressable> </Pressable>
@ -450,7 +445,7 @@ export default function ProfileScreen() {
</View> </View>
) : null} ) : null}
</View> </View>
<Text style={styles.previewCaption}> .</Text> <Text style={styles.previewCaption}>{t('profile.photoCaption')}</Text>
<View style={styles.modalPickRow}> <View style={styles.modalPickRow}>
<Pressable <Pressable
@ -460,7 +455,7 @@ export default function ProfileScreen() {
disabled={photoBusy} disabled={photoBusy}
> >
<Ionicons name="image-outline" size={16} color={colors.neutral900} /> <Ionicons name="image-outline" size={16} color={colors.neutral900} />
<Text style={styles.pickText}></Text> <Text style={styles.pickText}>{t('profile.photoLibrary')}</Text>
</Pressable> </Pressable>
<Pressable <Pressable
testID="photo-pick-camera" testID="photo-pick-camera"
@ -469,7 +464,7 @@ export default function ProfileScreen() {
disabled={photoBusy} disabled={photoBusy}
> >
<Ionicons name="camera-outline" size={16} color={colors.neutral900} /> <Ionicons name="camera-outline" size={16} color={colors.neutral900} />
<Text style={styles.pickText}></Text> <Text style={styles.pickText}>{t('profile.photoCamera')}</Text>
</Pressable> </Pressable>
</View> </View>
@ -484,10 +479,10 @@ export default function ProfileScreen() {
{photoBusy ? ( {photoBusy ? (
<View style={styles.applyBusyRow}> <View style={styles.applyBusyRow}>
<ActivityIndicator color={colors.white} size="small" /> <ActivityIndicator color={colors.white} size="small" />
<Text style={styles.applyText}> </Text> <Text style={styles.applyText}>{t('profile.photoApplying')}</Text>
</View> </View>
) : ( ) : (
<Text style={styles.applyText}></Text> <Text style={styles.applyText}>{t('profile.photoApply')}</Text>
)} )}
</Pressable> </Pressable>
<Pressable <Pressable
@ -496,7 +491,7 @@ export default function ProfileScreen() {
onPress={cancelPreview} onPress={cancelPreview}
disabled={photoBusy} disabled={photoBusy}
> >
<Text style={styles.cancelText}></Text> <Text style={styles.cancelText}>{t('common.cancel')}</Text>
</Pressable> </Pressable>
</Pressable> </Pressable>
</Pressable> </Pressable>

View File

@ -4,6 +4,7 @@
*/ */
import { router } from 'expo-router'; import { router } from 'expo-router';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { KeyboardAvoidingView, Platform, ScrollView, StyleSheet, Text } from 'react-native'; import { KeyboardAvoidingView, Platform, ScrollView, StyleSheet, Text } from 'react-native';
import { Banner } from '../components/Banner'; import { Banner } from '../components/Banner';
import { Button } from '../components/Button'; import { Button } from '../components/Button';
@ -13,6 +14,7 @@ import { register } from '../lib/auth';
import { colors, spacing, type } from '../theme'; import { colors, spacing, type } from '../theme';
export default function RegisterScreen() { export default function RegisterScreen() {
const { t } = useTranslation();
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [displayName, setDisplayName] = useState(''); const [displayName, setDisplayName] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
@ -25,7 +27,7 @@ export default function RegisterScreen() {
async function onSubmit() { async function onSubmit() {
setError(null); setError(null);
if (!email.trim() || !displayName.trim() || password.length < 8) { if (!email.trim() || !displayName.trim() || password.length < 8) {
setError('이메일·이름·비밀번호(8자 이상)를 확인해 주세요.'); setError(t('register.errInvalid'));
return; return;
} }
setLoading(true); setLoading(true);
@ -37,18 +39,14 @@ export default function RegisterScreen() {
companyName: companyName.trim() || undefined, companyName: companyName.trim() || undefined,
inviteCode: inviteCode.trim() || undefined, inviteCode: inviteCode.trim() || undefined,
}); });
setDone( setDone(res.joinedEvent ? t('register.doneJoined') : t('register.done'));
res.joinedEvent
? '가입이 완료되었고 초대 행사에 연결되었습니다. 로그인해 주세요.'
: '가입이 완료되었습니다. 로그인해 주세요.',
);
} catch (e) { } catch (e) {
if (e instanceof ApiRequestError && e.code === 'EMAIL_TAKEN') { if (e instanceof ApiRequestError && e.code === 'EMAIL_TAKEN') {
setError('이미 가입된 이메일입니다.'); setError(t('register.errEmailTaken'));
} else if (e instanceof ApiRequestError) { } else if (e instanceof ApiRequestError) {
setError(e.message); setError(e.message);
} else { } else {
setError('회원가입에 실패했습니다.'); setError(t('register.errGeneric'));
} }
} finally { } finally {
setLoading(false); setLoading(false);
@ -61,20 +59,20 @@ export default function RegisterScreen() {
behavior={Platform.OS === 'ios' ? 'padding' : undefined} behavior={Platform.OS === 'ios' ? 'padding' : undefined}
> >
<ScrollView contentContainerStyle={styles.scroll} keyboardShouldPersistTaps="handled"> <ScrollView contentContainerStyle={styles.scroll} keyboardShouldPersistTaps="handled">
<Text style={styles.title}> </Text> <Text style={styles.title}>{t('register.title')}</Text>
{error ? <Banner tone="error">{error}</Banner> : null} {error ? <Banner tone="error">{error}</Banner> : null}
{done ? <Banner tone="info">{done}</Banner> : null} {done ? <Banner tone="info">{done}</Banner> : null}
<Field label="이메일" value={email} onChangeText={setEmail} placeholder="name@company.co.kr" keyboardType="email-address" /> <Field label={t('register.email')} value={email} onChangeText={setEmail} placeholder={t('register.emailPlaceholder')} keyboardType="email-address" />
<Field label="이름" value={displayName} onChangeText={setDisplayName} placeholder="홍길동" autoCapitalize="none" /> <Field label={t('register.name')} value={displayName} onChangeText={setDisplayName} placeholder={t('register.namePlaceholder')} autoCapitalize="none" />
<Field label="비밀번호" value={password} onChangeText={setPassword} placeholder="8자 이상" secureTextEntry helperText="8~100자" /> <Field label={t('register.password')} value={password} onChangeText={setPassword} placeholder={t('register.passwordPlaceholder')} secureTextEntry helperText={t('register.passwordHelper')} />
<Field label="회사명 (선택)" value={companyName} onChangeText={setCompanyName} placeholder="지오인포" /> <Field label={t('register.company')} value={companyName} onChangeText={setCompanyName} placeholder={t('register.companyPlaceholder')} />
<Field label="초대 코드 (선택)" value={inviteCode} onChangeText={setInviteCode} placeholder="행사 초대 코드" /> <Field label={t('register.invite')} value={inviteCode} onChangeText={setInviteCode} placeholder={t('register.invitePlaceholder')} />
{done ? ( {done ? (
<Button label="로그인으로 이동" onPress={() => router.replace('/login')} /> <Button label={t('register.goLogin')} onPress={() => router.replace('/login')} />
) : ( ) : (
<Button label="가입하기" onPress={onSubmit} loading={loading} /> <Button label={t('register.submit')} onPress={onSubmit} loading={loading} />
)} )}
</ScrollView> </ScrollView>
</KeyboardAvoidingView> </KeyboardAvoidingView>

View File

@ -7,6 +7,7 @@
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { router, Stack } from 'expo-router'; import { router, Stack } from 'expo-router';
import React, { useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { Banner } from '../../components/Banner'; import { Banner } from '../../components/Banner';
import { useSecureScreen } from '../../context/SecureScreenContext'; import { useSecureScreen } from '../../context/SecureScreenContext';
@ -21,6 +22,7 @@ import {
import { colors, radius, spacing, type } from '../../theme'; import { colors, radius, spacing, type } from '../../theme';
export default function TicketWalletScreen() { export default function TicketWalletScreen() {
const { t } = useTranslation();
// 티켓 QR 화면 — 캡처 차단(QR 재사용 방지) + 백그라운드 마스킹(B4/B7). // 티켓 QR 화면 — 캡처 차단(QR 재사용 방지) + 백그라운드 마스킹(B4/B7).
useSecureScreen('tickets'); useSecureScreen('tickets');
@ -45,24 +47,24 @@ export default function TicketWalletScreen() {
setQrOpen(true); setQrOpen(true);
} }
function openDetail(t: SampleTicket) { function openDetail(ticket: SampleTicket) {
// SCR-P8(예매 확인·취소) 미구현 → 안내만(샘플) // SCR-P8(예매 확인·취소) 미구현 → 안내만(샘플)
Alert.alert( Alert.alert(
'예매 상세·취소', t('tickets.detailTitle'),
`${t.eventName}\n예매번호 ${t.bookingNoMasked}\n\n예매 확인·취소 화면(SCR-P8)은 티켓 백엔드(M10) 연동 후 제공됩니다.`, `${ticket.eventName}\n${ticket.bookingNoMasked}`,
); );
} }
return ( return (
<View style={styles.flex}> <View style={styles.flex}>
<Stack.Screen options={{ title: '내 티켓', headerTitleAlign: 'center' }} /> <Stack.Screen options={{ title: t('nav.ticketsIndex'), headerTitleAlign: 'center' }} />
<ScrollView contentContainerStyle={styles.scroll}> <ScrollView contentContainerStyle={styles.scroll}>
{/* 오프라인 표시 배지 */} {/* 오프라인 표시 배지 */}
<View style={styles.offlineWrap}> <View style={styles.offlineWrap}>
<View style={styles.offlineBadge}> <View style={styles.offlineBadge}>
<View style={styles.offlineDot} /> <View style={styles.offlineDot} />
<Text style={styles.offlineText}> </Text> <Text style={styles.offlineText}>{t('tickets.offline')}</Text>
</View> </View>
</View> </View>
@ -85,22 +87,18 @@ export default function TicketWalletScreen() {
</View> </View>
{/* 배지 전환 안내 배너 */} {/* 배지 전환 안내 배너 */}
<Banner tone="info"> <Banner tone="info">{t('tickets.badgeNotice')}</Banner>
QR로
</Banner>
{/* 티켓 리스트 / 빈 상태 */} {/* 티켓 리스트 / 빈 상태 */}
{filtered.length === 0 ? ( {filtered.length === 0 ? (
<EmptyState /> <EmptyState />
) : ( ) : (
filtered.map((t) => ( filtered.map((tk) => (
<TicketCard key={t.id} ticket={t} onOpenQr={openQr} onDetail={openDetail} /> <TicketCard key={tk.id} ticket={tk} onOpenQr={openQr} onDetail={openDetail} />
)) ))
)} )}
<Text style={styles.footNote}> <Text style={styles.footNote}>{t('tickets.footNote')}</Text>
(M10) .
</Text>
</ScrollView> </ScrollView>
<QrViewerModal <QrViewerModal
@ -115,17 +113,18 @@ export default function TicketWalletScreen() {
} }
function EmptyState() { function EmptyState() {
const { t } = useTranslation();
return ( return (
<View style={styles.empty}> <View style={styles.empty}>
<Ionicons name="ticket-outline" size={44} color={colors.neutral200} /> <Ionicons name="ticket-outline" size={44} color={colors.neutral200} />
<Text style={styles.emptyTitle}> </Text> <Text style={styles.emptyTitle}>{t('tickets.emptyTitle')}</Text>
<Text style={styles.emptyBody}> .</Text> <Text style={styles.emptyBody}>{t('tickets.emptyBody')}</Text>
<Pressable <Pressable
accessibilityRole="button" accessibilityRole="button"
style={styles.emptyBtn} style={styles.emptyBtn}
onPress={() => router.push('/tickets/select')} onPress={() => router.push('/tickets/select')}
> >
<Text style={styles.emptyBtnText}> </Text> <Text style={styles.emptyBtnText}>{t('tickets.emptyBtn')}</Text>
</Pressable> </Pressable>
</View> </View>
); );

View File

@ -0,0 +1,68 @@
/*
* ko/en/zh/ja . more/profile .
* + (AsyncStorage `kintex_lang`) + (react-i18next).
*/
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { useLanguage } from '../context/LanguageContext';
import type { Lang } from '../lib/i18n/types';
import { colors, radius, spacing, touch, type } from '../theme';
import { Card } from './Card';
export function LanguageSelector() {
const { t } = useTranslation();
const { lang, supported, setLanguage } = useLanguage();
return (
<Card>
<Text style={styles.title}>{t('lang.title')}</Text>
<Text style={styles.subtitle}>{t('lang.subtitle')}</Text>
<View style={styles.segment}>
{supported.map((code: Lang) => {
const on = lang === code;
return (
<Pressable
key={code}
accessibilityRole="button"
accessibilityState={{ selected: on }}
accessibilityLabel={t(`lang.${code}` as const)}
style={[styles.segBtn, on && styles.segBtnOn]}
onPress={() => {
if (!on) void setLanguage(code);
}}
>
<Text style={[styles.segText, on && styles.segTextOn]} numberOfLines={1}>
{t(`lang.${code}` as const)}
</Text>
</Pressable>
);
})}
</View>
</Card>
);
}
const styles = StyleSheet.create({
title: { color: colors.neutral900, fontSize: type.h3.fontSize, fontWeight: '700' },
subtitle: { color: colors.neutral500, fontSize: type.caption.fontSize, marginTop: 4, marginBottom: spacing.sm },
segment: {
flexDirection: 'row',
backgroundColor: colors.neutral050,
borderWidth: 1,
borderColor: colors.neutral200,
borderRadius: radius.md,
padding: 4,
gap: 4,
},
segBtn: {
flex: 1,
minHeight: touch.min,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radius.sm,
},
segBtnOn: { backgroundColor: colors.primary600 },
segText: { fontSize: type.caption.fontSize, fontWeight: '700', color: colors.neutral700 },
segTextOn: { color: colors.white },
});

View File

@ -11,11 +11,13 @@ import React, {
useState, useState,
} from 'react'; } from 'react';
import { setAccessToken } from '../lib/api'; import { setAccessToken } from '../lib/api';
import { landingPathFor } from '../lib/roleTrack';
import { secureDelete, secureGet, secureSet } from '../lib/secureStore'; import { secureDelete, secureGet, secureSet } from '../lib/secureStore';
import type { AuthUser, LoginResponse, WorkspaceDto } from '../lib/types'; import type { AuthUser, LoginResponse, WorkspaceDto } from '../lib/types';
const TOKEN_KEY = 'kintex.accessToken'; const TOKEN_KEY = 'kintex.accessToken';
const USER_KEY = 'kintex.user'; const USER_KEY = 'kintex.user';
const LANDING_KEY = 'kintex.landing';
interface AuthState { interface AuthState {
ready: boolean; ready: boolean;
@ -23,6 +25,8 @@ interface AuthState {
user: AuthUser | null; user: AuthUser | null;
workspaces: WorkspaceDto[]; workspaces: WorkspaceDto[];
activeEventId: string | null; activeEventId: string | null;
/** 로그인 시 역할로 계산한 랜딩 라우트(콜드 부팅 시 workspaces 미복원 문제 회피용으로 지속). */
landingPath: string | null;
} }
interface AuthContextValue extends AuthState { interface AuthContextValue extends AuthState {
@ -41,6 +45,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
user: null, user: null,
workspaces: [], workspaces: [],
activeEventId: null, activeEventId: null,
landingPath: null,
}); });
// 부팅 시 토큰 복원 // 부팅 시 토큰 복원
@ -49,10 +54,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
(async () => { (async () => {
const token = await secureGet(TOKEN_KEY); const token = await secureGet(TOKEN_KEY);
const userRaw = await secureGet(USER_KEY); const userRaw = await secureGet(USER_KEY);
const landingPath = await secureGet(LANDING_KEY);
if (cancelled) return; if (cancelled) return;
const user = userRaw ? (JSON.parse(userRaw) as AuthUser) : null; const user = userRaw ? (JSON.parse(userRaw) as AuthUser) : null;
if (token) setAccessToken(token); if (token) setAccessToken(token);
setState((s) => ({ ...s, ready: true, token, user })); setState((s) => ({ ...s, ready: true, token, user, landingPath }));
})(); })();
return () => { return () => {
cancelled = true; cancelled = true;
@ -61,14 +67,18 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const signIn = useCallback(async (res: LoginResponse) => { const signIn = useCallback(async (res: LoginResponse) => {
setAccessToken(res.accessToken); setAccessToken(res.accessToken);
// 역할 우선순위로 랜딩 경로를 계산·지속(콜드 부팅 시 재사용 — workspaces 미복원 misroute 방지).
const landingPath = landingPathFor({ user: res.user, workspaces: res.workspaces });
await secureSet(TOKEN_KEY, res.accessToken); await secureSet(TOKEN_KEY, res.accessToken);
await secureSet(USER_KEY, JSON.stringify(res.user)); await secureSet(USER_KEY, JSON.stringify(res.user));
await secureSet(LANDING_KEY, landingPath);
setState((s) => ({ setState((s) => ({
...s, ...s,
token: res.accessToken, token: res.accessToken,
user: res.user, user: res.user,
workspaces: res.workspaces ?? [], workspaces: res.workspaces ?? [],
activeEventId: res.workspaces?.[0]?.eventId ?? null, activeEventId: res.workspaces?.[0]?.eventId ?? null,
landingPath,
})); }));
}, []); }, []);
@ -76,12 +86,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
setAccessToken(null); setAccessToken(null);
await secureDelete(TOKEN_KEY); await secureDelete(TOKEN_KEY);
await secureDelete(USER_KEY); await secureDelete(USER_KEY);
await secureDelete(LANDING_KEY);
setState((s) => ({ setState((s) => ({
...s, ...s,
token: null, token: null,
user: null, user: null,
workspaces: [], workspaces: [],
activeEventId: null, activeEventId: null,
landingPath: null,
})); }));
}, []); }, []);

View File

@ -0,0 +1,62 @@
/*
* + API .
* i18n lib/i18n() ( ).
* react-i18next가 languageChanged에 useTranslation .
*/
import React, { createContext, useContext, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { loadStoredLanguage, normalizeLang, setAppLanguage } from '../lib/i18n';
import { SUPPORTED_LANGS, type Lang } from '../lib/i18n/types';
interface LanguageContextValue {
lang: Lang;
setLanguage: (lang: Lang) => Promise<void>;
supported: readonly Lang[];
}
const LanguageContext = createContext<LanguageContextValue | undefined>(undefined);
export function LanguageProvider({ children }: { children: React.ReactNode }) {
const { i18n } = useTranslation();
const [lang, setLang] = useState<Lang>(normalizeLang(i18n.language));
// 부팅 시 저장된 사용자 선택 언어 반영(없으면 디바이스 언어 유지).
useEffect(() => {
let cancelled = false;
loadStoredLanguage().then((l) => {
if (!cancelled) setLang(l);
});
return () => {
cancelled = true;
};
}, []);
// 외부(예: 다른 탭)에서 언어가 바뀌어도 로컬 상태 동기화.
useEffect(() => {
const onChanged = (l: string) => setLang(normalizeLang(l));
i18n.on('languageChanged', onChanged);
return () => {
i18n.off('languageChanged', onChanged);
};
}, [i18n]);
const value = useMemo<LanguageContextValue>(
() => ({
lang,
supported: SUPPORTED_LANGS,
setLanguage: async (l: Lang) => {
await setAppLanguage(l);
setLang(l);
},
}),
[lang],
);
return <LanguageContext.Provider value={value}>{children}</LanguageContext.Provider>;
}
export function useLanguage(): LanguageContextValue {
const ctx = useContext(LanguageContext);
if (!ctx) throw new Error('useLanguage must be used within LanguageProvider');
return ctx;
}

83
mobile/lib/i18n/index.ts Normal file
View File

@ -0,0 +1,83 @@
/*
* i18n react-i18next + expo-localization.
* · 언어: ko(/)·en·zh·ja
* · (initImmediate:false) expo-localization getLocales() .
* · AsyncStorage `kintex_lang` . .
* 보안: 번역 릿/ . ( ).
*/
import AsyncStorage from '@react-native-async-storage/async-storage';
import { getLocales } from 'expo-localization';
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import { en } from './locales/en';
import { ja } from './locales/ja';
import { ko } from './locales/ko';
import { zh } from './locales/zh';
import { DEFAULT_LANG, SUPPORTED_LANGS, type Lang } from './types';
export const LANG_STORAGE_KEY = 'kintex_lang';
export const resources = {
ko: { translation: ko },
en: { translation: en },
zh: { translation: zh },
ja: { translation: ja },
} as const;
/** 지원 언어로 정규화(미지원 → 기본). 'zh-Hans' 등 지역 태그는 앞 2자로 축약. */
export function normalizeLang(code: string | null | undefined): Lang {
if (!code) return DEFAULT_LANG;
const base = code.toLowerCase().split(/[-_]/)[0];
return (SUPPORTED_LANGS as readonly string[]).includes(base) ? (base as Lang) : DEFAULT_LANG;
}
/** 디바이스 로케일(동기) → 지원 언어. */
function detectDeviceLang(): Lang {
try {
const locales = getLocales();
return normalizeLang(locales?.[0]?.languageCode ?? locales?.[0]?.languageTag);
} catch {
return DEFAULT_LANG;
}
}
// ── 동기 초기화(디바이스 로케일 기준). 저장된 사용자 선택은 부팅 직후 loadStoredLanguage로 반영. ──
if (!i18n.isInitialized) {
i18n.use(initReactI18next).init({
resources,
lng: detectDeviceLang(),
fallbackLng: DEFAULT_LANG,
supportedLngs: SUPPORTED_LANGS as unknown as string[],
defaultNS: 'translation',
interpolation: { escapeValue: false },
returnNull: false,
initImmediate: false, // 최초 렌더에서 t()가 즉시 동작하도록 동기 초기화
});
}
/** 저장된 사용자 선택 언어를 로드해 적용(부팅 시 1회). 없으면 디바이스 언어 유지. */
export async function loadStoredLanguage(): Promise<Lang> {
try {
const saved = await AsyncStorage.getItem(LANG_STORAGE_KEY);
if (saved) {
const lang = normalizeLang(saved);
if (i18n.language !== lang) await i18n.changeLanguage(lang);
return lang;
}
} catch {
/* graceful — 디바이스 언어 유지 */
}
return normalizeLang(i18n.language);
}
/** 언어 변경 + 지속. 셀렉터 UI에서 호출. */
export async function setAppLanguage(lang: Lang): Promise<void> {
await i18n.changeLanguage(lang);
try {
await AsyncStorage.setItem(LANG_STORAGE_KEY, lang);
} catch {
/* graceful — 메모리 상 언어는 이미 반영됨 */
}
}
export default i18n;

View File

@ -0,0 +1,204 @@
/* English (en). Mirrors ko key shape (enforced by Translations type). */
import type { Translations } from '../types';
export const en: Translations = {
common: {
appName: 'KINTEX AI Exhibition',
loading: 'Checking…',
retry: 'Retry',
cancel: 'Cancel',
close: 'Close',
user: 'User',
none: '-',
unknown: 'Unknown',
error: 'Error',
},
lang: {
title: 'Language',
subtitle: 'Choose the app language. Your choice is saved on this device.',
ko: '한국어',
en: 'English',
zh: '中文',
ja: '日本語',
},
roles: {
ORGANIZER: 'Organizer',
EXHIBITOR: 'Exhibitor',
CONTRACTOR: 'Contractor',
HALL_MANAGER: 'Hall Manager',
},
nav: {
register: 'Sign up',
forgotPassword: 'Find password',
profile: 'My Info',
checklist: 'Site Checklist',
inspection: 'Site Inspection',
ticketsIndex: 'My Tickets',
ticketsSelect: 'Book Ticket',
tabHome: 'Home',
tabField: 'Site',
tabGallery: 'Gallery',
tabMore: 'More',
},
login: {
brandTitle: 'The moment you apply,\nsee the post-build photo first',
brandCaption: 'AI-generated preview · may differ from the actual build',
compromised:
'This device shows security risks (root/hook/debugger). To protect your data and tickets, please use a normal device.',
title: 'Sign in',
email: 'Email',
emailPlaceholder: 'name@company.com',
password: 'Password',
passwordPlaceholder: 'Password',
otpLabel: 'Two-factor code',
otpPlaceholder: '6-digit code',
otpHelper: '6-digit code from your authenticator (TOTP)',
remember: 'Remember email',
submit: 'Sign in',
submitOtp: 'Verify & sign in',
forgot: 'Find password',
register: 'Sign up',
errEmpty: 'Please enter your email and password.',
errOtpEmpty: 'Please enter the two-factor code.',
errOtpRequired: 'A two-factor code is required. Enter the code from your authenticator app.',
errOtpInvalid: 'The code is incorrect or expired.',
errLocked: 'Account locked after too many attempts. Please try again later.',
errGeneric: 'Sign in failed.',
},
register: {
title: 'Create account',
email: 'Email',
emailPlaceholder: 'name@company.com',
name: 'Name',
namePlaceholder: 'John Doe',
password: 'Password',
passwordPlaceholder: '8+ characters',
passwordHelper: '8100 characters',
company: 'Company (optional)',
companyPlaceholder: 'Zioinfo',
invite: 'Invite code (optional)',
invitePlaceholder: 'Event invite code',
submit: 'Sign up',
goLogin: 'Go to sign in',
errInvalid: 'Please check email, name and password (8+ characters).',
errEmailTaken: 'This email is already registered.',
errGeneric: 'Sign up failed.',
doneJoined: 'Registration complete and linked to the invited event. Please sign in.',
done: 'Registration complete. Please sign in.',
},
forgot: {
title: 'Reset password',
email: 'Email',
emailPlaceholder: 'name@company.com',
request: 'Request reset code',
code: 'Reset code',
codePlaceholder: '6-digit code',
newPassword: 'New password',
newPasswordPlaceholder: '8+ characters',
newPasswordHelper: '8100 characters',
submit: 'Change password',
errEmail: 'Please enter your email.',
errFields: 'Please check the reset code and new password (8+ characters).',
errCode: 'The reset code is incorrect or expired.',
errRequest: 'Request failed.',
errReset: 'Reset failed.',
},
home: {
hello: 'Hello, {{name}}',
noEvent: 'You are not part of any event — check your invite link or code.',
progress: 'Progress',
stepApply: 'Apply',
stepApprove: 'Approve',
stepBuild: 'Build',
stepInspect: 'Inspect',
actionChecklist: 'Site Checklist',
actionInspection: 'Site Inspection',
actionGallery: 'Visualization Gallery',
memberInternal: 'KINTEX internal account (Hall Manager)',
memberGeneral: 'General account',
},
field: {
title: 'Site Work',
guide: 'Site features are available to contractors (checklist) and hall managers (inspection).',
checklistTitle: 'Site Checklist',
checklistDesc: 'Check work steps · attach photos · request inspection',
inspectionTitle: 'Site Inspection',
inspectionDesc: 'Approved drawing vs site photo · pass/fail decision',
},
gallery: {
infoBar: 'AI-generated images cannot be used for contracts or reviews — drawings are the build standard',
degraded: 'Cannot reach the server image history; showing samples (degraded).',
empty: 'No images yet — generate preview photos in the design studio.',
retry: 'Regenerate',
studioNote: 'Edit the design studio on desktop',
notice: 'AI-generated images cannot be used for contracts or reviews (drawings are the standard)',
},
more: {
accountInternal: 'KINTEX internal account (Hall Manager)',
accountGeneral: 'General account',
openProfile: 'Open my info',
apiServer: 'API server',
serverStatus: 'Server status',
statusUp: 'Healthy (UP)',
statusDown: 'Unreachable (degraded)',
signOut: 'Sign out',
version: 'KINTEX AI Exhibition · v0.1.0',
},
profile: {
sectionInfo: 'My Info',
sectionEventRoles: 'Event Roles',
sectionSecurity: 'Account · Security',
sectionBiometric: 'Biometric Lock',
sectionNotify: 'Notifications',
labelName: 'Name',
labelUserId: 'User ID',
labelGlobalRole: 'Global role',
labelTenant: 'Venue (tenant)',
labelType: 'Type',
typeInternal: 'KINTEX internal (Hall Manager)',
typeGeneral: 'General account',
photoChange: 'Change profile photo',
photoTitle: 'Profile photo',
photoSelect: 'Choose a photo',
photoLibrary: 'Library',
photoCamera: 'Camera',
photoApply: 'Apply',
photoApplying: 'Applying…',
photoCaption: 'Displayed as a circle.',
photoPending: 'The selected photo is shown on this device only — server sync is in preparation.',
photoPickerUnavailable: 'Image selection is not available on this device.',
loadErr: 'Failed to load your info.',
uploadErr: 'Photo upload failed.',
otpTitle: 'Two-factor (OTP)',
otpOnAuth: 'Enabled · Authenticator (OTP)',
otpOnMethod: 'Enabled · {{method}}',
otpOff: 'Not set — 2-step authenticator (TOTP) is used at sign in.',
on: 'On',
off: 'Off',
bioTitle: 'Fingerprint · Face lock',
bioDesc:
'When on, you re-authenticate with fingerprint or Face when opening My Info. Sign-in 2FA remains unchanged.',
bioUnsupported:
'This device does not support biometrics or has no fingerprint/Face enrolled. Enroll biometrics in device settings and try again.',
bioFailed: 'Biometric authentication failed. Please try again.',
biometric: 'Biometrics',
lockTitle: 'Biometric lock',
lockHint: 'Unlock with fingerprint or Face.',
notifyDeadline: 'Deadline D-day alerts',
notifyApproval: 'Approval · inspection alerts',
notifyPayment: 'Payment · settlement alerts',
notifyNote: 'Notification settings are saved on this device. Server sync is in preparation.',
signOut: 'Sign out',
signOutConfirm: 'Sign out?',
version: 'KINTEX AI Exhibition · My Info',
},
tickets: {
offline: 'Offline — showing saved tickets',
badgeNotice: 'Check in with the ticket QR at the venue to switch to a mobile badge',
footNote: 'Tickets shown are samples — replaced with live data when the ticket backend (M10) is connected.',
emptyTitle: 'You have no tickets',
emptyBody: 'Book an entry pass and your tickets will appear here.',
emptyBtn: 'Book entry pass',
detailTitle: 'Booking detail · cancel',
},
};

View File

@ -0,0 +1,204 @@
/* 日本語 (ja). Mirrors ko key shape (enforced by Translations type). */
import type { Translations } from '../types';
export const ja: Translations = {
common: {
appName: 'KINTEX AI 展示管理',
loading: '確認中…',
retry: '再試行',
cancel: 'キャンセル',
close: '閉じる',
user: 'ユーザー',
none: '-',
unknown: '不明',
error: 'エラー',
},
lang: {
title: '言語',
subtitle: 'アプリの表示言語を選択します。選択はこの端末に保存されます。',
ko: '한국어',
en: 'English',
zh: '中文',
ja: '日本語',
},
roles: {
ORGANIZER: '主催者',
EXHIBITOR: '出展社',
CONTRACTOR: '施工業者',
HALL_MANAGER: 'ホールマネージャー',
},
nav: {
register: '会員登録',
forgotPassword: 'パスワード再設定',
profile: 'マイ情報',
checklist: '現場チェックリスト',
inspection: '現場検査',
ticketsIndex: 'マイチケット',
ticketsSelect: 'チケット予約',
tabHome: 'ホーム',
tabField: '現場',
tabGallery: 'ギャラリー',
tabMore: 'その他',
},
login: {
brandTitle: '申請したその瞬間、\n施工後の写真を先に見る',
brandCaption: 'AI 生成の予想画像 · 実際の施工結果と異なる場合があります',
compromised:
'この端末にセキュリティリスクroot化・フック・デバッガが検出されました。個人情報とチケット保護のため、正常な端末での利用を推奨します。',
title: 'ログイン',
email: 'メール',
emailPlaceholder: 'name@company.com',
password: 'パスワード',
passwordPlaceholder: 'パスワード',
otpLabel: '二段階認証コード',
otpPlaceholder: '6桁コード',
otpHelper: '認証アプリTOTPの6桁コード',
remember: 'メールを記憶',
submit: 'ログイン',
submitOtp: '認証してログイン',
forgot: 'パスワード再設定',
register: '会員登録',
errEmpty: 'メールとパスワードを入力してください。',
errOtpEmpty: '二段階認証コードを入力してください。',
errOtpRequired: '二段階認証コードが必要です。認証アプリのコードを入力してください。',
errOtpInvalid: '認証コードが正しくないか、期限切れです。',
errLocked: 'ログイン試行超過によりアカウントがロックされました。しばらくして再試行してください。',
errGeneric: 'ログインに失敗しました。',
},
register: {
title: 'アカウント作成',
email: 'メール',
emailPlaceholder: 'name@company.com',
name: '氏名',
namePlaceholder: '山田太郎',
password: 'パスワード',
passwordPlaceholder: '8文字以上',
passwordHelper: '8〜100文字',
company: '会社名(任意)',
companyPlaceholder: 'Zioinfo',
invite: '招待コード(任意)',
invitePlaceholder: 'イベント招待コード',
submit: '登録する',
goLogin: 'ログインへ',
errInvalid: 'メール・氏名・パスワード8文字以上を確認してください。',
errEmailTaken: 'すでに登録済みのメールです。',
errGeneric: '会員登録に失敗しました。',
doneJoined: '登録が完了し、招待イベントに連携されました。ログインしてください。',
done: '登録が完了しました。ログインしてください。',
},
forgot: {
title: 'パスワード再設定',
email: 'メール',
emailPlaceholder: 'name@company.com',
request: '再設定コードを要求',
code: '再設定コード',
codePlaceholder: '6桁コード',
newPassword: '新しいパスワード',
newPasswordPlaceholder: '8文字以上',
newPasswordHelper: '8〜100文字',
submit: 'パスワード変更',
errEmail: 'メールを入力してください。',
errFields: '再設定コードと新しいパスワード8文字以上を確認してください。',
errCode: '再設定コードが正しくないか、期限切れです。',
errRequest: 'リクエストに失敗しました。',
errReset: '初期化に失敗しました。',
},
home: {
hello: '{{name}}さん、こんにちは',
noEvent: '参加中のイベントがありません — 招待リンクまたは招待コードをご確認ください。',
progress: '進行状況',
stepApply: '申請',
stepApprove: '承認',
stepBuild: '施工',
stepInspect: '検査',
actionChecklist: '現場チェックリスト',
actionInspection: '現場検査',
actionGallery: 'ビジュアルギャラリー',
memberInternal: 'KINTEX 内部アカウント(ホールマネージャー)',
memberGeneral: '一般アカウント',
},
field: {
title: '現場業務',
guide: '現場機能は施工業者(チェックリスト)とホールマネージャー(検査)の役割で提供されます。',
checklistTitle: '現場チェックリスト',
checklistDesc: '施工工程の確認 · 写真添付 · 検査依頼',
inspectionTitle: '現場検査',
inspectionDesc: '承認図面 vs 現場写真 · 適合/不適合の判定',
},
gallery: {
infoBar: 'AI 生成画像は契約・審査書類に使用できません — 施工基準は図面です',
degraded: 'サーバーの画像履歴に接続できないため、サンプルを表示しますdegraded。',
empty: 'まだ生成された画像がありません — 設計スタジオで予想写真を生成してください。',
retry: '再生成',
studioNote: '設計スタジオはデスクトップで編集',
notice: 'AI 生成画像は契約・審査書類に使用できません(施工基準は図面)',
},
more: {
accountInternal: 'KINTEX 内部アカウント(ホールマネージャー)',
accountGeneral: '一般アカウント',
openProfile: 'マイ情報を開く',
apiServer: 'API サーバー',
serverStatus: 'サーバー状態',
statusUp: '正常 (UP)',
statusDown: '接続不可 (degraded)',
signOut: 'ログアウト',
version: 'KINTEX AI 展示管理 · v0.1.0',
},
profile: {
sectionInfo: 'マイ情報',
sectionEventRoles: '参加イベントの役割',
sectionSecurity: 'アカウント · セキュリティ',
sectionBiometric: '生体認証ロック',
sectionNotify: '通知設定',
labelName: '氏名',
labelUserId: 'ユーザー ID',
labelGlobalRole: 'グローバル役割',
labelTenant: '展示館(テナント)',
labelType: '区分',
typeInternal: 'KINTEX 内部(ホールマネージャー)',
typeGeneral: '一般アカウント',
photoChange: 'プロフィール写真を変更',
photoTitle: 'プロフィール写真',
photoSelect: '写真を選択してください',
photoLibrary: 'ギャラリー',
photoCamera: 'カメラ',
photoApply: '適用',
photoApplying: '適用中…',
photoCaption: '円形で表示されます。',
photoPending: '選択した写真はこの端末のみに表示されます — サーバー反映は準備中です。',
photoPickerUnavailable: 'この端末では画像選択機能を利用できません。',
loadErr: 'マイ情報を読み込めませんでした。',
uploadErr: '写真のアップロードに失敗しました。',
otpTitle: '二段階認証OTP',
otpOnAuth: '有効 · AuthenticatorOTP',
otpOnMethod: '有効 · {{method}}',
otpOff: '未設定 — ログイン時に認証アプリTOTPの二段階認証を使用します。',
on: 'オン',
off: 'オフ',
bioTitle: '指紋 · Face ロック',
bioDesc:
'オンにするとマイ情報を開く際に指紋または Face で再認証します。ログインの二段階認証はそのまま維持されます。',
bioUnsupported:
'この端末は生体認証に対応していないか、指紋/Face が登録されていません。端末設定で生体情報を登録してから再試行してください。',
bioFailed: '生体認証に失敗しました。再試行してください。',
biometric: '生体認証',
lockTitle: '生体認証ロック',
lockHint: '指紋または Face でロックを解除してください。',
notifyDeadline: '締切 D-day 通知',
notifyApproval: '承認・検査通知',
notifyPayment: '決済・精算通知',
notifyNote: '通知設定はこの端末に保存されます。サーバー同期は準備中です。',
signOut: 'ログアウト',
signOutConfirm: 'ログアウトしますか?',
version: 'KINTEX AI 展示管理 · マイ情報',
},
tickets: {
offline: 'オフライン — 保存済みチケットを表示中',
badgeNotice: 'チケット QR で現場チェックインするとモバイルバッジに切り替わります',
footNote: '表示中のチケットはサンプルです — チケットバックエンドM10連携時に実データへ置き換わります。',
emptyTitle: '保有チケットがありません',
emptyBody: '入場券を予約するとここにチケットが表示されます。',
emptyBtn: '入場券を予約',
detailTitle: '予約詳細 · キャンセル',
},
};

View File

@ -0,0 +1,208 @@
/*
* (ko) / . ** (shape) .**
* (en/zh/ja) (Resource) tsc가 .
* ( · ).
*/
export const ko = {
common: {
appName: 'KINTEX AI 전시관리',
loading: '확인 중…',
retry: '다시 시도',
cancel: '취소',
close: '닫기',
user: '사용자',
none: '-',
unknown: '알 수 없음',
error: '오류',
},
lang: {
title: '언어',
subtitle: '앱 표시 언어를 선택하세요. 선택은 이 기기에 저장됩니다.',
ko: '한국어',
en: 'English',
zh: '中文',
ja: '日本語',
},
roles: {
ORGANIZER: '주최자',
EXHIBITOR: '참가업체',
CONTRACTOR: '장치·시공업체',
HALL_MANAGER: '홀매니저',
},
nav: {
register: '회원가입',
forgotPassword: '비밀번호 찾기',
profile: '내 정보',
checklist: '현장 체크리스트',
inspection: '현장 검수',
ticketsIndex: '내 티켓',
ticketsSelect: '티켓 예매',
tabHome: '홈',
tabField: '현장',
tabGallery: '갤러리',
tabMore: '더보기',
},
login: {
brandTitle: '신청서를 내는 순간,\n시공 후 사진을 먼저 봅니다',
brandCaption: 'AI 생성 예상 이미지 · 실제 시공 결과와 다를 수 있습니다',
compromised:
'보안 위험이 감지된 기기입니다(루팅·후킹·디버거). 개인정보·티켓 보호를 위해 정상 기기에서 이용을 권장합니다.',
title: '로그인',
email: '이메일',
emailPlaceholder: 'name@company.co.kr',
password: '비밀번호',
passwordPlaceholder: '비밀번호',
otpLabel: '2차 인증 코드',
otpPlaceholder: '6자리 코드',
otpHelper: '인증 앱(TOTP)의 6자리 코드',
remember: '아이디 기억',
submit: '로그인',
submitOtp: '인증 후 로그인',
forgot: '비밀번호 찾기',
register: '회원가입',
errEmpty: '이메일과 비밀번호를 입력해 주세요.',
errOtpEmpty: '2차 인증 코드를 입력해 주세요.',
errOtpRequired: '2차 인증 코드가 필요합니다. 인증 앱의 코드를 입력해 주세요.',
errOtpInvalid: '인증 코드가 올바르지 않거나 만료되었습니다.',
errLocked: '로그인 시도 초과로 계정이 잠겼습니다. 잠시 후 다시 시도해 주세요.',
errGeneric: '로그인에 실패했습니다.',
},
register: {
title: '계정 만들기',
email: '이메일',
emailPlaceholder: 'name@company.co.kr',
name: '이름',
namePlaceholder: '홍길동',
password: '비밀번호',
passwordPlaceholder: '8자 이상',
passwordHelper: '8~100자',
company: '회사명 (선택)',
companyPlaceholder: '지오인포',
invite: '초대 코드 (선택)',
invitePlaceholder: '행사 초대 코드',
submit: '가입하기',
goLogin: '로그인으로 이동',
errInvalid: '이메일·이름·비밀번호(8자 이상)를 확인해 주세요.',
errEmailTaken: '이미 가입된 이메일입니다.',
errGeneric: '회원가입에 실패했습니다.',
doneJoined: '가입이 완료되었고 초대 행사에 연결되었습니다. 로그인해 주세요.',
done: '가입이 완료되었습니다. 로그인해 주세요.',
},
forgot: {
title: '비밀번호 재설정',
email: '이메일',
emailPlaceholder: 'name@company.co.kr',
request: '재설정 코드 요청',
code: '재설정 코드',
codePlaceholder: '6자리 코드',
newPassword: '새 비밀번호',
newPasswordPlaceholder: '8자 이상',
newPasswordHelper: '8~100자',
submit: '비밀번호 변경',
errEmail: '이메일을 입력해 주세요.',
errFields: '재설정 코드와 새 비밀번호(8자 이상)를 확인해 주세요.',
errCode: '재설정 코드가 올바르지 않거나 만료되었습니다.',
errRequest: '요청에 실패했습니다.',
errReset: '초기화에 실패했습니다.',
},
home: {
hello: '{{name}}님, 안녕하세요',
noEvent: '참여 중인 행사가 없습니다 — 초대 링크 또는 초대 코드를 확인해 주세요.',
progress: '진행 상태',
stepApply: '신청',
stepApprove: '승인',
stepBuild: '시공',
stepInspect: '검수',
actionChecklist: '현장 체크리스트',
actionInspection: '현장 검수',
actionGallery: '시각화 갤러리',
memberInternal: '킨텍스 내부 계정 (홀매니저)',
memberGeneral: '일반 계정',
},
field: {
title: '현장 업무',
guide: '현장 기능은 장치·시공업체(체크리스트)와 홀매니저(검수) 역할에서 제공됩니다.',
checklistTitle: '현장 체크리스트',
checklistDesc: '장치 공정 확인 · 사진 첨부 · 검수 요청',
inspectionTitle: '현장 검수',
inspectionDesc: '승인 도면 vs 현장 사진 · 적합/부적합 판정',
},
gallery: {
infoBar: 'AI 생성 이미지는 계약·심사 서류에 사용할 수 없습니다 — 시공 기준은 도면입니다',
degraded: '서버 이미지 이력에 연결할 수 없어 예시를 표시합니다 (degraded).',
empty: '아직 생성된 이미지가 없습니다 — 설계 스튜디오에서 예상 사진을 생성하세요.',
retry: '다시 생성',
studioNote: '설계 스튜디오는 데스크톱에서 편집',
notice: 'AI 생성 이미지는 계약·심사 서류에 사용할 수 없습니다 (시공 기준은 도면)',
},
more: {
accountInternal: '킨텍스 내부 계정 (홀매니저)',
accountGeneral: '일반 계정',
openProfile: '내 정보 열기',
apiServer: 'API 서버',
serverStatus: '서버 상태',
statusUp: '정상 (UP)',
statusDown: '연결 불가 (degraded)',
signOut: '로그아웃',
version: 'KINTEX AI 전시관리 · v0.1.0',
},
profile: {
sectionInfo: '내 정보',
sectionEventRoles: '참여 행사 역할',
sectionSecurity: '계정 · 보안',
sectionBiometric: '생체인식 잠금',
sectionNotify: '알림 설정',
labelName: '이름',
labelUserId: '사용자 ID',
labelGlobalRole: '전역 역할',
labelTenant: '전시관(테넌트)',
labelType: '구분',
typeInternal: '킨텍스 내부(홀매니저)',
typeGeneral: '일반 계정',
photoChange: '프로필 사진 변경',
photoTitle: '프로필 사진',
photoSelect: '사진을 선택하세요',
photoLibrary: '갤러리',
photoCamera: '카메라',
photoApply: '적용',
photoApplying: '적용 중…',
photoCaption: '원형으로 표시됩니다.',
photoPending: '선택한 사진은 이 기기에만 표시됩니다 — 서버 반영은 준비 중입니다.',
photoPickerUnavailable: '이미지 선택 기능을 사용할 수 없는 기기입니다.',
loadErr: '내정보를 불러오지 못했습니다.',
uploadErr: '사진 업로드에 실패했습니다.',
otpTitle: '2차 인증(OTP)',
otpOnAuth: '활성화됨 · Authenticator(OTP)',
otpOnMethod: '활성화됨 · {{method}}',
otpOff: '미설정 — 로그인 시 인증 앱(TOTP) 2단계 인증을 사용합니다.',
on: '켜짐',
off: '꺼짐',
bioTitle: '지문 · Face 잠금',
bioDesc:
'켜면 내정보 진입 시 지문 또는 Face로 재인증합니다. 로그인 2차 인증은 그대로 유지됩니다.',
bioUnsupported:
'이 기기는 생체인식을 지원하지 않거나 지문/Face가 등록되어 있지 않습니다. 기기 설정에서 생체 정보를 등록한 뒤 다시 시도해 주세요.',
bioFailed: '생체 인증에 실패했습니다. 다시 시도해 주세요.',
biometric: '생체인식',
lockTitle: '생체인식 잠금',
lockHint: '지문 또는 Face로 잠금을 해제하세요.',
notifyDeadline: '마감 D-데이 알림',
notifyApproval: '승인·검수 알림',
notifyPayment: '결제·정산 알림',
notifyNote: '알림 설정은 이 기기에 저장됩니다. 서버 동기화는 준비 중입니다.',
signOut: '로그아웃',
signOutConfirm: '로그아웃하시겠습니까?',
version: 'KINTEX AI 전시관리 · 내정보',
},
tickets: {
offline: '오프라인 — 저장된 티켓 표시 중',
badgeNotice: '티켓 QR로 현장 체크인하면 모바일 배지로 전환됩니다',
footNote: '표시된 티켓은 샘플입니다 — 티켓 백엔드(M10) 연동 시 실데이터로 대체됩니다.',
emptyTitle: '보유한 티켓이 없습니다',
emptyBody: '입장권을 예매하면 이곳에 티켓이 표시됩니다.',
emptyBtn: '입장권 예매',
detailTitle: '예매 상세·취소',
},
} as const;
export type Resource = typeof ko;

View File

@ -0,0 +1,203 @@
/* 简体中文 (zh). Mirrors ko key shape (enforced by Translations type). */
import type { Translations } from '../types';
export const zh: Translations = {
common: {
appName: 'KINTEX AI 展览管理',
loading: '确认中…',
retry: '重试',
cancel: '取消',
close: '关闭',
user: '用户',
none: '-',
unknown: '未知',
error: '错误',
},
lang: {
title: '语言',
subtitle: '选择应用显示语言。选择将保存在本设备上。',
ko: '한국어',
en: 'English',
zh: '中文',
ja: '日本語',
},
roles: {
ORGANIZER: '主办方',
EXHIBITOR: '参展商',
CONTRACTOR: '搭建施工商',
HALL_MANAGER: '展厅经理',
},
nav: {
register: '注册',
forgotPassword: '找回密码',
profile: '我的信息',
checklist: '现场检查表',
inspection: '现场验收',
ticketsIndex: '我的门票',
ticketsSelect: '订票',
tabHome: '首页',
tabField: '现场',
tabGallery: '图库',
tabMore: '更多',
},
login: {
brandTitle: '提交申请的瞬间,\n先看到施工后的效果图',
brandCaption: 'AI 生成预览图 · 可能与实际施工结果不同',
compromised:
'检测到该设备存在安全风险(越狱·注入·调试器)。为保护个人信息和门票,建议在正常设备上使用。',
title: '登录',
email: '邮箱',
emailPlaceholder: 'name@company.com',
password: '密码',
passwordPlaceholder: '密码',
otpLabel: '二次验证码',
otpPlaceholder: '6位验证码',
otpHelper: '验证器应用TOTP的6位验证码',
remember: '记住邮箱',
submit: '登录',
submitOtp: '验证并登录',
forgot: '找回密码',
register: '注册',
errEmpty: '请输入邮箱和密码。',
errOtpEmpty: '请输入二次验证码。',
errOtpRequired: '需要二次验证码。请输入验证器应用中的验证码。',
errOtpInvalid: '验证码不正确或已过期。',
errLocked: '登录尝试过多,账户已锁定。请稍后重试。',
errGeneric: '登录失败。',
},
register: {
title: '创建账户',
email: '邮箱',
emailPlaceholder: 'name@company.com',
name: '姓名',
namePlaceholder: '张三',
password: '密码',
passwordPlaceholder: '8位以上',
passwordHelper: '8~100位',
company: '公司名(可选)',
companyPlaceholder: 'Zioinfo',
invite: '邀请码(可选)',
invitePlaceholder: '活动邀请码',
submit: '注册',
goLogin: '前往登录',
errInvalid: '请检查邮箱·姓名·密码8位以上。',
errEmailTaken: '该邮箱已注册。',
errGeneric: '注册失败。',
doneJoined: '注册完成并已关联受邀活动。请登录。',
done: '注册完成。请登录。',
},
forgot: {
title: '重置密码',
email: '邮箱',
emailPlaceholder: 'name@company.com',
request: '请求重置码',
code: '重置码',
codePlaceholder: '6位验证码',
newPassword: '新密码',
newPasswordPlaceholder: '8位以上',
newPasswordHelper: '8~100位',
submit: '修改密码',
errEmail: '请输入邮箱。',
errFields: '请检查重置码和新密码8位以上。',
errCode: '重置码不正确或已过期。',
errRequest: '请求失败。',
errReset: '重置失败。',
},
home: {
hello: '{{name}},您好',
noEvent: '您尚未参与任何活动 — 请确认邀请链接或邀请码。',
progress: '进度状态',
stepApply: '申请',
stepApprove: '审批',
stepBuild: '施工',
stepInspect: '验收',
actionChecklist: '现场检查表',
actionInspection: '现场验收',
actionGallery: '可视化图库',
memberInternal: 'KINTEX 内部账户(展厅经理)',
memberGeneral: '普通账户',
},
field: {
title: '现场工作',
guide: '现场功能面向搭建施工商(检查表)和展厅经理(验收)角色提供。',
checklistTitle: '现场检查表',
checklistDesc: '确认施工工序 · 附加照片 · 申请验收',
inspectionTitle: '现场验收',
inspectionDesc: '审批图纸 vs 现场照片 · 合格/不合格判定',
},
gallery: {
infoBar: 'AI 生成图片不可用于合同·审查文件 — 施工标准以图纸为准',
degraded: '无法连接服务器图片记录显示示例degraded。',
empty: '暂无生成的图片 — 请在设计工作室生成效果图。',
retry: '重新生成',
studioNote: '设计工作室请在桌面端编辑',
notice: 'AI 生成图片不可用于合同·审查文件(施工标准以图纸为准)',
},
more: {
accountInternal: 'KINTEX 内部账户(展厅经理)',
accountGeneral: '普通账户',
openProfile: '打开我的信息',
apiServer: 'API 服务器',
serverStatus: '服务器状态',
statusUp: '正常 (UP)',
statusDown: '无法连接 (degraded)',
signOut: '退出登录',
version: 'KINTEX AI 展览管理 · v0.1.0',
},
profile: {
sectionInfo: '我的信息',
sectionEventRoles: '参与活动角色',
sectionSecurity: '账户 · 安全',
sectionBiometric: '生物识别锁',
sectionNotify: '通知设置',
labelName: '姓名',
labelUserId: '用户 ID',
labelGlobalRole: '全局角色',
labelTenant: '展馆(租户)',
labelType: '类型',
typeInternal: 'KINTEX 内部(展厅经理)',
typeGeneral: '普通账户',
photoChange: '更换头像',
photoTitle: '头像',
photoSelect: '请选择照片',
photoLibrary: '相册',
photoCamera: '相机',
photoApply: '应用',
photoApplying: '应用中…',
photoCaption: '将以圆形显示。',
photoPending: '所选照片仅显示在本设备 — 服务器同步准备中。',
photoPickerUnavailable: '该设备无法使用图片选择功能。',
loadErr: '无法加载我的信息。',
uploadErr: '照片上传失败。',
otpTitle: '二次验证OTP',
otpOnAuth: '已启用 · AuthenticatorOTP',
otpOnMethod: '已启用 · {{method}}',
otpOff: '未设置 — 登录时使用验证器应用TOTP两步验证。',
on: '开',
off: '关',
bioTitle: '指纹 · Face 锁',
bioDesc: '开启后进入我的信息时需用指纹或 Face 再次验证。登录二次验证保持不变。',
bioUnsupported:
'该设备不支持生物识别,或未录入指纹/Face。请在设备设置中录入生物信息后重试。',
bioFailed: '生物识别验证失败。请重试。',
biometric: '生物识别',
lockTitle: '生物识别锁',
lockHint: '请用指纹或 Face 解锁。',
notifyDeadline: '截止 D-day 提醒',
notifyApproval: '审批·验收提醒',
notifyPayment: '支付·结算提醒',
notifyNote: '通知设置保存在本设备。服务器同步准备中。',
signOut: '退出登录',
signOutConfirm: '要退出登录吗?',
version: 'KINTEX AI 展览管理 · 我的信息',
},
tickets: {
offline: '离线 — 显示已保存门票',
badgeNotice: '在现场用门票二维码签到即可转换为移动徽章',
footNote: '所示门票为示例 — 门票后端M10连接后将替换为真实数据。',
emptyTitle: '暂无门票',
emptyBody: '预订入场券后,门票将显示在此处。',
emptyBtn: '预订入场券',
detailTitle: '预订详情 · 取消',
},
};

16
mobile/lib/i18n/types.ts Normal file
View File

@ -0,0 +1,16 @@
/*
* i18n .
* ko() DeepString으로 en/zh/ja가 tsc가 .
*/
import type { Resource } from './locales/ko';
export type DeepString<T> = {
[K in keyof T]: T[K] extends string ? string : DeepString<T[K]>;
};
/** 모든 로케일이 구현해야 하는 번역 형상. */
export type Translations = DeepString<Resource>;
export const SUPPORTED_LANGS = ['ko', 'en', 'zh', 'ja'] as const;
export type Lang = (typeof SUPPORTED_LANGS)[number];
export const DEFAULT_LANG: Lang = 'ko';

78
mobile/lib/roleTrack.ts Normal file
View File

@ -0,0 +1,78 @@
/*
* · () src/frontend/src/lib/roleTrack.ts ().
* 우선순위: admin > ops > business > agency > visitor. visitor() .
*
* ( ):
* · user(AuthUser) roleCode가 hallManager=true를 ops .
* (roleCode를 /api/auth/me의 MePrincipal.roleCode )
* · (/admin·/contractor/dashboard·/visitor·/home) .
*/
import type { AuthUser, EventRole, WorkspaceDto } from './types';
export type Track = 'visitor' | 'business' | 'agency' | 'ops' | 'admin';
/** 워크스페이스 역할 → 트랙(스코프 판정용). 웹 trackOf와 동일. */
export function trackOf(role: EventRole | null | undefined): Track {
switch (role) {
case 'ORGANIZER':
case 'EXHIBITOR':
return 'business';
case 'CONTRACTOR':
return 'agency';
case 'HALL_MANAGER':
return 'ops';
default:
return 'visitor';
}
}
/** 랜딩 판정 입력 — 로그인 응답 user + workspaces. roleCode는 알 때만(옵션). */
export interface LandingInput {
user?: (AuthUser & { roleCode?: string | null }) | null;
workspaces?: WorkspaceDto[] | null;
}
/**
* (primaryTrack ). resolveLandingTrack .
* roleCode('ADMIN'/'MANAGER') , hallManager로 ops를 .
*/
export function resolveLandingTrack({ user, workspaces }: LandingInput): Track {
const role = (user?.roleCode ?? '').toUpperCase();
const ws = workspaces ?? [];
if (role === 'ADMIN') return 'admin';
if (role === 'MANAGER' || user?.hallManager || ws.some((w) => w.myRole === 'HALL_MANAGER')) {
return 'ops';
}
if (ws.some((w) => w.myRole === 'ORGANIZER' || w.myRole === 'EXHIBITOR')) return 'business';
if (ws.some((w) => w.myRole === 'CONTRACTOR')) return 'agency';
return 'visitor';
}
/**
* ().
* · admin/ops/business (/(tabs)) admin ,
* · agency(·) (/(tabs)/field)
* · visitor() (/tickets) B2C
*/
export function landingPathForTrack(track: Track): string {
switch (track) {
case 'agency':
return '/(tabs)/field';
case 'visitor':
return '/tickets';
case 'admin':
case 'ops':
case 'business':
default:
return '/(tabs)';
}
}
/** 입력으로부터 랜딩 라우트 계산(예외 시 탭 홈 폴백). */
export function landingPathFor(input: LandingInput): string {
try {
return landingPathForTrack(resolveLandingTrack(input));
} catch {
return '/(tabs)';
}
}

View File

@ -17,14 +17,17 @@
"expo-image-picker": "~15.1.0", "expo-image-picker": "~15.1.0",
"expo-linking": "~6.3.1", "expo-linking": "~6.3.1",
"expo-local-authentication": "~14.0.1", "expo-local-authentication": "~14.0.1",
"expo-localization": "~15.0.3",
"expo-router": "~3.5.23", "expo-router": "~3.5.23",
"expo-screen-capture": "~6.0.1", "expo-screen-capture": "~6.0.1",
"expo-secure-store": "~13.0.2", "expo-secure-store": "~13.0.2",
"expo-splash-screen": "~0.27.7", "expo-splash-screen": "~0.27.7",
"expo-status-bar": "~1.12.1", "expo-status-bar": "~1.12.1",
"i18next": "^23.16.8",
"jail-monkey": "^2.8.0", "jail-monkey": "^2.8.0",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-i18next": "^14.1.3",
"react-native": "0.74.5", "react-native": "0.74.5",
"react-native-gesture-handler": "~2.16.1", "react-native-gesture-handler": "~2.16.1",
"react-native-safe-area-context": "4.10.5", "react-native-safe-area-context": "4.10.5",
@ -7742,11 +7745,24 @@
"expo": "*" "expo": "*"
} }
}, },
"node_modules/expo-localization": {
"version": "15.0.3",
"resolved": "https://registry.npmjs.org/expo-localization/-/expo-localization-15.0.3.tgz",
"integrity": "sha512-IfcmlKuKRlowR9qIzL0e+nGHBeNoF7l2GQaOJstc7HZiPjNJ4J1R4D53ZNf483dt7JSkTRJBihdTadOtOEjRdg==",
"license": "MIT",
"dependencies": {
"rtl-detect": "^1.0.2"
},
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-modules-autolinking": { "node_modules/expo-modules-autolinking": {
"version": "1.11.3", "version": "1.11.3",
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-1.11.3.tgz", "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-1.11.3.tgz",
"integrity": "sha512-oYh8EZEvYF5TYppxEKUTTJmbr8j7eRRnrIxzZtMvxLTXoujThVPMFS/cbnSnf2bFm1lq50TdDNABhmEi7z0ngQ==", "integrity": "sha512-oYh8EZEvYF5TYppxEKUTTJmbr8j7eRRnrIxzZtMvxLTXoujThVPMFS/cbnSnf2bFm1lq50TdDNABhmEi7z0ngQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"chalk": "^4.1.0", "chalk": "^4.1.0",
"commander": "^7.2.0", "commander": "^7.2.0",
@ -8659,6 +8675,15 @@
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"license": "MIT",
"dependencies": {
"void-elements": "3.1.0"
}
},
"node_modules/http-errors": { "node_modules/http-errors": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
@ -8712,6 +8737,30 @@
"integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==", "integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==",
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/i18next": {
"version": "23.16.8",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-23.16.8.tgz",
"integrity": "sha512-06r/TitrM88Mg5FdUXAKL96dJMzgqLE5dv3ryBAra4KCwD9mJ4ndOTS95ZuymIGoE+2hzfdaMak2X11/es7ZWg==",
"funding": [
{
"type": "individual",
"url": "https://locize.com"
},
{
"type": "individual",
"url": "https://locize.com/i18next.html"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.23.2"
}
},
"node_modules/ieee754": { "node_modules/ieee754": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@ -12455,6 +12504,28 @@
"react": ">=17.0.0" "react": ">=17.0.0"
} }
}, },
"node_modules/react-i18next": {
"version": "14.1.3",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-14.1.3.tgz",
"integrity": "sha512-wZnpfunU6UIAiJ+bxwOiTmBOAaB14ha97MjOEnLGac2RJ+h/maIYXZuTHlmyqQVX1UVHmU1YDTQ5vxLmwfXTjw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.23.9",
"html-parse-stringify": "^3.0.1"
},
"peerDependencies": {
"i18next": ">= 23.2.3",
"react": ">= 16.8.0"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
}
}
},
"node_modules/react-is": { "node_modules/react-is": {
"version": "16.13.1", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@ -13061,6 +13132,12 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/rtl-detect": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/rtl-detect/-/rtl-detect-1.1.2.tgz",
"integrity": "sha512-PGMBq03+TTG/p/cRB7HCLKJ1MgDIi07+QU1faSjiYRfmY5UsAttV9Hs08jDAHVwcOwmVLcSJkpwyfXszVjWfIQ==",
"license": "BSD-3-Clause"
},
"node_modules/run-parallel": { "node_modules/run-parallel": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@ -14914,6 +14991,15 @@
"integrity": "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==", "integrity": "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/walker": { "node_modules/walker": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz",

View File

@ -20,14 +20,17 @@
"expo-image-picker": "~15.1.0", "expo-image-picker": "~15.1.0",
"expo-linking": "~6.3.1", "expo-linking": "~6.3.1",
"expo-local-authentication": "~14.0.1", "expo-local-authentication": "~14.0.1",
"expo-localization": "~15.0.3",
"expo-router": "~3.5.23", "expo-router": "~3.5.23",
"expo-screen-capture": "~6.0.1", "expo-screen-capture": "~6.0.1",
"expo-secure-store": "~13.0.2", "expo-secure-store": "~13.0.2",
"expo-splash-screen": "~0.27.7", "expo-splash-screen": "~0.27.7",
"expo-status-bar": "~1.12.1", "expo-status-bar": "~1.12.1",
"i18next": "^23.16.8",
"jail-monkey": "^2.8.0", "jail-monkey": "^2.8.0",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-i18next": "^14.1.3",
"react-native": "0.74.5", "react-native": "0.74.5",
"react-native-gesture-handler": "~2.16.1", "react-native-gesture-handler": "~2.16.1",
"react-native-safe-area-context": "4.10.5", "react-native-safe-area-context": "4.10.5",