feat(web): #126 Frontend Replay UI 整合
All checks were successful
E2E Health Check / e2e-health (push) Successful in 18s
All checks were successful
E2E Health Check / e2e-health (push) Successful in 18s
- 新增 useUXAudit hook (5 分鐘自動刷新) - 新增 UXAuditCard 組件 (健康度 + Replay 連結) - 整合到錯誤追蹤頁面 - i18n: zh-TW + en 翻譯 功能: - UX 健康度評分 (good/moderate/poor) - 有錯誤的 Replay 連結 - 憤怒點擊/死亡點擊統計 - Replay Dashboard 快捷連結 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,11 +7,14 @@
|
||||
* - ErrorOverviewCard (#41)
|
||||
* - RecentIssuesList (#42)
|
||||
* - ErrorTrendChart (#43)
|
||||
* - UXAuditCard (#126) - Session Replay 整合
|
||||
*
|
||||
* 建立: 2026-03-26 (台北時區)
|
||||
* 建立者: Claude Code (#41-44 Error UI)
|
||||
* 更新: 2026-03-31 Claude Code (#126 Replay UI)
|
||||
*/
|
||||
|
||||
export { ErrorOverviewCard } from './error-overview-card'
|
||||
export { RecentIssuesList } from './recent-issues-list'
|
||||
export { ErrorTrendChart } from './error-trend-chart'
|
||||
export { UXAuditCard } from './ux-audit-card'
|
||||
|
||||
293
apps/web/src/components/errors/ux-audit-card.tsx
Normal file
293
apps/web/src/components/errors/ux-audit-card.tsx
Normal file
@@ -0,0 +1,293 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* UXAuditCard - #126 Session Replay / UX Audit 卡片
|
||||
* ==================================================
|
||||
* Phase 19: Sentry Session Replay 前端整合
|
||||
*
|
||||
* 功能:
|
||||
* - 顯示 UX 健康度評分
|
||||
* - 列出有錯誤的 Replay 連結
|
||||
* - 顯示憤怒/死亡點擊統計
|
||||
*
|
||||
* Nothing.tech 視覺規範:
|
||||
* - 純白底色 (bg-white)
|
||||
* - 極細淺灰邊框 (border border-gray-200)
|
||||
* - 無圓角或微圓角 (rounded-sm)
|
||||
* - 嚴禁陰影 (shadow-none)
|
||||
*
|
||||
* 建立: 2026-03-31 (台北時區)
|
||||
* 建立者: Claude Code (#126 Replay UI)
|
||||
*/
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Video,
|
||||
MousePointerClick,
|
||||
AlertTriangle,
|
||||
ExternalLink,
|
||||
Activity,
|
||||
Zap,
|
||||
} from 'lucide-react'
|
||||
import type { UXAuditResponse, UXAuditDetail } from '@/lib/api-client'
|
||||
|
||||
// =============================================================================
|
||||
// Component Props
|
||||
// =============================================================================
|
||||
|
||||
interface UXAuditCardProps {
|
||||
data: UXAuditResponse | null
|
||||
loading?: boolean
|
||||
error?: string | null
|
||||
className?: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Health Score Config
|
||||
// =============================================================================
|
||||
|
||||
const HEALTH_CONFIG = {
|
||||
good: {
|
||||
color: 'text-green-700',
|
||||
bgColor: 'bg-green-50',
|
||||
borderColor: 'border-green-200',
|
||||
label: 'good',
|
||||
},
|
||||
moderate: {
|
||||
color: 'text-amber-700',
|
||||
bgColor: 'bg-amber-50',
|
||||
borderColor: 'border-amber-200',
|
||||
label: 'moderate',
|
||||
},
|
||||
poor: {
|
||||
color: 'text-red-700',
|
||||
bgColor: 'bg-red-50',
|
||||
borderColor: 'border-red-200',
|
||||
label: 'poor',
|
||||
},
|
||||
} as const
|
||||
|
||||
// =============================================================================
|
||||
// Replay Item Component
|
||||
// =============================================================================
|
||||
|
||||
function ReplayItem({ detail }: { detail: UXAuditDetail }) {
|
||||
const t = useTranslations('errors.uxAudit')
|
||||
|
||||
if (detail.type === 'replay_with_errors') {
|
||||
return (
|
||||
<a
|
||||
href={detail.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-3 p-2 hover:bg-gray-50 rounded-sm transition-colors group"
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
<Video className="h-4 w-4 text-purple-600" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-gray-900 truncate">
|
||||
{t('replayWithErrors', { count: detail.error_count || 0 })}
|
||||
</div>
|
||||
{detail.urls && detail.urls.length > 0 && (
|
||||
<div className="text-xs text-gray-500 truncate">
|
||||
{detail.urls[0]}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ExternalLink className="h-3.5 w-3.5 text-gray-400 group-hover:text-purple-600 transition-colors" />
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
// UI Error type
|
||||
return (
|
||||
<a
|
||||
href={detail.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-3 p-2 hover:bg-gray-50 rounded-sm transition-colors group"
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
<AlertTriangle className="h-4 w-4 text-orange-600" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-gray-900 truncate">
|
||||
{detail.title || 'UI Error'}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{t('occurrences', { count: detail.count || 0 })}
|
||||
</div>
|
||||
</div>
|
||||
<ExternalLink className="h-3.5 w-3.5 text-gray-400 group-hover:text-orange-600 transition-colors" />
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Main Component
|
||||
// =============================================================================
|
||||
|
||||
export function UXAuditCard({
|
||||
data,
|
||||
loading = false,
|
||||
error = null,
|
||||
className,
|
||||
}: UXAuditCardProps) {
|
||||
const t = useTranslations('errors.uxAudit')
|
||||
|
||||
// Loading state
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={cn('bg-white border border-gray-200 rounded-sm', className)}>
|
||||
<div className="p-3 border-b border-gray-100">
|
||||
<div className="h-4 bg-gray-200 rounded w-1/3 animate-pulse" />
|
||||
</div>
|
||||
<div className="p-4 space-y-3 animate-pulse">
|
||||
<div className="h-8 bg-gray-100 rounded w-1/4" />
|
||||
<div className="h-4 bg-gray-100 rounded w-full" />
|
||||
<div className="h-4 bg-gray-100 rounded w-3/4" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<div className={cn('bg-white border border-red-200 rounded-sm p-4', className)}>
|
||||
<div className="flex items-center gap-2 text-red-600">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<span className="text-sm">{error}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// No data state
|
||||
if (!data) {
|
||||
return (
|
||||
<div className={cn('bg-white border border-gray-200 rounded-sm p-6', className)}>
|
||||
<div className="text-center text-gray-500">
|
||||
<Video className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">{t('noData')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const healthConfig = HEALTH_CONFIG[data.health_score] || HEALTH_CONFIG.moderate
|
||||
const replayDetails = data.details.filter((d) => d.type === 'replay_with_errors')
|
||||
const errorDetails = data.details.filter((d) => d.type === 'ui_error')
|
||||
|
||||
return (
|
||||
<div className={cn('bg-white border border-gray-200 rounded-sm', className)}>
|
||||
{/* Header */}
|
||||
<div className="px-3 py-2 border-b border-gray-100 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Video className="h-4 w-4 text-purple-600" />
|
||||
<h3 className="text-sm font-medium text-gray-900">{t('title')}</h3>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
'px-2 py-0.5 text-xs font-medium rounded',
|
||||
healthConfig.bgColor,
|
||||
healthConfig.color,
|
||||
)}
|
||||
>
|
||||
{t(`health.${healthConfig.label}`)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-2 gap-px bg-gray-100 border-b border-gray-100">
|
||||
{/* Replays with Errors */}
|
||||
<div className="bg-white p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Video className="h-4 w-4 text-purple-500" />
|
||||
<span className="text-xs text-gray-500">{t('replaysWithErrors')}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xl font-semibold text-gray-900">
|
||||
{data.replays_with_errors}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* UI Errors */}
|
||||
<div className="bg-white p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-4 w-4 text-orange-500" />
|
||||
<span className="text-xs text-gray-500">{t('uiErrors')}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xl font-semibold text-gray-900">
|
||||
{data.ui_errors}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rage Clicks */}
|
||||
<div className="bg-white p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="h-4 w-4 text-red-500" />
|
||||
<span className="text-xs text-gray-500">{t('rageClicks')}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xl font-semibold text-gray-900">
|
||||
{data.rage_clicks}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dead Clicks */}
|
||||
<div className="bg-white p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<MousePointerClick className="h-4 w-4 text-gray-400" />
|
||||
<span className="text-xs text-gray-500">{t('deadClicks')}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xl font-semibold text-gray-900">
|
||||
{data.dead_clicks}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Replay List */}
|
||||
{replayDetails.length > 0 && (
|
||||
<div className="border-b border-gray-100">
|
||||
<div className="px-3 py-1.5 bg-gray-50 text-xs font-medium text-gray-600">
|
||||
{t('recentReplays')}
|
||||
</div>
|
||||
<div className="divide-y divide-gray-100">
|
||||
{replayDetails.slice(0, 5).map((detail, idx) => (
|
||||
<ReplayItem key={detail.replay_id || idx} detail={detail} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* UI Errors List */}
|
||||
{errorDetails.length > 0 && (
|
||||
<div className="border-b border-gray-100">
|
||||
<div className="px-3 py-1.5 bg-gray-50 text-xs font-medium text-gray-600">
|
||||
{t('recentUIErrors')}
|
||||
</div>
|
||||
<div className="divide-y divide-gray-100">
|
||||
{errorDetails.slice(0, 3).map((detail, idx) => (
|
||||
<ReplayItem key={detail.issue_id || idx} detail={detail} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer: Dashboard Link */}
|
||||
<div className="px-3 py-2">
|
||||
<a
|
||||
href={data.replay_dashboard_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-1.5 text-xs text-purple-600 hover:text-purple-700 transition-colors"
|
||||
>
|
||||
<Activity className="h-3.5 w-3.5" />
|
||||
{t('viewDashboard')}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user