feat(web): #126 Frontend Replay UI 整合
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:
OG T
2026-03-31 16:04:44 +08:00
parent d03668669b
commit 2f02f1523a
9 changed files with 530 additions and 3 deletions

View File

@@ -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'

View 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>
)
}