From 0645fc85c66abc690be90ac596014fbde7e12f4f Mon Sep 17 00:00:00 2001 From: MYONG JAEWI <78201530+Jaymyong66@users.noreply.github.com> Date: Mon, 11 May 2026 16:15:28 +0900 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20=EC=8B=9C=EB=AE=AC=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20=EB=AA=A8=EB=93=9C=20settle=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EB=B2=84=EA=B7=B8=202=EA=B1=B4=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PolicyMonitorTable의 Settle Claim/NoClaim 버튼이 mode 분기 없이 on-chain 경로만 타서 simulation 모드에서 'Wallet or master agreement not available' 토스트 후 상태가 변하지 않던 문제. mode === 'simulation' 분기를 추가하여 신규 store 액션 simSettleClaim/simSettleNoClaim 호출. - runOracle이 'On Time' 입력(delay<120) 시 contract 상태를 갱신하지 않아 'No trigger' 표시 후에도 contract.status가 'active'로 잔존하던 문제. on-chain onChainResolve와 동일하게 contract를 'noClaim'으로 전이시키도록 수정. contract 검증을 tier 분기보다 앞으로 이동. - 부수 결함: settleClaims가 claim만 'settled'로 바꾸고 contract는 'claimed' 잔존시키던 문제 수정. PolicyMonitorTable에서 Settle 버튼이 사라지지 않던 원인. Co-Authored-By: Claude Opus 4.7 --- .../tabs/tab-oracle/PolicyMonitorTable.tsx | 14 +++++- frontend/src/store/useProtocolStore.ts | 49 +++++++++++++++++-- 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/tabs/tab-oracle/PolicyMonitorTable.tsx b/frontend/src/components/tabs/tab-oracle/PolicyMonitorTable.tsx index 641a90f8..2ba9f87e 100644 --- a/frontend/src/components/tabs/tab-oracle/PolicyMonitorTable.tsx +++ b/frontend/src/components/tabs/tab-oracle/PolicyMonitorTable.tsx @@ -102,13 +102,19 @@ const STATUS_ICON: Record = { export function PolicyMonitorTable() { const { t } = useTranslation(); const { toast } = useToast(); - const { contracts, claims, masterAgreementPDA, onChainSettle } = useProtocolStore(); + const { mode, contracts, claims, masterAgreementPDA, onChainSettle, simSettleClaim, simSettleNoClaim } = useProtocolStore(); const masterPK = masterAgreementPDA ? new PublicKey(masterAgreementPDA) : null; const { account: masterAccount } = useMasterAgreementAccount(masterPK); const { settleFlightClaim, settleFlightNoClaim, buildSettleAccounts, loading: settleLoading } = useSettleFlight(); const [settleLoadingId, setSettleLoadingId] = useState(null); const handleSettleClaim = async (cid: number) => { + if (mode === 'simulation') { + const ok = simSettleClaim(cid); + if (!ok) toast(t('toast.noClaimSettle'), 'w'); + else toast(t('oracle.settleClaimBtn'), 's'); + return; + } if (!masterPK || !masterAccount) { toast(t('toast.walletNotAvailable'), 'd'); return; } setSettleLoadingId(cid); const [flightPDA] = getFlightPolicyPDA(masterPK, new BN(cid)); @@ -120,6 +126,12 @@ export function PolicyMonitorTable() { }; const handleSettleNoClaim = async (cid: number) => { + if (mode === 'simulation') { + const ok = simSettleNoClaim(cid); + if (!ok) toast(t('toast.noClaimSettle'), 'w'); + else toast(t('oracle.settleNoClaimBtn'), 's'); + return; + } if (!masterPK || !masterAccount) { toast(t('toast.walletNotAvailable'), 'd'); return; } setSettleLoadingId(cid); const [flightPDA] = getFlightPolicyPDA(masterPK, new BN(cid)); diff --git a/frontend/src/store/useProtocolStore.ts b/frontend/src/store/useProtocolStore.ts index b3d59c62..adae8f3e 100644 --- a/frontend/src/store/useProtocolStore.ts +++ b/frontend/src/store/useProtocolStore.ts @@ -283,6 +283,8 @@ interface ProtocolState { runOracle: (contractId: number, delay: number, fresh: number, cancelled: boolean) => { ok: boolean; msg: string; type: 'error' | 'ok' | 'info'; code?: string }; approveClaims: () => number; settleClaims: () => number; + simSettleClaim: (contractId: number) => boolean; + simSettleNoClaim: (contractId: number) => boolean; addLog: (msg: string, color: string, instruction: string, detail?: string, txSignature?: string) => void; applyMasterAgreementDisplayNames: (payload: MasterAgreementDisplayNames) => void; setMasterAgreementPDA: (pda: string | null) => void; @@ -529,15 +531,20 @@ export const useProtocolStore = create()(persist((set, get) => ({ if (fresh < 0 || fresh > 30) return { ok: false, msg: i18n.t('store.oracleStale', { fresh }), type: 'error' as const, code: 'E_ORACLE_STALE' }; if (delay < 0 || delay % 10 !== 0) return { ok: false, msg: i18n.t('store.oracleFormat', { delay }), type: 'error' as const, code: 'E_ORACLE_FORMAT' }; + // Contract 검증을 tier 분기보다 먼저 수행해야 'On Time' 입력에서도 contract 상태가 'noClaim'으로 갱신된다. + const contract = st.contracts.find(c => c.id === contractId); + if (!contract) return { ok: false, msg: i18n.t('store.contractNotFound', { id: contractId }), type: 'error' as const, code: 'E_CONTRACT_NOT_FOUND' }; + if (contract.status !== 'active') return { ok: false, msg: i18n.t('store.alreadyClaimed', { id: contractId }), type: 'error' as const, code: 'E_ALREADY_CLAIMED' }; + const tier = cancelled ? TIERS[3] : getTier(delay); if (!tier) { + // on-chain onChainResolve와 동일하게 contract 상태를 noClaim으로 전이시킨다. + set(prev => ({ + contracts: prev.contracts.map(c => c.id === contractId ? { ...c, status: 'noClaim' as const } : c), + })); get().addLog(i18n.t('store.oracleNoTrigger', { delay }), '#22C55E', 'check_oracle'); return { ok: true, msg: i18n.t('store.oracleNoTriggerMsg', { delay }), type: 'ok' as const }; } - - const contract = st.contracts.find(c => c.id === contractId); - if (!contract) return { ok: false, msg: i18n.t('store.contractNotFound', { id: contractId }), type: 'error' as const, code: 'E_CONTRACT_NOT_FOUND' }; - if (contract.status !== 'active') return { ok: false, msg: i18n.t('store.alreadyClaimed', { id: contractId }), type: 'error' as const, code: 'E_ALREADY_CLAIMED' }; const ceded = st.cededRatioBps / 10000; const commRate = st.reinsCommissionBps / 10000; const reinsEff = st.reinsurer.enabled ? ceded * (1 - commRate) : 0; @@ -599,8 +606,11 @@ export const useProtocolStore = create()(persist((set, get) => ({ const appr = st.claims.filter(c => c.status === 'approved'); if (!appr.length) return 0; const apprIds = new Set(appr.map(c => c.id)); + const contractIds = new Set(appr.map(c => c.contractId)); set(prev => ({ claims: prev.claims.map(c => apprIds.has(c.id) ? { ...c, status: 'settled' as const, settledAt: nowDate() } : c), + // 정산된 claim에 대응하는 contract도 'settled'로 전이시켜야 PolicyMonitorTable의 Settle 버튼이 사라진다. + contracts: prev.contracts.map(c => contractIds.has(c.id) ? { ...c, status: 'settled' as const } : c), policyStateIdx: 6, })); get().addLog( @@ -610,6 +620,37 @@ export const useProtocolStore = create()(persist((set, get) => ({ return appr.length; }, + simSettleClaim: (contractId) => { + const st = get(); + const claim = st.claims.find(c => c.contractId === contractId); + const contract = st.contracts.find(c => c.id === contractId); + if (!contract || !claim || claim.status === 'settled') return false; + set(prev => ({ + claims: prev.claims.map(c => c.contractId === contractId + ? { ...c, status: 'settled' as const, approvedAt: c.approvedAt ?? nowDate(), settledAt: nowDate() } + : c), + contracts: prev.contracts.map(c => c.id === contractId ? { ...c, status: 'settled' as const } : c), + policyStateIdx: 6, + })); + get().addLog( + i18n.t('store.settledOnchain', { id: contractId }), '#14F195', 'settle_flight_claim', + ); + return true; + }, + + simSettleNoClaim: (contractId) => { + const st = get(); + const contract = st.contracts.find(c => c.id === contractId); + if (!contract || contract.status !== 'noClaim') return false; + set(prev => ({ + contracts: prev.contracts.map(c => c.id === contractId ? { ...c, status: 'settled' as const } : c), + })); + get().addLog( + i18n.t('store.noTrigger', { id: contractId }), '#94A3B8', 'settle_flight_no_claim', + ); + return true; + }, + addLog: (msg, color, instruction, detail = '', txSignature) => { set(prev => ({ logIdCounter: prev.logIdCounter + 1, From 02c16e432dbf24be740877143c4b057686426f40 Mon Sep 17 00:00:00 2001 From: MYONG JAEWI <78201530+Jaymyong66@users.noreply.github.com> Date: Mon, 11 May 2026 16:16:00 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=EC=8B=9C=EB=AE=AC=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EB=AA=A8=EB=93=9C=20=EC=98=81=EC=86=8D?= =?UTF-8?q?=ED=99=94=20+=20Sim=20Reset=20=EB=B2=84=ED=8A=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - store partialize의 simulation 분기에 진행 상태(processStep, masterActive, contracts/claims/acc/pool/premHist/poolHist 및 participants/reinsurer의 confirmed)를 모두 영속화. 새로고침 후에도 사용자의 시뮬레이션 진행이 유지됨. - persist version 1 + migrate 함수 추가. 이전 mixed-format 사용자의 stale 진행 상태를 1회 정리하여 새 스키마와 충돌 방지. - AdminHeader 우측 SIM 토글 옆에 'Sim Reset' 버튼 노출(simulation 모드 전용). 클릭 시 confirm 다이얼로그 → resetAll() 호출 → workflow + form + contracts + claims + pool + logs 전부 기본값으로 초기화. on-chain 모드에선 자동 숨김. - i18n 신규 키: header.simReset.btn, header.simReset.confirm (ko/en 양쪽 추가). Co-Authored-By: Claude Opus 4.7 --- .../src/components/layout/AdminHeader.tsx | 43 ++++++++++++++++++- frontend/src/i18n/locales/en.ts | 2 + frontend/src/i18n/locales/ko.ts | 2 + frontend/src/store/useProtocolStore.ts | 32 ++++++++++++-- 4 files changed, 74 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/layout/AdminHeader.tsx b/frontend/src/components/layout/AdminHeader.tsx index f5b036b8..bce8a8cf 100644 --- a/frontend/src/components/layout/AdminHeader.tsx +++ b/frontend/src/components/layout/AdminHeader.tsx @@ -63,6 +63,34 @@ const ModeDot = styled.div<{ variant: 'sim' | 'chain'; active?: boolean }>` animation: ${p => p.active ? blink : 'none'} 2s infinite; `; +/* ── Sim Reset Button (sim 모드 전용) ── */ + +const SimResetBtn = styled.button` + display: flex; + align-items: center; + gap: 5px; + padding: 4px 10px; + border-radius: 18px; + border: 1px solid rgba(239, 68, 68, 0.35); + background: rgba(239, 68, 68, 0.08); + color: ${p => p.theme.colors.danger}; + font-family: ${p => p.theme.fonts.mono}; + font-size: 10px; + font-weight: 700; + cursor: pointer; + transition: all 0.15s; + white-space: nowrap; + + &:hover { + background: rgba(239, 68, 68, 0.16); + border-color: rgba(239, 68, 68, 0.55); + } + + &:active { + transform: scale(0.96); + } +`; + /* ── Selects ── */ const SelectBase = styled.select` @@ -222,8 +250,8 @@ const KpiValue = styled(Mono)` `; export function AdminHeader() { - const { mode, setMode, role, masterAgreementPDA, masterActive, contracts, totalPremium, totalClaim, poolBalance } = useProtocolStore( - useShallow(s => ({ mode: s.mode, setMode: s.setMode, role: s.role, masterAgreementPDA: s.masterAgreementPDA, masterActive: s.masterActive, contracts: s.contracts, totalPremium: s.totalPremium, totalClaim: s.totalClaim, poolBalance: s.poolBalance })), + const { mode, setMode, role, masterAgreementPDA, masterActive, contracts, totalPremium, totalClaim, poolBalance, resetAll } = useProtocolStore( + useShallow(s => ({ mode: s.mode, setMode: s.setMode, role: s.role, masterAgreementPDA: s.masterAgreementPDA, masterActive: s.masterActive, contracts: s.contracts, totalPremium: s.totalPremium, totalClaim: s.totalClaim, poolBalance: s.poolBalance, resetAll: s.resetAll })), ); const { toast } = useToast(); const { t, i18n } = useTranslation(); @@ -238,6 +266,12 @@ export function AdminHeader() { setMode(m); }; + const handleSimReset = () => { + if (!window.confirm(t('header.simReset.confirm'))) return; + resetAll(); + toast(t('toast.resetDone'), 's'); + }; + const kpis = [ { label: t('header.kpi.masterContract'), value: masterActive ? t('common.active') : t('common.inactive'), highlight: masterActive }, { label: t('header.kpi.activeContracts'), value: t('common.count', { count: contracts.length }) }, @@ -260,6 +294,11 @@ export function AdminHeader() { SIM + {mode === 'simulation' && ( + + {t('header.simReset.btn')} + + )} ); diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 4d9cf259..3065c71e 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -27,6 +27,8 @@ const en = { 'header.kpi.totalClaim': 'Total Claims', 'header.kpi.poolBalance': 'Pool Balance', 'header.kpi.poolHealth': 'Pool Health', + 'header.simReset.btn': 'Sim Reset', + 'header.simReset.confirm': 'Reset simulation state?', // === Roles (dropdown) === 'role.leader': '👑 Leader (Samsung F&M)', diff --git a/frontend/src/i18n/locales/ko.ts b/frontend/src/i18n/locales/ko.ts index 0abe3884..15970a7d 100644 --- a/frontend/src/i18n/locales/ko.ts +++ b/frontend/src/i18n/locales/ko.ts @@ -27,6 +27,8 @@ const ko = { 'header.kpi.totalClaim': '누적 보험금', 'header.kpi.poolBalance': 'Pool 잔액', 'header.kpi.poolHealth': 'Pool 건전성', + 'header.simReset.btn': 'Sim Reset', + 'header.simReset.confirm': '시뮬레이션 모드 상태를 초기화 할까요?', // === Roles (dropdown) === 'role.leader': '👑 리더사 (삼성화재)', diff --git a/frontend/src/store/useProtocolStore.ts b/frontend/src/store/useProtocolStore.ts index adae8f3e..4c1f9e3c 100644 --- a/frontend/src/store/useProtocolStore.ts +++ b/frontend/src/store/useProtocolStore.ts @@ -1131,6 +1131,32 @@ export const useProtocolStore = create()(persist((set, get) => ({ }), { name: 'riskmesh-protocol', + // v0 → v1: simulation 모드에서 진행 상태(processStep, masterActive, contracts/claims/acc/pool 등)를 + // localStorage에 저장하지 않도록 변경. 이전 버전 사용자의 stale 진행 상태를 1회 정리한다. + version: 1, + migrate: (persistedState, version) => { + if (!persistedState || typeof persistedState !== 'object') return persistedState; + const next = { ...(persistedState as Record) }; + if (version < 1) { + const STALE_WORKFLOW_KEYS = [ + 'masterActive', 'processStep', 'policyStateIdx', + 'contracts', 'claims', 'contractCount', 'claimCount', + 'acc', 'totalPremium', 'totalClaim', + 'poolBalance', 'premHist', 'poolHist', + ]; + for (const k of STALE_WORKFLOW_KEYS) delete next[k]; + if (Array.isArray(next.participants)) { + next.participants = (next.participants as Array>).map(p => ({ + ...p, + confirmed: false, + })); + } + if (next.reinsurer && typeof next.reinsurer === 'object') { + next.reinsurer = { ...(next.reinsurer as Record), confirmed: false }; + } + } + return next; + }, partialize: (state) => { const always = { mode: state.mode, @@ -1146,11 +1172,10 @@ export const useProtocolStore = create()(persist((set, get) => ({ payoutTiers: state.payoutTiers, }; if (state.mode !== 'onchain') { + // simulation 모드: 폼 입력값과 진행 상태(processStep, masterActive, contracts/claims/acc/pool 등)를 + // 모두 영속화. 사용자가 명시적으로 'Sim Reset' 버튼을 눌러야 초기화된다. return { ...always, - // In simulation mode, participants/reinsurer are user-managed — persist them. - // In on-chain mode they are authoritative from chain (syncMasterFromChain), - // so we skip them to prevent stale confirmed state leaking across agreements. participants: state.participants, reinsurer: state.reinsurer, masterActive: state.masterActive, @@ -1168,6 +1193,7 @@ export const useProtocolStore = create()(persist((set, get) => ({ poolHist: state.poolHist, }; } + // on-chain 모드: participants/reinsurer는 syncMasterFromChain 권위 값이므로 저장하지 않는다. return always; }, onRehydrateStorage: () => (state) => { From cf334d63a5cd285b1c90294354744c82becdd348 Mon Sep 17 00:00:00 2001 From: MYONG JAEWI <78201530+Jaymyong66@users.noreply.github.com> Date: Mon, 11 May 2026 20:56:41 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20=EC=8B=9C=EB=AE=AC=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EB=AA=A8=EB=93=9C=20+=20=EA=B0=80?= =?UTF-8?q?=EC=9D=B4=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/guide/GuideTour.tsx | 275 ++++++++++++------ frontend/src/components/guide/guideSteps.ts | 180 ++++++++++-- frontend/src/components/guide/useGuideTour.ts | 25 +- frontend/src/i18n/locales/en.ts | 54 ++-- frontend/src/i18n/locales/ko.ts | 54 ++-- frontend/src/lib/idl/open_parametric.json | 2 +- frontend/src/pages/Dashboard.tsx | 2 +- frontend/src/pages/PortalPage.tsx | 17 +- 8 files changed, 422 insertions(+), 187 deletions(-) diff --git a/frontend/src/components/guide/GuideTour.tsx b/frontend/src/components/guide/GuideTour.tsx index 08a83a7e..8f7e5889 100644 --- a/frontend/src/components/guide/GuideTour.tsx +++ b/frontend/src/components/guide/GuideTour.tsx @@ -4,27 +4,29 @@ import styled from '@emotion/styled'; import { keyframes } from '@emotion/react'; import { useTranslation } from 'react-i18next'; import { useGuideTour } from './useGuideTour'; -import { GUIDE_STEPS, TOTAL_STEPS } from './guideSteps'; +import { + GUIDE_ANCHORS, + GUIDE_STEPS, + TOTAL_STEPS, + type GuideStateSnapshot, + type GuideContext, +} from './guideSteps'; import { useProtocolStore } from '@/store/useProtocolStore'; import { useShallow } from 'zustand/shallow'; import type { TabId } from '@/components/layout/TabBar'; interface Props { activeTab: TabId; + setActiveTab: (tab: TabId) => void; } /* ── Animations ── */ -const fadeIn = keyframes` - from { opacity: 0; transform: translateY(6px); } - to { opacity: 1; transform: translateY(0); } +const noticeFadeIn = keyframes` + from { opacity: 0; transform: translate(-50%, calc(-50% + 6px)); } + to { opacity: 1; transform: translate(-50%, -50%); } `; -// const pulse = keyframes` -// 0%, 100% { box-shadow: 0 0 0 9999px rgba(0,0,0,0.6); } -// 50% { box-shadow: 0 0 0 9999px rgba(0,0,0,0.65); } -// `; - const completeFadeIn = keyframes` from { opacity: 0; transform: scale(0.9); } to { opacity: 1; transform: scale(1); } @@ -32,17 +34,15 @@ const completeFadeIn = keyframes` /* ── Styled ── */ -// const Spotlight = styled.div` -// position: fixed; -// z-index: 9998; -// border-radius: 8px; -// box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.6); -// pointer-events: none; -// transition: top 0.3s ease, left 0.3s ease, width 0.3s ease, height 0.3s ease; -// animation: ${pulse} 3s ease-in-out infinite; -// `; - -const TooltipWrap = styled.div` +/** + * Tooltip은 항상 mount된 채로 opacity/transform 으로만 표시 전환. + * 같은 인스턴스를 유지해야 위치(top/left) 보정이 transition으로 자연스럽게 흐른다. + * + * smoothPos: + * true → 같은 step 내 위치 보정 (scroll, post-scroll 재측정)에서 부드럽게 슬라이드 + * false → step 전환 직후엔 transition을 꺼서 이전→새 위치 사이를 날아다니지 않게 + */ +const TooltipWrap = styled.div<{ visible: boolean; smoothPos: boolean }>` position: fixed; z-index: 9999; min-width: 240px; @@ -52,8 +52,11 @@ const TooltipWrap = styled.div` border-radius: 12px; padding: 14px 16px; box-shadow: 0 4px 24px rgba(0,0,0,0.5), 0 0 20px rgba(153,69,255,0.12); - animation: ${fadeIn} 0.25s ease; - pointer-events: auto; + pointer-events: ${p => (p.visible ? 'auto' : 'none')}; + opacity: ${p => (p.visible ? 1 : 0)}; + transition: ${p => p.smoothPos + ? 'opacity 0.28s cubic-bezier(0.4, 0, 0.2, 1), top 0.32s cubic-bezier(0.4, 0, 0.2, 1), left 0.32s cubic-bezier(0.4, 0, 0.2, 1)' + : 'opacity 0.28s cubic-bezier(0.4, 0, 0.2, 1)'}; `; const StepBadge = styled.span` @@ -145,13 +148,31 @@ const Arrow = styled.div<{ position: 'top' | 'bottom' | 'left' | 'right' }>` `} `; +/* ── 화면 중앙에 띄우는 안내(앵커 미발견 시) ── */ + +const CenterNotice = styled.div` + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 9999; + background: rgba(17, 24, 39, 0.98); + border: 1px solid rgba(245, 158, 11, 0.45); + border-radius: 12px; + padding: 16px 20px; + font-size: 12px; + color: #FCD34D; + text-align: center; + animation: ${noticeFadeIn} 0.22s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: 0 4px 24px rgba(0,0,0,0.5); +`; + /* ── Completion overlay ── */ const CompleteOverlay = styled.div` position: fixed; inset: 0; z-index: 9998; - /* background: rgba(0, 0, 0, 0.7); */ display: flex; align-items: center; justify-content: center; @@ -211,119 +232,177 @@ function getTooltipStyle(rect: DOMRect, position: string): React.CSSProperties { } } -/* Steps that need a manual "Next" button (auto-advance not reliable) */ -const MANUAL_STEPS = new Set([11, 13]); +const STEP_FADE_OUT_MS = 240; // step 전환 시 의도적 hidden 시간 (탭 전환/리렌더 grace) +const ANCHOR_RETRY_INTERVAL_MS = 90; // anchor 폴링 간격 +const ANCHOR_MISSING_GRACE_MS = 2200; // anchor 못 찾으면 안내 띄우기 전 대기 +const ANCHOR_MISSING_SKIP_MS = 6000; // 그래도 못 찾으면 다음 단계로 /* ── Component ── */ -export function GuideTour({ activeTab }: Props) { +export function GuideTour({ activeTab, setActiveTab }: Props) { const { currentStep, showComplete, nextStep, skipTour, dismissComplete } = useGuideTour(); const { t } = useTranslation(); const [targetRect, setTargetRect] = useState(null); + const [missingAnchor, setMissingAnchor] = useState(false); + // step 전환 직후엔 false → 이전 위치에서 새 위치로 날아가지 않게. + // 첫 rect가 자리잡은 직후 true로 → 같은 step 내 후속 rect 변경(post-scroll, resize)은 부드럽게. + const [smoothPos, setSmoothPos] = useState(false); - const { processStep, role, participants, reinsurer, masterActive, contracts, claims } = useProtocolStore( + const { processStep, masterActive, participants, reinsurer, contracts, claims } = useProtocolStore( useShallow(s => ({ processStep: s.processStep, - role: s.role, + masterActive: s.masterActive, participants: s.participants, reinsurer: s.reinsurer, - masterActive: s.masterActive, contracts: s.contracts, claims: s.claims, })), ); - const contractsAtStart = useRef(0); - const claimsAtStart = useRef(0); + // step 진입 시점의 baseline (contracts/claims 카운트 비교용) + const baselineRef = useRef(null); + + const snapshot: GuideStateSnapshot = { + processStep, + masterActive, + reinsurerEnabled: reinsurer.enabled, + reinsurerConfirmed: reinsurer.confirmed, + firstParticipantConfirmed: participants[0]?.confirmed ?? false, + contractsCount: contracts.length, + claimsCount: claims.length, + anySettled: claims.some(c => c.status === 'settled'), + activeTab, + }; + + const ctx: GuideContext = { setActiveTab }; const step = currentStep !== null ? GUIDE_STEPS[currentStep] : null; - /* ── Find & track target element ── */ + /* ── 진입 시: 이전 tooltip 잠깐 fade-out → prepare → 일정 grace 후 anchor 폴링 시작 ── */ + + useEffect(() => { + if (!step) { setTargetRect(null); setMissingAnchor(false); setSmoothPos(false); return; } + baselineRef.current = { ...snapshot }; + // 이전 step의 tooltip을 즉시 fade-out (opacity transition만 작동, 위치는 transition off로 이동) + setTargetRect(null); + setMissingAnchor(false); + setSmoothPos(false); // 새 step 시작 → 위치 transition off (날아다님 방지) + step.prepare?.(ctx); + + let cancelled = false; + let retryTimer: ReturnType | null = null; + let postScrollTimer: ReturnType | null = null; + let smoothEnableTimer: ReturnType | null = null; + const pollForAnchor = () => { + if (cancelled) return; + const el = document.querySelector(`[data-guide="${step.anchor}"]`); + if (el) { + // block:'center'로 화면 중앙에 배치하여 사용자가 즉시 인지 가능하게. + el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + // 스크롤 시작 직후 rect — 위치 transition이 꺼져 있어 즉시 새 자리에 표시 + requestAnimationFrame(() => { + if (cancelled) return; + setTargetRect(el.getBoundingClientRect()); + // 첫 rect 적용 후 transition 다시 활성화 → post-scroll 재측정 시 부드럽게 슬라이드 + smoothEnableTimer = setTimeout(() => { + if (!cancelled) setSmoothPos(true); + }, 60); + }); + // smooth scroll이 완료된 시점에 한 번 더 측정 → 최종 위치로 tooltip 슬라이드 + postScrollTimer = setTimeout(() => { + if (cancelled) return; + setTargetRect(el.getBoundingClientRect()); + }, 480); + return; + } + retryTimer = setTimeout(pollForAnchor, ANCHOR_RETRY_INTERVAL_MS); + }; + // prepare(탭 전환) 후 DOM이 안정될 때까지 의도적 grace + const startTimer = setTimeout(pollForAnchor, STEP_FADE_OUT_MS); + return () => { + cancelled = true; + clearTimeout(startTimer); + if (retryTimer) clearTimeout(retryTimer); + if (postScrollTimer) clearTimeout(postScrollTimer); + if (smoothEnableTimer) clearTimeout(smoothEnableTimer); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentStep]); + + /* ── 같은 step 내 위치 보정 (scroll/resize) ── */ const updateRect = useCallback(() => { - if (!step) { setTargetRect(null); return; } - const el = document.querySelector(`[data-guide="${step.target}"]`); + if (!step) return; + const el = document.querySelector(`[data-guide="${step.anchor}"]`); if (el) { - el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); - requestAnimationFrame(() => { - setTargetRect(el.getBoundingClientRect()); - }); - } else { - setTargetRect(null); + // 위치만 갱신, 못 찾으면 이전 rect 유지하여 깜빡임 방지 + setTargetRect(el.getBoundingClientRect()); } }, [step]); useEffect(() => { - if (currentStep === null) { setTargetRect(null); return; } - const timer = setTimeout(updateRect, 150); + if (currentStep === null) return; window.addEventListener('resize', updateRect); window.addEventListener('scroll', updateRect, true); return () => { - clearTimeout(timer); window.removeEventListener('resize', updateRect); window.removeEventListener('scroll', updateRect, true); }; }, [currentStep, updateRect]); - /* ── Auto-skip when target not found ── */ + /* ── precondition false면 즉시 skip (optional step) ── */ useEffect(() => { - if (currentStep === null || targetRect) return; - const timer = setTimeout(() => { - const el = document.querySelector(`[data-guide="${GUIDE_STEPS[currentStep]!.target}"]`); - if (!el) nextStep(); - }, 600); - return () => clearTimeout(timer); - }, [currentStep, targetRect, nextStep]); + if (!step || !baselineRef.current) return; + if (step.optional && step.precondition && !step.precondition(snapshot)) { + const t = setTimeout(nextStep, 100); + return () => clearTimeout(t); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentStep]); - /* ── Snapshot counts when step changes ── */ + /* ── 앵커가 끝까지 안 잡히는 경우의 안내 + 자동 skip ── */ useEffect(() => { - contractsAtStart.current = contracts.length; - claimsAtStart.current = claims.length; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [currentStep]); + if (currentStep === null || targetRect) return; + const noticeTimer = setTimeout(() => setMissingAnchor(true), ANCHOR_MISSING_GRACE_MS); + const skipTimer = setTimeout(() => { + const el = document.querySelector(`[data-guide="${GUIDE_STEPS[currentStep]!.anchor}"]`); + if (!el) nextStep(); + }, ANCHOR_MISSING_SKIP_MS); + return () => { + clearTimeout(noticeTimer); + clearTimeout(skipTimer); + }; + }, [currentStep, targetRect, nextStep]); - /* ── Auto-advance based on store state ── */ + /* ── store/탭 변화로 자동 다음 진행 ── */ useEffect(() => { - if (currentStep === null) return; - const sn = GUIDE_STEPS[currentStep]!.step; - let ok = false; - switch (sn) { - case 1: ok = processStep >= 1; break; - case 2: ok = role === 'participant'; break; - case 3: ok = participants[0]?.confirmed ?? false; break; - case 4: ok = participants.length < 2 || role === 'participant'; break; - case 5: ok = participants.length < 2 || (participants[1]?.confirmed ?? false); break; - case 6: ok = !reinsurer.enabled || role === 'rein'; break; - case 7: ok = !reinsurer.enabled || reinsurer.confirmed; break; - case 8: ok = role === 'leader'; break; - case 9: ok = masterActive; break; - case 10: ok = activeTab === 'tab-feed'; break; - case 12: ok = activeTab === 'tab-oracle'; break; - case 14: ok = claims.length > claimsAtStart.current; break; - case 15: ok = claims.some(c => c.status === 'settled'); break; - case 16: ok = activeTab === 'tab-settlement'; break; - } - if (ok) { - const timer = setTimeout(nextStep, 350); - return () => clearTimeout(timer); + if (!step || !baselineRef.current) return; + if (step.isComplete(snapshot, baselineRef.current)) { + const t = setTimeout(nextStep, 350); + return () => clearTimeout(t); } - }, [currentStep, processStep, role, participants, reinsurer, masterActive, activeTab, contracts.length, claims, nextStep]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + currentStep, processStep, masterActive, + participants, reinsurer.enabled, reinsurer.confirmed, + contracts.length, claims, activeTab, nextStep, + ]); - /* ── Step 13: DOM event listener for select change ── */ + /* ── select-contract step: DOM change 리스너 ── */ useEffect(() => { - if (currentStep === null || GUIDE_STEPS[currentStep]!.step !== 13) return; - const el = document.querySelector('[data-guide="select-contract"]') as HTMLSelectElement; + if (!step || step.anchor !== GUIDE_ANCHORS.SELECT_CONTRACT) return; + const el = document.querySelector(`[data-guide="${GUIDE_ANCHORS.SELECT_CONTRACT}"]`) as HTMLSelectElement | null; if (!el) return; const handler = () => { if (el.value !== '0') setTimeout(nextStep, 350); }; el.addEventListener('change', handler); return () => el.removeEventListener('change', handler); - }, [currentStep, nextStep]); + }, [currentStep, step, nextStep]); /* ── Completion overlay ── */ @@ -340,23 +419,19 @@ export function GuideTour({ activeTab }: Props) { ); } - /* ── No active step ── */ + /* ── 활성 step 없음 ── */ - if (currentStep === null || !step || !targetRect) return null; + if (currentStep === null || !step) return null; - const showNext = MANUAL_STEPS.has(step.step); + const showNext = step.manualNext === true; + const visible = !!targetRect; + const tooltipStyle = targetRect + ? getTooltipStyle(targetRect, step.position) + : { top: -9999, left: -9999 } as React.CSSProperties; // off-screen 유지 (transition 부드럽게) return createPortal( <> - {/* */} - + {step.step} / {TOTAL_STEPS} {t(step.titleKey)} @@ -366,6 +441,14 @@ export function GuideTour({ activeTab }: Props) { {showNext && {t('guide.next')}} + {!visible && missingAnchor && ( + +
+ {t('guide.anchorMissing', { step: step.step, total: TOTAL_STEPS })} +
+ {t('guide.skip')} +
+ )} , document.body, ); diff --git a/frontend/src/components/guide/guideSteps.ts b/frontend/src/components/guide/guideSteps.ts index 1d85f69a..f9175f05 100644 --- a/frontend/src/components/guide/guideSteps.ts +++ b/frontend/src/components/guide/guideSteps.ts @@ -1,28 +1,172 @@ +/** + * Guide tour step definitions (simulation 모드 전용). + * + * - GUIDE_ANCHORS: 컴포넌트와 가이드가 공유하는 단일 source-of-truth. + * 컴포넌트는 `data-guide={GUIDE_ANCHORS.XXX}`로 등록하고, 가이드는 같은 상수를 참조한다. + * anchor를 변경할 일이 생기면 enum만 고치면 grep 한 번으로 모든 사용처가 잡힌다. + * + * - GuideStep: + * - prepare(ctx) → step 진입 시 호출, 탭/모드 등 환경 정렬 + * - isComplete(s) → store/탭 변화로 자동 다음 step 진행 + * - optional → 조건이 안 맞으면 자동 skip (예: reinsurer 미사용) + * - manualNext → 사용자가 [Next] 버튼 직접 눌러야 진행 (auto-advance가 모호한 경우) + */ + +import type { TabId } from '@/components/layout/TabBar'; + +export const GUIDE_ANCHORS = { + // tab-contract + SET_TERMS: 'set-terms-btn', + CONFIRM_P1: 'confirm-p1', + CONFIRM_REIN: 'confirm-rein', + ACTIVATE: 'activate-btn', + // tab-feed + CREATE_CONTRACT: 'create-contract-btn', + // tab-oracle + SELECT_CONTRACT: 'select-contract', + RESOLVE: 'resolve-btn', + SETTLE: 'settle-btn', + // sidebar tabs (DashboardSidebar는 data-guide={tab.id}로 등록되어 id가 곧 anchor) + TAB_FEED: 'tab-feed', + TAB_ORACLE: 'tab-oracle', + TAB_SETTLEMENT: 'tab-settlement', +} as const; + +export type GuideAnchor = typeof GUIDE_ANCHORS[keyof typeof GUIDE_ANCHORS]; + +export type GuidePhase = 'setup' | 'issue' | 'resolve' | 'settle'; + +export interface GuideContext { + setActiveTab: (tab: TabId) => void; +} + +export interface GuideStateSnapshot { + processStep: number; + masterActive: boolean; + reinsurerEnabled: boolean; + reinsurerConfirmed: boolean; + firstParticipantConfirmed: boolean; + contractsCount: number; + claimsCount: number; + anySettled: boolean; + activeTab: TabId; +} + export interface GuideStep { - step: number; - target: string; + step: number; // 1-based + phase: GuidePhase; + anchor: GuideAnchor; titleKey: string; descKey: string; position: 'top' | 'bottom' | 'left' | 'right'; + prepare?: (ctx: GuideContext) => void; + precondition?: (s: GuideStateSnapshot) => boolean; + isComplete: (s: GuideStateSnapshot, baseline: GuideStateSnapshot) => boolean; + optional?: boolean; + manualNext?: boolean; } export const GUIDE_STEPS: GuideStep[] = [ - { step: 1, target: 'set-terms-btn', titleKey: 'guide.step1.title', descKey: 'guide.step1.desc', position: 'right' }, - { step: 2, target: 'role-select', titleKey: 'guide.step2.title', descKey: 'guide.step2.desc', position: 'bottom' }, - { step: 3, target: 'confirm-p1', titleKey: 'guide.step3.title', descKey: 'guide.step3.desc', position: 'right' }, - { step: 4, target: 'role-select', titleKey: 'guide.step4.title', descKey: 'guide.step4.desc', position: 'bottom' }, - { step: 5, target: 'confirm-p2', titleKey: 'guide.step5.title', descKey: 'guide.step5.desc', position: 'right' }, - { step: 6, target: 'role-select', titleKey: 'guide.step6.title', descKey: 'guide.step6.desc', position: 'bottom' }, - { step: 7, target: 'confirm-rein', titleKey: 'guide.step7.title', descKey: 'guide.step7.desc', position: 'right' }, - { step: 8, target: 'role-select', titleKey: 'guide.step8.title', descKey: 'guide.step8.desc', position: 'bottom' }, - { step: 9, target: 'activate-btn', titleKey: 'guide.step9.title', descKey: 'guide.step9.desc', position: 'right' }, - { step: 10, target: 'tab-feed', titleKey: 'guide.step10.title', descKey: 'guide.step10.desc', position: 'bottom' }, - { step: 11, target: 'create-contract-btn', titleKey: 'guide.step11.title', descKey: 'guide.step11.desc', position: 'right' }, - { step: 12, target: 'tab-oracle', titleKey: 'guide.step12.title', descKey: 'guide.step12.desc', position: 'bottom' }, - { step: 13, target: 'select-contract', titleKey: 'guide.step13.title', descKey: 'guide.step13.desc', position: 'right' }, - { step: 14, target: 'resolve-btn', titleKey: 'guide.step14.title', descKey: 'guide.step14.desc', position: 'right' }, - { step: 15, target: 'settle-btn', titleKey: 'guide.step15.title', descKey: 'guide.step15.desc', position: 'right' }, - { step: 16, target: 'tab-settlement', titleKey: 'guide.step16.title', descKey: 'guide.step16.desc', position: 'bottom' }, + // ── Phase 1: Setup ───────────────────────────────────────── + { + step: 1, + phase: 'setup', + anchor: GUIDE_ANCHORS.SET_TERMS, + titleKey: 'guide.step1.title', + descKey: 'guide.step1.desc', + position: 'right', + prepare: ctx => ctx.setActiveTab('tab-contract'), + isComplete: s => s.processStep >= 1, + }, + { + step: 2, + phase: 'setup', + anchor: GUIDE_ANCHORS.CONFIRM_P1, + titleKey: 'guide.step2.title', + descKey: 'guide.step2.desc', + position: 'right', + prepare: ctx => ctx.setActiveTab('tab-contract'), + isComplete: s => s.firstParticipantConfirmed, + }, + { + step: 3, + phase: 'setup', + anchor: GUIDE_ANCHORS.CONFIRM_REIN, + titleKey: 'guide.step3.title', + descKey: 'guide.step3.desc', + position: 'right', + prepare: ctx => ctx.setActiveTab('tab-contract'), + precondition: s => s.reinsurerEnabled, + isComplete: s => !s.reinsurerEnabled || s.reinsurerConfirmed, + optional: true, + }, + { + step: 4, + phase: 'setup', + anchor: GUIDE_ANCHORS.ACTIVATE, + titleKey: 'guide.step4.title', + descKey: 'guide.step4.desc', + position: 'right', + prepare: ctx => ctx.setActiveTab('tab-contract'), + isComplete: s => s.masterActive, + }, + + // ── Phase 2: Issue ───────────────────────────────────────── + { + step: 5, + phase: 'issue', + anchor: GUIDE_ANCHORS.CREATE_CONTRACT, + titleKey: 'guide.step5.title', + descKey: 'guide.step5.desc', + position: 'right', + prepare: ctx => ctx.setActiveTab('tab-feed'), + isComplete: (s, baseline) => s.contractsCount > baseline.contractsCount, + }, + + // ── Phase 3: Resolve ─────────────────────────────────────── + { + step: 6, + phase: 'resolve', + anchor: GUIDE_ANCHORS.SELECT_CONTRACT, + titleKey: 'guide.step6.title', + descKey: 'guide.step6.desc', + position: 'right', + prepare: ctx => ctx.setActiveTab('tab-oracle'), + // 사용자가 select에서 값을 변경할 때 GuideTour의 별도 DOM 리스너가 nextStep 호출 + isComplete: () => false, + manualNext: false, + }, + { + step: 7, + phase: 'resolve', + anchor: GUIDE_ANCHORS.RESOLVE, + titleKey: 'guide.step7.title', + descKey: 'guide.step7.desc', + position: 'right', + prepare: ctx => ctx.setActiveTab('tab-oracle'), + isComplete: (s, baseline) => s.claimsCount > baseline.claimsCount, + }, + + // ── Phase 4: Settle ──────────────────────────────────────── + { + step: 8, + phase: 'settle', + anchor: GUIDE_ANCHORS.SETTLE, + titleKey: 'guide.step8.title', + descKey: 'guide.step8.desc', + position: 'right', + prepare: ctx => ctx.setActiveTab('tab-oracle'), + isComplete: s => s.anySettled, + }, + { + step: 9, + phase: 'settle', + anchor: GUIDE_ANCHORS.TAB_SETTLEMENT, + titleKey: 'guide.step9.title', + descKey: 'guide.step9.desc', + position: 'right', + isComplete: s => s.activeTab === 'tab-settlement', + }, ]; export const TOTAL_STEPS = GUIDE_STEPS.length; diff --git a/frontend/src/components/guide/useGuideTour.ts b/frontend/src/components/guide/useGuideTour.ts index 9d49f315..244ae1f3 100644 --- a/frontend/src/components/guide/useGuideTour.ts +++ b/frontend/src/components/guide/useGuideTour.ts @@ -1,4 +1,6 @@ import { create } from 'zustand'; +import i18n from '@/i18n'; +import { useProtocolStore } from '@/store/useProtocolStore'; import { TOTAL_STEPS } from './guideSteps'; const STORAGE_KEY = 'riskmesh-guide-completed'; @@ -6,6 +8,10 @@ const STORAGE_KEY = 'riskmesh-guide-completed'; interface GuideTourStore { currentStep: number | null; showComplete: boolean; + /** + * Guide 시작 — 항상 simulation 모드 + 초기 상태에서 step 1부터. + * 진행 중인 simulation이 있으면 confirm으로 사용자 의사를 한 번 확인한다. + */ startTour: () => void; nextStep: () => void; skipTour: () => void; @@ -16,7 +22,24 @@ export const useGuideTour = create((set, get) => ({ currentStep: null, showComplete: false, - startTour: () => set({ currentStep: 0, showComplete: false }), + startTour: () => { + const protocol = useProtocolStore.getState(); + if (protocol.mode !== 'simulation') { + protocol.setMode('simulation'); + } + const dirty = protocol.processStep > 0 || protocol.masterActive + || protocol.contracts.length > 0 || protocol.claims.length > 0; + if (dirty) { + const ok = window.confirm(i18n.t('guide.startConfirmReset')); + if (!ok) return; + protocol.resetAll(); + } + // role을 leader로 강제 (setTerms/activate가 leader 권한 요구) + if (protocol.role !== 'leader' && protocol.role !== 'operator') { + protocol.setRole('leader'); + } + set({ currentStep: 0, showComplete: false }); + }, nextStep: () => { const { currentStep } = get(); diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 3065c71e..63629789 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -507,44 +507,32 @@ const en = { 'landing.visionSub': 'Standardized settlement logic. Modular risk pool architecture. Capital markets compatibility.', 'landing.visionCta': 'Explore the Demo', - // === Guide Tour === + // === Guide Tour (simulation-only, 9 steps) === 'guide.startBtn': 'Guide', 'guide.skip': 'Skip', 'guide.next': 'Next >', 'guide.complete': 'Tour complete!\nFeel free to explore on your own.', 'guide.closeBtn': 'OK', - 'guide.step1.title': 'Step 1: Set Terms', - 'guide.step1.desc': 'Set the master agreement terms. Click this button.', - 'guide.step2.title': 'Step 2: Switch to Participant', - 'guide.step2.desc': 'Change your role to "Participant" in the header.', - 'guide.step3.title': 'Step 3: Participant Confirm', - 'guide.step3.desc': 'Click the confirm button for the Participant.', - 'guide.step4.title': 'Step 4: Additional Participant (optional)', - 'guide.step4.desc': 'If there are additional participants, switch roles.', - 'guide.step5.title': 'Step 5: Additional Participant Confirm (optional)', - 'guide.step5.desc': 'Confirm additional participants if present.', - 'guide.step6.title': 'Step 6: Switch to Reinsurer', - 'guide.step6.desc': 'Change your role to "Reinsurer".', - 'guide.step7.title': 'Step 7: Reinsurer Confirm', - 'guide.step7.desc': 'Click the confirm button for Reinsurer.', - 'guide.step8.title': 'Step 8: Switch to Leader', - 'guide.step8.desc': 'Change your role back to "Leader".', - 'guide.step9.title': 'Step 9: Activate Master Agreement', - 'guide.step9.desc': 'Click the master agreement activation button.', - 'guide.step10.title': 'Step 10: Live Policy Feed', - 'guide.step10.desc': 'Click the "Live Policy Feed" tab.', - 'guide.step11.title': 'Step 11: Create Policy', - 'guide.step11.desc': 'Create a policy by clicking this button.', - 'guide.step12.title': 'Step 12: Oracle & Claims', - 'guide.step12.desc': 'Navigate to the "Oracle & Claims" tab.', - 'guide.step13.title': 'Step 13: Select Policy', - 'guide.step13.desc': 'Select a target policy from the dropdown.', - 'guide.step14.title': 'Step 14: Process Flight Delay', - 'guide.step14.desc': 'Click the flight delay processing button.', - 'guide.step15.title': 'Step 15: Settle Claims', - 'guide.step15.desc': 'Click the claim settlement button.', - 'guide.step16.title': 'Step 16: Settlement Status', - 'guide.step16.desc': 'View the results in the "Settlement" tab.', + 'guide.startConfirmReset': 'A simulation is in progress. Reset and start the guide?', + 'guide.anchorMissing': 'Locating the highlight for step {{step}}/{{total}}…', + 'guide.step1.title': 'Step 1: Set Master Terms', + 'guide.step1.desc': 'Fill in the terms and click "Set Terms".', + 'guide.step2.title': 'Step 2: Confirm Participant', + 'guide.step2.desc': 'Click the "Confirm" button on the participant row.', + 'guide.step3.title': 'Step 3: Confirm Reinsurer', + 'guide.step3.desc': 'Click the "Confirm" button on the reinsurer row (if enabled).', + 'guide.step4.title': 'Step 4: Activate Master', + 'guide.step4.desc': 'Click "Activate" to put the master agreement into effect.', + 'guide.step5.title': 'Step 5: Issue a Policy', + 'guide.step5.desc': 'In the Live Feed tab, click "Create Policy" to issue a flight policy.', + 'guide.step6.title': 'Step 6: Select a Policy', + 'guide.step6.desc': 'In the Oracle tab, select a target policy to resolve.', + 'guide.step7.title': 'Step 7: Resolve Flight', + 'guide.step7.desc': 'Click "Resolve" to run the oracle. "On Time" is also fine.', + 'guide.step8.title': 'Step 8: Settle Claim', + 'guide.step8.desc': 'Click "Settle" to finalize the claim.', + 'guide.step9.title': 'Step 9: View Settlement', + 'guide.step9.desc': 'Open the "Settlement" tab to review the result.', // === Portal === 'portal.title': 'Participant Portal', diff --git a/frontend/src/i18n/locales/ko.ts b/frontend/src/i18n/locales/ko.ts index 15970a7d..59060188 100644 --- a/frontend/src/i18n/locales/ko.ts +++ b/frontend/src/i18n/locales/ko.ts @@ -507,44 +507,32 @@ const ko = { 'landing.visionSub': '표준화된 정산 로직. 모듈형 리스크 풀 아키텍처. 자본 시장과의 호환성.', 'landing.visionCta': '데모 살펴보기', - // === Guide Tour === + // === Guide Tour (simulation 전용, 9 step) === 'guide.startBtn': '가이드', 'guide.skip': '건너뛰기', 'guide.next': '다음 >', 'guide.complete': '가이드 투어를 완료했습니다!\n이제 자유롭게 사용해보세요.', 'guide.closeBtn': '확인', - 'guide.step1.title': 'Step 1: 약관 세팅', - 'guide.step1.desc': '마스터계약의 약관을 설정합니다. 이 버튼을 클릭하세요.', - 'guide.step2.title': 'Step 2: 참여사로 전환', - 'guide.step2.desc': '상단에서 역할을 "참여사"로 변경하세요.', - 'guide.step3.title': 'Step 3: 참여사 컨펌', - 'guide.step3.desc': '참여사의 컨펌 버튼을 클릭하세요.', - 'guide.step4.title': 'Step 4: 추가 참여사 (선택)', - 'guide.step4.desc': '추가 참여사가 있으면 역할을 전환하세요.', - 'guide.step5.title': 'Step 5: 추가 참여사 컨펌 (선택)', - 'guide.step5.desc': '추가 참여사가 있으면 컨펌하세요.', - 'guide.step6.title': 'Step 6: 재보험사로 전환', - 'guide.step6.desc': '역할을 "재보험사"로 변경하세요.', - 'guide.step7.title': 'Step 7: 재보험사 컨펌', - 'guide.step7.desc': '재보험사의 컨펌 버튼을 클릭하세요.', - 'guide.step8.title': 'Step 8: 리더사로 전환', - 'guide.step8.desc': '역할을 "리더사"로 다시 변경하세요.', - 'guide.step9.title': 'Step 9: 마스터계약 활성화', - 'guide.step9.desc': '마스터 계약 활성화 버튼을 클릭하세요.', - 'guide.step10.title': 'Step 10: 실시간 보험 피드', - 'guide.step10.desc': '"실시간 보험 피드" 탭을 클릭하세요.', - 'guide.step11.title': 'Step 11: 보험 가입', - 'guide.step11.desc': '보험 가입 버튼을 클릭합니다.', - 'guide.step12.title': 'Step 12: 오라클 & 클레임', - 'guide.step12.desc': '"오라클 & 클레임" 탭으로 이동하세요.', - 'guide.step13.title': 'Step 13: 대상 보험 선택', - 'guide.step13.desc': '대상 보험을 선택하세요.', - 'guide.step14.title': 'Step 14: 항공편 지연 처리', - 'guide.step14.desc': '항공편 지연 처리 버튼을 클릭하세요.', - 'guide.step15.title': 'Step 15: 보험금 정산', - 'guide.step15.desc': '보험금 정산 버튼을 클릭하세요.', - 'guide.step16.title': 'Step 16: 정산 현황', - 'guide.step16.desc': '"정산 현황" 탭에서 결과를 확인하세요.', + 'guide.startConfirmReset': '진행 중인 시뮬레이션이 있습니다. 초기화하고 가이드를 시작할까요?', + 'guide.anchorMissing': '{{step}}/{{total}} 단계 안내 위치를 찾는 중입니다… 화면을 잠시 확인해주세요.', + 'guide.step1.title': 'Step 1: 마스터계약 약관 설정', + 'guide.step1.desc': '약관을 입력하고 "약관 세팅" 버튼을 누르세요.', + 'guide.step2.title': 'Step 2: 참여사 컨펌', + 'guide.step2.desc': '참여사 행의 "컨펌" 버튼을 누르세요.', + 'guide.step3.title': 'Step 3: 재보험사 컨펌', + 'guide.step3.desc': '재보험사 행의 "컨펌" 버튼을 누르세요. (재보험사를 사용 중일 때만)', + 'guide.step4.title': 'Step 4: 마스터계약 활성화', + 'guide.step4.desc': '"활성화" 버튼을 눌러 마스터계약을 발효시키세요.', + 'guide.step5.title': 'Step 5: 보험 가입(증권 발행)', + 'guide.step5.desc': '실시간 피드 탭에서 "보험 가입" 버튼을 눌러 증권을 만드세요.', + 'guide.step6.title': 'Step 6: 대상 보험 선택', + 'guide.step6.desc': '오라클 탭에서 정산 대상 보험을 선택하세요.', + 'guide.step7.title': 'Step 7: 항공편 지연 해소', + 'guide.step7.desc': '"지연 처리" 버튼을 눌러 오라클을 실행하세요. (On Time도 가능)', + 'guide.step8.title': 'Step 8: 클레임 정산', + 'guide.step8.desc': '"보험금 정산" 버튼을 눌러 클레임을 마무리하세요.', + 'guide.step9.title': 'Step 9: 정산 결과 확인', + 'guide.step9.desc': '"정산 현황" 탭에서 결과를 확인하세요.', // === Portal === 'portal.title': '참여사 포탈', diff --git a/frontend/src/lib/idl/open_parametric.json b/frontend/src/lib/idl/open_parametric.json index c481b906..13a86c30 100644 --- a/frontend/src/lib/idl/open_parametric.json +++ b/frontend/src/lib/idl/open_parametric.json @@ -79,7 +79,7 @@ }, { "name": "queue", - "address": "A43DyUGA7s8eXPxqEjJY6EBu1KKbNgfxF8h17VAHn13w" + "address": "EYiAmGSdsQTuCw413V5BzaruWuCCSDgTPtBGvLkXHbe7" }, { "name": "slot_hashes", diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 07255da0..df784e8b 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -48,7 +48,7 @@ export function Dashboard() { - + ); } diff --git a/frontend/src/pages/PortalPage.tsx b/frontend/src/pages/PortalPage.tsx index 4b58d8a6..6543eed7 100644 --- a/frontend/src/pages/PortalPage.tsx +++ b/frontend/src/pages/PortalPage.tsx @@ -105,6 +105,13 @@ const TRACK_B_STATUS_COLORS: Record = { [PolicyState.Expired]: '#475569', }; +function formatPolicyEndDate(ts?: number): string { + if (!ts) return ''; + return new Date(ts * 1000).toLocaleDateString('ko-KR', { + year: '2-digit', month: '2-digit', day: '2-digit', + }); +} + function PolicyListItem({ policy, onClick }: { policy: MyPolicySummary; onClick: () => void }) { const { t } = useTranslation(); const isTrackB = policy.track === 'B'; @@ -114,6 +121,10 @@ function PolicyListItem({ policy, onClick }: { policy: MyPolicySummary; onClick: const statusLabel = isTrackB ? (POLICY_STATE_LABELS[policy.status] || 'Unknown') : (STATUS_LABELS[policy.status] || 'Unknown'); + const addressLabel = `${policy.pda.slice(0, 12)}...${policy.pda.slice(-8)}`; + const subLabel = isTrackB && policy.flightNo + ? `${policy.flightNo} · ${policy.route}` + : formatPolicyEndDate(policy.coverageEndTs); return ( @@ -125,12 +136,10 @@ function PolicyListItem({ policy, onClick }: { policy: MyPolicySummary; onClick: ))}
- {isTrackB ? `Policy #${policy.masterId}` : `Master #${policy.masterId}`} + {addressLabel} - {isTrackB && policy.flightNo - ? `${policy.flightNo} · ${policy.route}` - : `${policy.pda.slice(0, 12)}...${policy.pda.slice(-8)}`} + {subLabel}