From cbe03c630df4f64f6019272fabafdbd1be5aa6ac Mon Sep 17 00:00:00 2001 From: parkcheolhong Date: Wed, 22 Apr 2026 14:09:58 +0900 Subject: [PATCH 1/7] feat: finalize marketplace popup and liveview ui --- frontend/frontend/app/marketplace/page.tsx | 3 + .../marketplace/feature-launcher-grid.tsx | 18 +- .../feature-orchestrator-popup.tsx | 756 ++++-------------- .../feature-popup-input-section.tsx | 86 ++ .../feature-popup-live-view-section.tsx | 140 ++++ .../feature-popup-output-section.tsx | 570 +++++++++++++ .../feature-popup-state-section.tsx | 65 ++ .../hooks/use-feature-orchestrator.ts | 333 +++++++- .../lib/marketplace-popup-telemetry.ts | 119 +++ 9 files changed, 1486 insertions(+), 604 deletions(-) create mode 100644 frontend/frontend/components/marketplace/popup-sections/feature-popup-input-section.tsx create mode 100644 frontend/frontend/components/marketplace/popup-sections/feature-popup-live-view-section.tsx create mode 100644 frontend/frontend/components/marketplace/popup-sections/feature-popup-output-section.tsx create mode 100644 frontend/frontend/components/marketplace/popup-sections/feature-popup-state-section.tsx create mode 100644 frontend/frontend/lib/marketplace-popup-telemetry.ts diff --git a/frontend/frontend/app/marketplace/page.tsx b/frontend/frontend/app/marketplace/page.tsx index 32219d7..e840009 100644 --- a/frontend/frontend/app/marketplace/page.tsx +++ b/frontend/frontend/app/marketplace/page.tsx @@ -1,3 +1,5 @@ +"use client"; + import * as React from 'react'; import Link from 'next/link'; import FeatureLauncherGrid from '@/components/marketplace/feature-launcher-grid'; @@ -625,6 +627,7 @@ export default function MarketplacePage() { {catalog.map((feature) => { const enabled = feature.status === 'enabled'; - const isSpreadsheetFeature = feature.feature_id === 'ai-sheet'; - const featureSummary = isSpreadsheetFeature - ? '시트 schema preview 를 먼저 확인하고, final phase 에서 xlsx/csv workbook 패키지를 즉시 다운로드합니다.' - : feature.summary; + const meta = getFeatureExperienceMeta(feature.feature_id); return (
@@ -36,20 +33,21 @@ export default function FeatureLauncherGrid({ catalog, catalogLoading, catalogEr

{feature.popup_mode}

- {enabled ? (isSpreadsheetFeature ? '엑셀 즉시 생성' : 'Popup 실행') : '준비 중'} + {enabled ? meta.launcherBadge : '준비 중'} -

{featureSummary}

+

{meta.launcherSummary}

- {isSpreadsheetFeature && schema preview} - {isSpreadsheetFeature && xlsx/csv 다운로드} + {meta.launcherHighlights.map((highlight) => ( + {highlight} + ))} {feature.supports_photo_upload && 사진 업로드} {feature.supports_final_phase && final phase} {feature.feature_id === activeFeatureId && 선택됨}
diff --git a/frontend/frontend/components/marketplace/feature-orchestrator-popup.tsx b/frontend/frontend/components/marketplace/feature-orchestrator-popup.tsx index 0b19db7..8cfdbe0 100644 --- a/frontend/frontend/components/marketplace/feature-orchestrator-popup.tsx +++ b/frontend/frontend/components/marketplace/feature-orchestrator-popup.tsx @@ -1,344 +1,18 @@ 'use client'; import * as React from 'react'; -import type { FeatureArtifact, FeatureLiveViewArtifact, FeaturePopupState, FeatureProgressSnapshot, FeatureStreamConnection, SpreadsheetDownloadLink, SpreadsheetRunSummary } from '@/hooks/use-feature-orchestrator'; - -type SpreadsheetFeatureArtifact = FeatureArtifact & { - prompt_summary?: string; - keywords?: string[]; - sheet_schema?: { - sheet_name?: string; - row_goal?: number; - columns?: Array<{ - name: string; - type: string; - }>; - }; - workbook?: { - sheet_name?: string; - column_count?: number; - row_count?: number; - sample_rows?: Array>; - }; - delivery_assets?: Array<{ - format?: string; - path?: string; - path_hint?: string; - size_bytes?: number; - exists?: boolean; - generated_at?: string; - }>; - generated_at?: string; -}; - -const POPUP_STATE_FLOW: FeaturePopupState[] = ['accepted', 'preview_running', 'preview_ready', 'final_running', 'quality_review', 'completed']; - -const STATE_LABELS: Record = { - idle: '대기', - accepted: '수락됨', - preview_running: 'preview 실행 중', - preview_ready: 'preview 준비 완료', - final_running: 'final 실행 중', - quality_review: '품질 검토', - completed: '완료', - completed_preview_only: 'preview 전용 완료', - failed: '실패', -}; - -const CONNECTION_LABELS: Record = { - idle: '대기', - connecting: '스트림 연결 중', - streaming: '실시간 수신 중', - completed: '라이브뷰 완료', - failed: '라이브뷰 실패', -}; - -function stateLabel(state: FeaturePopupState) { - return STATE_LABELS[state]; -} - -function connectionLabel(state: FeatureStreamConnection) { - return CONNECTION_LABELS[state]; -} - -function formatElapsed(seconds: number) { - const safeSeconds = Math.max(0, Math.floor(seconds || 0)); - const mins = Math.floor(safeSeconds / 60); - const secs = safeSeconds % 60; - return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`; -} - -function progressWidthClass(percent: number) { - const normalized = Math.max(0, Math.min(100, Math.round((percent || 0) / 5) * 5)); - return { - 0: 'w-0', - 5: 'w-[5%]', - 10: 'w-[10%]', - 15: 'w-[15%]', - 20: 'w-[20%]', - 25: 'w-[25%]', - 30: 'w-[30%]', - 35: 'w-[35%]', - 40: 'w-[40%]', - 45: 'w-[45%]', - 50: 'w-[50%]', - 55: 'w-[55%]', - 60: 'w-[60%]', - 65: 'w-[65%]', - 70: 'w-[70%]', - 75: 'w-[75%]', - 80: 'w-[80%]', - 85: 'w-[85%]', - 90: 'w-[90%]', - 95: 'w-[95%]', - 100: 'w-full', - }[normalized as 0 | 5 | 10 | 15 | 20 | 25 | 30 | 35 | 40 | 45 | 50 | 55 | 60 | 65 | 70 | 75 | 80 | 85 | 90 | 95 | 100]; -} - -function formatBytes(size: number) { - if (!Number.isFinite(size) || size <= 0) { - return '0 B'; - } - if (size >= 1024 * 1024) { - return `${(size / (1024 * 1024)).toFixed(1)} MB`; - } - if (size >= 1024) { - return `${(size / 1024).toFixed(1)} KB`; - } - return `${Math.round(size)} B`; -} - -function normalizeSpreadsheetType(value?: string | null) { - const normalized = String(value || '').trim().toLowerCase(); - if (!normalized) { - return 'text'; - } - if (['number', 'numeric', 'decimal', 'integer', 'int', 'float', 'double', 'currency', 'amount', 'price'].some((token) => normalized.includes(token))) { - return 'number'; - } - if (['date', 'datetime', 'timestamp', 'time'].some((token) => normalized.includes(token))) { - return 'date'; - } - return 'text'; -} - -function inferCellType(value: unknown, declaredType?: string | null) { - const normalizedDeclaredType = normalizeSpreadsheetType(declaredType); - if (normalizedDeclaredType !== 'text') { - return normalizedDeclaredType; - } - - if (typeof value === 'number' && Number.isFinite(value)) { - return 'number'; - } - - if (value instanceof Date && !Number.isNaN(value.getTime())) { - return 'date'; - } - - if (typeof value === 'string') { - const trimmed = value.trim(); - if (!trimmed) { - return 'text'; - } - const numericValue = Number(trimmed.replace(/,/g, '')); - if (!Number.isNaN(numericValue) && /^[-+]?\d[\d,]*(\.\d+)?$/.test(trimmed)) { - return 'number'; - } - const parsedDate = Date.parse(trimmed); - if (!Number.isNaN(parsedDate) && /[-/:.년월일T]/.test(trimmed)) { - return 'date'; - } - } - - return 'text'; -} - -function workbookCellClassName(value: unknown, declaredType?: string | null) { - const cellType = inferCellType(value, declaredType); - if (cellType === 'number') { - return 'border-b border-[#1e2a3c] px-3 py-2 text-right font-medium tabular-nums text-[#ffd37a]'; - } - if (cellType === 'date') { - return 'border-b border-[#1e2a3c] px-3 py-2 text-center font-medium text-[#8ec5ff]'; - } - return 'border-b border-[#1e2a3c] px-3 py-2 text-left text-[#e6edf3]'; -} - -function renderSchemaTable(columns: Array<{ name: string; type: string }>, rowGoal: number) { - return ( -
- - - - - - - - - - - {columns.map((column, index) => ( - - - - - - - ))} - -
#컬럼명타입목표 행
{index + 1}{column.name}{column.type}{rowGoal}
-
- ); -} - -function renderWorkbookRowsTable(rows: Array>, columns?: Array<{ name: string; type: string }>) { - if (!rows.length) { - return null; - } - const headers = Object.keys(rows[0] || {}); - const declaredTypes = new Map((columns || []).map((column) => [column.name, column.type])); - return ( -
- - - - - {headers.map((header) => ( - - ))} - - - - {rows.map((row, rowIndex) => ( - - - {headers.map((header) => ( - - ))} - - ))} - -
{header}
{rowIndex + 1}{String(row[header] ?? '')}
-
- ); -} - -function renderSpreadsheetArtifact(artifact: FeatureArtifact) { - const spreadsheetArtifact = artifact as SpreadsheetFeatureArtifact; - const schema = spreadsheetArtifact.sheet_schema; - const workbook = spreadsheetArtifact.workbook; - const deliveryAssets = spreadsheetArtifact.delivery_assets || []; - - return ( -
- {spreadsheetArtifact.prompt_summary && ( -
-

Prompt Summary

-

{spreadsheetArtifact.prompt_summary}

-
- )} - {!!spreadsheetArtifact.keywords?.length && ( -
- {spreadsheetArtifact.keywords.map((keyword: string) => ( - #{keyword} - ))} -
- )} - {schema && ( -
-
-

Sheet Schema Preview

- {schema.sheet_name || 'GeneratedSheet'} -
- {renderSchemaTable(schema.columns || [], Number(schema.row_goal || 0))} -
- )} - {workbook && ( -
-

Workbook Package

-
-
-

Sheet

-

{workbook.sheet_name || 'GeneratedSheet'}

-
-
-

Columns

-

{workbook.column_count || 0}

-
-
-

Rows

-

{workbook.row_count || 0}

-
-
- {renderWorkbookRowsTable(workbook.sample_rows || [], schema?.columns || [])} -
- )} - {!!deliveryAssets.length && ( -
-

Delivery Assets

-
- {deliveryAssets.map((asset: NonNullable[number]) => ( -
-
-

{String(asset.format || '').toUpperCase() || 'FILE'}

-

{asset.path_hint || asset.path || 'path unavailable'}

-
-
-

{asset.exists ? 'ready' : 'missing'}

-

{formatBytes(Number(asset.size_bytes || 0))}

-
-
- ))} -
-
- )} -
- ); -} - -function renderArtifactCard(title: string, artifact: FeatureArtifact | null) { - if (!artifact) { - return
{title} 결과가 아직 없습니다.
; - } - - const spreadsheetArtifact = artifact as SpreadsheetFeatureArtifact; - const isSpreadsheetArtifact = !!spreadsheetArtifact.sheet_schema || !!spreadsheetArtifact.workbook || !!spreadsheetArtifact.delivery_assets?.length; - - return ( -
-
-

{title}

- {artifact.state || artifact.phase || 'artifact'} -
- {artifact.image_data_url ? ( - {title} - ) : isSpreadsheetArtifact ? ( - renderSpreadsheetArtifact(artifact) - ) : ( -
이미지 미리보기가 아직 없습니다.
- )} - {!!artifact.composition?.warnings?.length && ( -
- {artifact.composition.warnings.map((warning: string) => ( -

{warning}

- ))} -
- )} - {!!artifact.notes?.length && ( -
- {artifact.notes.map((note: string) => ( -

{note}

- ))} -
- )} -
- ); -} +import type { FeatureArtifact, FeatureExperienceMeta, FeatureLiveViewArtifact, FeaturePopupState, FeatureProgressSnapshot, FeatureStreamConnection, SpreadsheetDownloadLink, SpreadsheetRunSummary } from '@/hooks/use-feature-orchestrator'; +import FeaturePopupInputSection from '@/components/marketplace/popup-sections/feature-popup-input-section'; +import { connectionLabel, formatElapsed, POPUP_STATE_FLOW, progressWidthClass, stateLabel } from '@/components/marketplace/popup-sections/feature-popup-helpers'; +import FeaturePopupLiveViewSection from '@/components/marketplace/popup-sections/feature-popup-live-view-section'; +import FeaturePopupOutputSection from '@/components/marketplace/popup-sections/feature-popup-output-section'; +import FeaturePopupStateSection from '@/components/marketplace/popup-sections/feature-popup-state-section'; +import type { PopupEventLogItem, PopupQualityReview } from '@/components/marketplace/popup-sections/feature-popup-types'; interface FeatureOrchestratorPopupProps { isOpen: boolean; activeFeatureId: string; + featureMeta: FeatureExperienceMeta; popupMode?: string; title: string; featureSummary: string; @@ -357,17 +31,13 @@ interface FeatureOrchestratorPopupProps { applyPhotoFile: (file: File | null) => void; previewArtifact: FeatureArtifact | null; finalArtifact: FeatureArtifact | null; - qualityReview: { - passed?: boolean; - score?: number; - issues?: string[]; - } | null; + qualityReview: PopupQualityReview; submitLoading: boolean; submitFeature: () => void; closePopup: () => void; errorText: string; runId: string; - eventLog: Array<{ state: FeaturePopupState; at: string }>; + eventLog: PopupEventLogItem[]; streamConnection: FeatureStreamConnection; stageRunStatus?: string; latestEventAt: string; @@ -384,6 +54,12 @@ export default function FeatureOrchestratorPopup(props: FeatureOrchestratorPopup return null; } + const dialogId = React.useId(); + const descriptionId = React.useId(); + const dialogRef = React.useRef(null); + const closeButtonRef = React.useRef(null); + const contentStartRef = React.useRef(null); + const meta = props.featureMeta; const isSpreadsheetBuilder = props.activeFeatureId === 'ai-sheet' || props.popupMode === 'spreadsheet-builder'; const qualityGateScoreLabel = props.qualityReview ? `${Math.round(Number(props.qualityReview.score || 0) * 100)}점` : '대기'; const latestSpreadsheetDownloadFormat = React.useMemo(() => { @@ -399,261 +75,169 @@ export default function FeatureOrchestratorPopup(props: FeatureOrchestratorPopup .filter((item) => !Number.isNaN(item.completedAt)) .sort((left, right) => right.completedAt - left.completedAt)[0]?.format || ''; }, [props.spreadsheetDownloadLinks]); - const templateOptions = isSpreadsheetBuilder - ? [ - { value: 'sheet-schema-template', label: '기본 시트 스키마 템플릿' }, - { value: 'sales-pipeline-template', label: '영업 파이프라인 템플릿' }, - { value: 'inventory-control-template', label: '재고 관리 템플릿' }, - ] - : [ - { value: 'ad-photo-template', label: '광고 사진 템플릿' }, - { value: 'portrait-promo-template', label: '인물 프로모션 템플릿' }, - { value: 'product-banner-template', label: '제품 배너 템플릿' }, - ]; + const liveViewTitle = props.liveViewArtifact?.title || meta.liveViewTitle; + const liveViewDescription = props.liveViewArtifact?.caption || meta.liveViewDescription; + + React.useEffect(() => { + const previousActiveElement = document.activeElement instanceof HTMLElement ? document.activeElement : null; + const timer = window.setTimeout(() => { + closeButtonRef.current?.focus(); + }, 0); + + return () => { + window.clearTimeout(timer); + previousActiveElement?.focus(); + }; + }, []); + + React.useEffect(() => { + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + + return () => { + document.body.style.overflow = previousOverflow; + }; + }, []); + + const handleDialogKeyDown = React.useCallback((event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + props.closePopup(); + return; + } - return ( -
-
-
-
-

Popup Feature Orchestrator

-

{props.title}

-

{props.featureSummary}

-
- -
+ if (event.key !== 'Tab' || !dialogRef.current) { + return; + } -
-
-
-
-
-
-
-

Real-time Live View

-

{props.liveViewArtifact?.title || '라이브 피드 대기 중'}

-

{props.liveViewArtifact?.caption || (isSpreadsheetBuilder ? 'spreadsheet-builder 는 preview 단계에서 시트 schema 를 만들고, final 단계에서 workbook 패키지와 delivery asset 상태를 확정합니다.' : '실행을 시작하면 preview, final, quality 단계가 들어오는 순서대로 최신 상태와 이미지를 이 영역에 고정합니다.')}

-
- - {connectionLabel(props.streamConnection)} - -
+ const focusableElements = Array.from( + dialogRef.current.querySelectorAll( + 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])', + ), + ).filter((element) => !element.hasAttribute('disabled') && element.tabIndex !== -1 && element.offsetParent !== null); - {props.liveViewArtifact?.image_data_url ? ( -
- {props.liveViewArtifact.title} -
- source {props.liveViewArtifact.source} - {stateLabel(props.popupState)} -
-
- ) : isSpreadsheetBuilder && props.spreadsheetRunSummary ? ( -
-
-
-

Excel Live Feed

-

{props.spreadsheetRunSummary.stageLabel}

-

{props.spreadsheetRunSummary.stageDescription}

-
- {props.spreadsheetRunSummary.sheetName} -
-
-
-

컬럼 수

-

{props.spreadsheetRunSummary.columnCount}

-
-
-

행 수

-

{props.spreadsheetRunSummary.rowCount}

-
-
-

다운로드 자산

-

{props.spreadsheetDownloadLinks?.filter((item) => item.ready).length || 0}

-
-
-
-

Prompt Summary

-

{props.spreadsheetRunSummary.promptSummary || '프롬프트 요약이 아직 없습니다.'}

-
-
- ) : ( -
- {isSpreadsheetBuilder ? '실행을 시작하면 preview 결과로 시트 schema 가 우측 카드에 표시되고, final 단계에서 workbook 패키지와 xlsx/csv delivery asset 이 확정됩니다.' : '실행을 시작하면 preview artifact 또는 final artifact 가 도착하는 즉시 이 영역이 자동으로 갱신됩니다.'} -
- )} -
+ if (!focusableElements.length) { + event.preventDefault(); + dialogRef.current.focus(); + return; + } -
-
-

현재 단계

-

{stateLabel(props.popupState)}

-

event log 와 stage snapshot 에 맞춰 즉시 갱신됩니다.

-
-
-

경과 시간

-

{formatElapsed(props.elapsedSeconds)}

-

accepted 시점부터 스트림 종료까지 실시간으로 증가합니다.

-
-
-
-

세부 진행률

- {Math.max(0, Math.min(100, props.progressSnapshot?.percent || 0))}% -
-
-
-
-

{props.progressSnapshot?.message || 'progress 이벤트 대기 중'}

-

{props.progressSnapshot?.step || 'accepted'}

-
-
-

Stage Run 상태

-

{props.stageRunStatus || '미수신'}

-

stream 과 별도로 stage-run snapshot 을 주기적으로 재조회합니다.

-
-
-

마지막 갱신

-

{props.latestEventAt ? new Date(props.latestEventAt).toLocaleTimeString('ko-KR') : '대기'}

-

새 이벤트가 오면 즉시 시간과 화면이 함께 갱신됩니다.

-
-
-
-
+ const firstElement = focusableElements[0]; + const lastElement = focusableElements[focusableElements.length - 1]; + const activeElement = document.activeElement as HTMLElement | null; -
-

실행 입력

-
- props.setProjectName(event.target.value)} placeholder="프로젝트명" className="w-full rounded-2xl border border-[#30363d] bg-[#0d1117] px-4 py-3 text-sm text-white outline-none" /> - -