1- import { type FormEvent , useEffect , useMemo , useState } from "react" ;
1+ import { Suspense , type FormEvent , useEffect , useMemo , useState } from "react" ;
22import { createRoot } from "react-dom/client" ;
33import { useTranslation } from "react-i18next" ;
44
@@ -23,33 +23,18 @@ import { MarketplaceView } from "./views/MarketplaceView";
2323import { ChangePasswordView } from "./views/ChangePasswordView" ;
2424import { ForgotPasswordView } from "./views/ForgotPasswordView" ;
2525import { SetupWizardView } from "./views/SetupWizardView" ;
26- import { LedgerView } from "../../../modules/ledger/frontend/LedgerView" ;
27- import { SubscriptionView } from "../../../modules/subscription/frontend/SubscriptionView" ;
28- import { MODULE_SUB_NAV } from "./moduleConfig" ;
29-
30- // ─── Helpers ──────────────────────────────────────────────────
31-
32- // 코어 라우트 목록 (앱 shell 없이 전체 화면으로 렌더되는 것 제외)
33- const CORE_ROUTES = [ "login" , "forgot-password" , "home" , "marketplace" , "admin" , "change-password" ] as const ;
34- // 모듈 라우트 — module.json name 기준 (서버 레지스트리와 일치)
35- const MODULE_ROUTES : string [ ] = [ "ledger" , "subscription" ] ;
36-
37- /** 해시에서 베이스 라우트만 추출 ("ledger/import" → "ledger") */
38- function getRouteFromHash ( rawHash : string ) : RouteKey {
39- const hash = rawHash . replace ( "#" , "" ) ;
40- const base = hash . split ( "/" ) [ 0 ] ?? hash ;
41- if ( base === "settings" ) return "home" ;
42- if ( ( CORE_ROUTES as readonly string [ ] ) . includes ( base ) ) return base as RouteKey ;
43- if ( MODULE_ROUTES . includes ( base ) ) return base as RouteKey ;
44- return "login" ;
45- }
46-
47- /** 해시에서 서브 라우트만 추출 ("ledger/import" → "import", "ledger" → "") */
48- function getSubRouteFromHash ( rawHash : string ) : string {
49- const hash = rawHash . replace ( "#" , "" ) ;
50- const parts = hash . split ( "/" ) ;
51- return parts . length > 1 ? parts . slice ( 1 ) . join ( "/" ) : "" ;
52- }
26+ import {
27+ MODULE_SUB_NAV ,
28+ getModuleView ,
29+ preloadModuleViews ,
30+ registerModuleLocales ,
31+ } from "./moduleRegistry" ;
32+ import {
33+ canStoreRedirectTarget ,
34+ getDeepLinkTarget ,
35+ getRouteFromHash ,
36+ getSubRouteFromHash ,
37+ } from "./routeConfig" ;
5338
5439// ─── Theme ────────────────────────────────────────────────────
5540type ThemeSetting = "light" | "dark" | "system" ;
@@ -64,6 +49,23 @@ function applyTheme(setting: ThemeSetting) {
6449 try { localStorage . setItem ( "fs_theme" , setting ) ; } catch { /* ignore */ }
6550}
6651
52+ function runWhenBrowserIdle ( task : ( ) => void ) : ( ) => void {
53+ // requestIdleCallback 지원 브라우저에서는 유휴 시간에 실행하고,
54+ // 미지원 브라우저에서는 짧은 setTimeout으로 동작을 에뮬레이션한다.
55+ const idleWindow = window as Window & {
56+ requestIdleCallback ?: ( callback : ( ) => void ) => number ;
57+ cancelIdleCallback ?: ( id : number ) => void ;
58+ } ;
59+
60+ if ( idleWindow . requestIdleCallback ) {
61+ const idleId = idleWindow . requestIdleCallback ( task ) ;
62+ return ( ) => idleWindow . cancelIdleCallback ?.( idleId ) ;
63+ }
64+
65+ const timeoutId = window . setTimeout ( task , 120 ) ;
66+ return ( ) => window . clearTimeout ( timeoutId ) ;
67+ }
68+
6769function loadTheme ( ) : ThemeSetting {
6870 try {
6971 const saved = localStorage . getItem ( "fs_theme" ) ;
@@ -74,6 +76,9 @@ function loadTheme(): ThemeSetting {
7476
7577// 초기 테마 적용 (React 렌더 전에 FOUC 방지)
7678applyTheme ( loadTheme ( ) ) ;
79+ // 모듈 i18n 번역 리소스를 앱 부트 시점에 선등록해
80+ // 모듈 첫 진입 시 영어 -> 한국어로 늦게 바뀌는 깜빡임을 줄인다.
81+ registerModuleLocales ( ) ;
7782
7883// ─── Storage Keys ─────────────────────────────────────────────
7984const SS = {
@@ -91,13 +96,6 @@ const LS = {
9196 startupRoute : "fs_startup_route" ,
9297} as const ;
9398
94- // 딥 링크: 비인증 상태에서 진입한 app route 반환 (모듈 라우트 포함)
95- function getDeepLinkTarget ( ) : RouteKey | null {
96- const hash = window . location . hash . replace ( "#" , "" ) ;
97- const appRoutes : string [ ] = [ "home" , "marketplace" , "admin" , ...MODULE_ROUTES ] ;
98- return appRoutes . includes ( hash ) ? ( hash as RouteKey ) : null ;
99- }
100-
10199// 개인화: 로그인 후 첫 화면 설정
102100type StartupRoute = "home" | "marketplace" ;
103101
@@ -155,7 +153,7 @@ function App() {
155153 ) ;
156154 // 딥 링크: 비인증 상태에서 진입한 app route (로그인 후 복귀)
157155 const [ redirectAfterLogin , setRedirectAfterLogin ] = useState < RouteKey | null > (
158- ( ) => ( sessionStorage . getItem ( SS . auth ) === "true" ? null : getDeepLinkTarget ( ) ) ,
156+ ( ) => ( sessionStorage . getItem ( SS . auth ) === "true" ? null : getDeepLinkTarget ( window . location . hash ) ) ,
159157 ) ;
160158 // 첫 방문 온보딩 배너
161159 const [ isFirstVisit , setIsFirstVisit ] = useState ( false ) ;
@@ -184,8 +182,7 @@ function App() {
184182 setRoute ( next ) ;
185183 setSubRoute ( nextSub ) ;
186184 // 비인증 상태에서 app route로 hash 변경 시 redirect 대상 갱신
187- const appRoutes : RouteKey [ ] = [ "home" , "marketplace" , "admin" ] ;
188- if ( sessionStorage . getItem ( SS . auth ) !== "true" && ( appRoutes as string [ ] ) . includes ( next ) ) {
185+ if ( sessionStorage . getItem ( SS . auth ) !== "true" && canStoreRedirectTarget ( next ) ) {
189186 setRedirectAfterLogin ( next ) ;
190187 }
191188 } ;
@@ -203,6 +200,14 @@ function App() {
203200 // eslint-disable-next-line react-hooks/exhaustive-deps
204201 } , [ isAuthenticated ] ) ;
205202
203+ // 초기 로그인 이후 idle 타이밍에 모듈 청크를 미리 받아 첫 진입 체감 지연을 줄인다.
204+ useEffect ( ( ) => {
205+ if ( ! isAuthenticated ) return ;
206+ return runWhenBrowserIdle ( ( ) => {
207+ void preloadModuleViews ( ) ;
208+ } ) ;
209+ } , [ isAuthenticated ] ) ;
210+
206211 // 관리자 PIN 세션 30분 만료
207212 useEffect ( ( ) => {
208213 if ( ! isPinVerified || pinVerifiedAt === null ) return ;
@@ -236,16 +241,37 @@ function App() {
236241 } , [ isAuthenticated , mustChangePassword , pendingOtpEmail , route ] ) ;
237242
238243 useEffect ( ( ) => {
239- if ( window . location . hash !== `#${ effectiveRoute } ` ) {
240- window . location . hash = effectiveRoute ;
244+ // 인증/비인증 가드로 effectiveRoute가 바뀔 때 해시를 일치시킨다.
245+ // 모듈 라우트는 subRoute를 보존해 #ledger/import 같은 주소가 사라지지 않게 처리한다.
246+ const shouldKeepSubRoute = getModuleView ( effectiveRoute ) !== null && subRoute . length > 0 ;
247+ const expectedHash = shouldKeepSubRoute ? `#${ effectiveRoute } /${ subRoute } ` : `#${ effectiveRoute } ` ;
248+ if ( window . location . hash !== expectedHash ) {
249+ window . location . hash = expectedHash ;
241250 }
242- } , [ effectiveRoute ] ) ;
251+ } , [ effectiveRoute , subRoute ] ) ;
243252
244253 const navigate = ( nextRoute : RouteKey ) => {
245254 setRoute ( nextRoute ) ;
246255 window . location . hash = nextRoute ;
247256 } ;
248257
258+ const ActiveModuleView = getModuleView ( effectiveRoute ) ;
259+
260+ const applyServerLanguagePreference = async ( ) : Promise < void > => {
261+ // 로그인 직후 서버 저장 언어를 먼저 반영해
262+ // 페이지 진입 후 번역이 뒤늦게 바뀌는 체감을 줄인다.
263+ try {
264+ const response = await apiFetch ( "/core/users/me/settings" ) ;
265+ const json = await response . json ( ) as { success : boolean ; data ?: { language ?: string } } ;
266+ const language = json . data ?. language ;
267+ if ( json . success && typeof language === "string" && language . length > 0 ) {
268+ await changeLanguage ( language ) ;
269+ }
270+ } catch {
271+ // 언어 설정 로드 실패는 무음 처리
272+ }
273+ } ;
274+
249275 // ── 로그인 성공 시 공통 처리 ─────────────────────────────────
250276 const handleLoginSuccess = ( email : string , isAdmin : boolean , isTempPassword : boolean ) => {
251277 setLoginError ( null ) ;
@@ -271,19 +297,12 @@ function App() {
271297 if ( localStorage . getItem ( LS . firstVisitShown ) !== "true" ) setIsFirstVisit ( true ) ;
272298 } catch { /* ignore */ }
273299
274- // 서버에 저장된 언어 설정 로드 (실패해도 무음 처리)
275- apiFetch ( "/core/users/me/settings" )
276- . then ( ( r ) => r . json ( ) )
277- . then ( ( json : { success : boolean ; data ?: { language : string } } ) => {
278- if ( json . success && json . data ?. language ) {
279- void changeLanguage ( json . data . language ) ;
280- }
281- } )
282- . catch ( ( ) => { /* 언어 설정 로드 실패는 무음 처리 */ } ) ;
283-
284- const target = redirectAfterLogin ?? startupRoute ;
285- setRedirectAfterLogin ( null ) ;
286- navigate ( target ) ;
300+ // 화면 이동 전에 서버 언어 설정을 우선 반영해 en → ko 깜빡임을 줄인다.
301+ void applyServerLanguagePreference ( ) . finally ( ( ) => {
302+ const target = redirectAfterLogin ?? startupRoute ;
303+ setRedirectAfterLogin ( null ) ;
304+ navigate ( target ) ;
305+ } ) ;
287306 } ;
288307
289308 // Auth handlers
@@ -520,8 +539,17 @@ function App() {
520539 />
521540 ) }
522541 { /* ── 모듈 뷰 ────────────────────────────────────── */ }
523- { effectiveRoute === "ledger" && < LedgerView subRoute = { subRoute } /> }
524- { effectiveRoute === "subscription" && < SubscriptionView /> }
542+ { ActiveModuleView && (
543+ < Suspense
544+ fallback = { (
545+ < div style = { { minHeight : 240 , display : "flex" , alignItems : "center" , justifyContent : "center" } } >
546+ < span className = "setup-spinner" style = { { width : 24 , height : 24 , borderWidth : 3 } } />
547+ </ div >
548+ ) }
549+ >
550+ < ActiveModuleView subRoute = { subRoute } />
551+ </ Suspense >
552+ ) }
525553 { isSettingsOpen && (
526554 < SettingsView
527555 theme = { theme }
0 commit comments