diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml new file mode 100644 index 0000000..899f1f9 --- /dev/null +++ b/.github/workflows/ci-cd.yml @@ -0,0 +1,201 @@ +name: Crawlful Hub CI/CD Pipeline + +on: + push: + branches: + - main + - develop + - 'release/*' + pull_request: + branches: + - main + - develop + +env: + NODE_VERSION: '18.x' + NODE_OPTIONS: '--max-old-space-size=4096' + +jobs: + lint: + name: Lint & Type Check + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + cache-dependency-path: '**/package-lock.json' + + - name: Install dependencies + run: | + npm ci + cd server && npm ci + cd ../dashboard && npm ci + cd ../extension && npm ci + + - name: Run ESLint + run: npm run lint --if-present + + - name: Run TypeScript check + run: | + cd server && npx tsc --noEmit --skipLibCheck + cd ../dashboard && npx tsc --noEmit --skipLibCheck + + test: + name: Unit Tests + runs-on: ubuntu-latest + needs: lint + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + + - name: Install dependencies + run: | + npm ci + cd server && npm ci + + - name: Run tests + run: cd server && npm test --if-present + env: + NODE_ENV: test + DB_HOST: localhost + REDIS_HOST: localhost + + build: + name: Build + runs-on: ubuntu-latest + needs: test + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + + - name: Install dependencies + run: | + npm ci + cd server && npm ci + cd ../dashboard && npm ci + cd ../extension && npm ci + + - name: Build server + run: cd server && npm run build --if-present + + - name: Build dashboard + run: cd dashboard && npm run build --if-present + + - name: Build extension + run: cd extension && npm run build --if-present + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: build-artifacts + path: | + server/dist + dashboard/dist + extension/dist + retention-days: 7 + + security-scan: + name: Security Scan + runs-on: ubuntu-latest + needs: lint + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Run Trivy vulnerability scanner + uses: aquasecurity/trivy-action@master + with: + scan-type: 'fs' + scan-ref: '.' + format: 'table' + exit-code: '0' + ignore-unfixed: true + severity: 'CRITICAL,HIGH' + + - name: Run npm audit + run: | + npm audit --audit-level=high || true + cd server && npm audit --audit-level=high || true + continue-on-error: true + + deploy-staging: + name: Deploy to Staging + runs-on: ubuntu-latest + needs: [build, security-scan] + if: github.ref == 'refs/heads/develop' + environment: + name: staging + url: https://staging.crawlful-hub.com + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + name: build-artifacts + + - name: Deploy to staging + run: | + echo "Deploying to staging environment..." + echo "This is a placeholder for actual deployment steps" + env: + DEPLOY_ENV: staging + + deploy-production: + name: Deploy to Production + runs-on: ubuntu-latest + needs: [build, security-scan] + if: github.ref == 'refs/heads/main' + environment: + name: production + url: https://crawlful-hub.com + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + name: build-artifacts + + - name: Deploy to production + run: | + echo "Deploying to production environment..." + echo "This is a placeholder for actual deployment steps" + env: + DEPLOY_ENV: production + + - name: Notify deployment + run: | + echo "Production deployment completed" + echo "Version: ${{ github.sha }}" + + notify: + name: Notify + runs-on: ubuntu-latest + needs: [deploy-staging, deploy-production] + if: always() + steps: + - name: Send notification + run: | + echo "Pipeline completed with status: ${{ job.status }}" + echo "Branch: ${{ github.ref_name }}" + echo "Commit: ${{ github.sha }}" diff --git a/.trae/rules/project-specific-rules.md b/.trae/rules/project-specific-rules.md index bfdda57..d8a4403 100644 --- a/.trae/rules/project-specific-rules.md +++ b/.trae/rules/project-specific-rules.md @@ -143,6 +143,86 @@ Agent 必须在以下阶段上报"自我问题": - **文件占用锁**: 同目录协作先声明归属,"谁领取谁编辑" - **冲突处理**: 后写入方必须先 Read 最新内容,增量合并 +### 7.4 任务包领取机制(强制执行) + +**核心原则**: 一次领取完整任务包,避免碎片化等待 + +#### 任务包定义 +``` +任务包 = 同一闭环的连续任务 + 依赖链完整 + 文件归属明确 +``` + +#### 领取规则 +1. **优先领取任务包**: 必须优先领取同一闭环的完整任务链 +2. **最小粒度**: 单次领取不少于 2 个相关任务 +3. **依赖自包含**: 领取的任务包内依赖必须闭环 + +#### 任务包类型 +| 包类型 | 包含任务 | 示例 | +|--------|----------|------| +| 🔗 闭环包 | 同一业务闭环的全部任务 | BE-TOB001 + BE-TOB002 + BE-TOB003 | +| 📦 模块包 | 同一模块的连续任务 | FE-AD001 + FE-AD002 + FE-AD003 | +| 🔗 依赖链包 | 有依赖关系的任务链 | BE-P001 → BE-P002 → BE-P003 | + +### 7.5 协作防撞车机制(强制执行) + +#### 方案一:模块分区锁定 +``` +领取任务时,必须同时声明: +1. 占用的模块/闭环名称 +2. 涉及的主要文件路径 +3. 预计完成时间 +``` + +#### 方案二:文件占用声明 +在 Task_Overview.md 顶部维护 **🔒 当前占用区**: +```markdown +## 🔒 当前任务占用状态 + +| Agent | 占用模块 | 涉及任务 | 主要文件 | 开始时间 | 状态 | +|-------|----------|----------|----------|----------|------| +| AI-Backend-1 | B2B贸易闭环 | BE-TOB001~003 | B2BTradeService.ts | 2025-03-18 10:00 | 🔒 进行中 | +``` + +#### 方案三:冲突检测流程 +``` +Step 1: 领取前检查 Task_Overview.md 的 🔒占用区 +Step 2: 确认目标模块未被占用 +Step 3: 声明占用并更新状态 +Step 4: 执行任务 +Step 5: 完成后释放占用 +``` + +#### 撞车处理优先级 +1. **先声明者优先**: 先在 Task_Overview.md 声明占用的 Agent 拥有优先权 +2. **后到者避让**: 后到的 Agent 必须选择其他模块 +3. **协商解决**: 如有争议,由 Brain 协调分配 + +### 7.6 任务包领取模板 + +领取任务时,必须在 Task_Overview.md 更新以下信息: + +```markdown +### 🔒 当前占用声明 + +**Agent**: [你的标识,如 AI-Backend-1] +**领取时间**: [YYYY-MM-DD HH:MM] +**任务包**: [任务ID列表,如 BE-TOB001, BE-TOB002, BE-TOB003] +**占用模块**: [模块名称,如 B2B贸易闭环] +**涉及文件**: +- server/src/services/B2BTradeService.ts +- server/src/models/B2B.ts +- server/src/api/routes/trade.ts +**预计完成**: [预计完成时间] +``` + +### 7.7 禁止行为 + +- ❌ **禁止**: 单独领取任务包内的部分任务 +- ❌ **禁止**: 不声明占用直接开始开发 +- ❌ **禁止**: 跨模块同时占用多个任务包 +- ❌ **禁止**: 占用超过 24 小时未释放 + --- ## 8. 追踪与日志 @@ -193,6 +273,8 @@ Agent 必须在以下阶段上报"自我问题": | 安全权限 | 使用 `authorize()` 中间件 | 权限漏洞 | | 性能边界 | Worker并发≤10, 内存≤4GB | 系统崩溃 | | 追踪日志 | 五元组必填 | 无法追溯 | +| **任务领取** | **优先领任务包,最小2个任务** | **碎片化等待** | +| **协作防撞** | **必须声明占用,先声明优先** | **重复开发** | --- diff --git a/dashboard/src/pages/ABTest/ABTestConfig.tsx b/dashboard/src/pages/ABTest/ABTestConfig.tsx new file mode 100644 index 0000000..b29d653 --- /dev/null +++ b/dashboard/src/pages/ABTest/ABTestConfig.tsx @@ -0,0 +1,581 @@ +import React, { useState } from 'react'; +import { + Card, + Form, + Input, + Button, + Select, + Slider, + DatePicker, + Typography, + Alert, + Space, + Divider, + Steps, + Radio, + InputNumber, + Tag, + Tooltip, +} from 'antd'; +import { + ExperimentOutlined, + PlusOutlined, + DeleteOutlined, + InfoCircleOutlined, + SaveOutlined, + PlayCircleOutlined, +} from '@ant-design/icons'; +import type { Dayjs } from 'dayjs'; + +const { Title, Text, Paragraph } = Typography; +const { Option } = Select; +const { Step } = Steps; +const { RangePicker } = DatePicker; + +interface TestVariant { + id: string; + name: string; + description: string; + trafficAllocation: number; + configuration: Record; +} + +interface ABTestConfig { + name: string; + description: string; + type: 'UI' | 'PRICE' | 'CONTENT' | 'FEATURE'; + targetMetric: string; + variants: TestVariant[]; + trafficPercentage: number; + duration: number; + startDate?: Dayjs; + endDate?: Dayjs; + audience: { + segments: string[]; + platforms: string[]; + countries: string[]; + }; + successCriteria: { + metric: string; + threshold: number; + confidenceLevel: number; + }; +} + +export const ABTestConfigPage: React.FC = () => { + const [form] = Form.useForm(); + const [currentStep, setCurrentStep] = useState(0); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [variants, setVariants] = useState([ + { id: 'control', name: 'Control (A)', description: 'Current version', trafficAllocation: 50, configuration: {} }, + { id: 'variant-1', name: 'Variant B', description: 'Test version', trafficAllocation: 50, configuration: {} }, + ]); + + const testTypes = [ + { value: 'UI', label: 'UI/UX Test', description: 'Test different user interface designs' }, + { value: 'PRICE', label: 'Pricing Test', description: 'Test different pricing strategies' }, + { value: 'CONTENT', label: 'Content Test', description: 'Test different content variations' }, + { value: 'FEATURE', label: 'Feature Test', description: 'Test new feature adoption' }, + ]; + + const targetMetrics = [ + { value: 'conversion_rate', label: 'Conversion Rate' }, + { value: 'revenue_per_user', label: 'Revenue Per User' }, + { value: 'click_through_rate', label: 'Click-Through Rate' }, + { value: 'bounce_rate', label: 'Bounce Rate' }, + { value: 'session_duration', label: 'Session Duration' }, + { value: 'add_to_cart_rate', label: 'Add to Cart Rate' }, + ]; + + const audienceSegments = [ + 'New Visitors', + 'Returning Customers', + 'High Value Customers', + 'Mobile Users', + 'Desktop Users', + 'VIP Members', + ]; + + const platforms = ['Amazon', 'Temu', 'TikTok', 'Shopee', 'Lazada']; + const countries = ['US', 'CA', 'UK', 'DE', 'FR', 'JP', 'AU']; + + const steps = [ + { title: 'Basic Info', description: 'Test details' }, + { title: 'Variants', description: 'Test variations' }, + { title: 'Audience', description: 'Target users' }, + { title: 'Review', description: 'Final check' }, + ]; + + const addVariant = () => { + const newVariant: TestVariant = { + id: `variant-${variants.length}`, + name: `Variant ${String.fromCharCode(66 + variants.length - 1)}`, + description: '', + trafficAllocation: 0, + configuration: {}, + }; + setVariants([...variants, newVariant]); + }; + + const removeVariant = (index: number) => { + if (variants.length <= 2) { + setError('At least 2 variants are required'); + return; + } + const newVariants = variants.filter((_, i) => i !== index); + // Redistribute traffic evenly + const equalAllocation = Math.floor(100 / newVariants.length); + newVariants.forEach((v, i) => { + v.trafficAllocation = i === newVariants.length - 1 + ? 100 - (equalAllocation * (newVariants.length - 1)) + : equalAllocation; + }); + setVariants(newVariants); + }; + + const updateVariantAllocation = (index: number, value: number) => { + const newVariants = [...variants]; + const oldValue = newVariants[index].trafficAllocation; + const diff = value - oldValue; + + newVariants[index].trafficAllocation = value; + + // Distribute the difference to other variants + const otherIndices = newVariants.map((_, i) => i).filter(i => i !== index); + const diffPerVariant = Math.floor(diff / otherIndices.length); + + otherIndices.forEach(i => { + newVariants[i].trafficAllocation = Math.max(0, newVariants[i].trafficAllocation - diffPerVariant); + }); + + // Ensure total is 100 + const total = newVariants.reduce((sum, v) => sum + v.trafficAllocation, 0); + if (total !== 100) { + newVariants[otherIndices[0]].trafficAllocation += (100 - total); + } + + setVariants(newVariants); + }; + + const handleNext = async () => { + try { + if (currentStep === 0) { + await form.validateFields(['name', 'type', 'targetMetric']); + } else if (currentStep === 1) { + const totalAllocation = variants.reduce((sum, v) => sum + v.trafficAllocation, 0); + if (totalAllocation !== 100) { + setError(`Traffic allocation must total 100%. Current: ${totalAllocation}%`); + return; + } + } + setError(null); + setCurrentStep(currentStep + 1); + } catch (err) { + // Validation failed + } + }; + + const handlePrev = () => { + setCurrentStep(currentStep - 1); + setError(null); + }; + + const handleSave = async () => { + setLoading(true); + try { + const values = await form.validateFields(); + const config: ABTestConfig = { + ...values, + variants, + }; + + // Simulate API call + await new Promise(resolve => setTimeout(resolve, 1500)); + + // Success + setCurrentStep(4); // Success step + } catch (err) { + setError('Failed to save A/B test configuration'); + } finally { + setLoading(false); + } + }; + + const renderStepContent = () => { + switch (currentStep) { + case 0: + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + ); + + case 1: + return ( + + + + {variants.map((variant, index) => ( + + + {variant.name} + + {index === 0 && Control} + + } + extra={ + index > 0 && ( + + +
+ + Total Allocation: {' '} + sum + v.trafficAllocation, 0) === 100 ? 'success' : 'danger'} + strong + > + {variants.reduce((sum, v) => sum + v.trafficAllocation, 0)}% + + +
+ + ); + + case 2: + return ( + + + + + + + + + + + + + + + + + + + + Success Criteria + + + + + + + + + + + + 90% + 95% + 99% + + + + ); + + case 3: + const values = form.getFieldsValue(); + return ( + + + + + + Name: {values.name} + + + Type: {testTypes.find(t => t.value === values.type)?.label} + + + Target Metric: + {targetMetrics.find(m => m.value === values.targetMetric)?.label} + + + Duration: {values.duration} days + + + + + {variants.map((variant, index) => ( + + {variant.name} + - {variant.trafficAllocation}% traffic + {variant.description && ( + ({variant.description}) + )} + + ))} + + + + + Platforms: + {values.audience?.platforms?.join(', ') || 'All platforms'} + + + Countries: + {values.audience?.countries?.join(', ') || 'All countries'} + + + Traffic: + {values.trafficPercentage || 100}% of eligible users + + + + ); + + case 4: + return ( + + View Test Dashboard + + } + /> + ); + + default: + return null; + } + }; + + return ( +
+ + + <ExperimentOutlined /> Create A/B Test + + + Configure and launch A/B tests to optimize your business metrics + + + + {steps.map(step => ( + + ))} + + + {error && ( + setError(null)} + /> + )} + +
+ {renderStepContent()} +
+ + {currentStep < 4 && ( +
+ + + {currentStep < 3 ? ( + + ) : ( + + )} + +
+ )} +
+
+ ); +}; + +export default ABTestConfigPage; diff --git a/dashboard/src/pages/ABTest/ABTestResults.tsx b/dashboard/src/pages/ABTest/ABTestResults.tsx new file mode 100644 index 0000000..3d500fe --- /dev/null +++ b/dashboard/src/pages/ABTest/ABTestResults.tsx @@ -0,0 +1,545 @@ +import React, { useState, useEffect } from 'react'; +import { + Card, + Table, + Tag, + Typography, + Statistic, + Row, + Col, + Progress, + Alert, + Button, + Space, + Tabs, + DatePicker, + Select, + Tooltip, + Badge, + Divider, + Empty, +} from 'antd'; +import { + ExperimentOutlined, + TrophyOutlined, + ArrowUpOutlined, + ArrowDownOutlined, + InfoCircleOutlined, + DownloadOutlined, + SyncOutlined, + CheckCircleOutlined, + CloseCircleOutlined, + MinusCircleOutlined, +} from '@ant-design/icons'; +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip as RechartsTooltip, + Legend, + ResponsiveContainer, + BarChart, + Bar, + PieChart, + Pie, + Cell, +} from 'recharts'; +import type { Dayjs } from 'dayjs'; + +const { Title, Text, Paragraph } = Typography; +const { TabPane } = Tabs; +const { RangePicker } = DatePicker; +const { Option } = Select; + +interface TestResult { + id: string; + name: string; + status: 'RUNNING' | 'COMPLETED' | 'PAUSED' | 'DRAFT'; + type: string; + startDate: string; + endDate?: string; + totalVisitors: number; + totalConversions: number; + confidenceLevel: number; + winner?: string; + variants: VariantResult[]; +} + +interface VariantResult { + id: string; + name: string; + isControl: boolean; + visitors: number; + conversions: number; + conversionRate: number; + improvement: number; + confidenceInterval: [number, number]; + isWinner: boolean; + isSignificant: boolean; +} + +interface DailyMetric { + date: string; + control: number; + variant: number; +} + +export const ABTestResultsPage: React.FC = () => { + const [loading, setLoading] = useState(false); + const [selectedTest, setSelectedTest] = useState(null); + const [dateRange, setDateRange] = useState<[Dayjs, Dayjs] | null>(null); + const [activeTab, setActiveTab] = useState('overview'); + + // Mock data + const testResults: TestResult[] = [ + { + id: 'test-001', + name: 'Homepage Banner Test', + status: 'COMPLETED', + type: 'UI', + startDate: '2024-03-01', + endDate: '2024-03-15', + totalVisitors: 50000, + totalConversions: 2500, + confidenceLevel: 95, + winner: 'Variant B', + variants: [ + { + id: 'control', + name: 'Control (A)', + isControl: true, + visitors: 25000, + conversions: 1125, + conversionRate: 4.5, + improvement: 0, + confidenceInterval: [4.2, 4.8], + isWinner: false, + isSignificant: false, + }, + { + id: 'variant-b', + name: 'Variant B', + isControl: false, + visitors: 25000, + conversions: 1375, + conversionRate: 5.5, + improvement: 22.2, + confidenceInterval: [5.2, 5.8], + isWinner: true, + isSignificant: true, + }, + ], + }, + { + id: 'test-002', + name: 'Pricing Strategy Test', + status: 'RUNNING', + type: 'PRICE', + startDate: '2024-03-10', + totalVisitors: 30000, + totalConversions: 1200, + confidenceLevel: 0, + variants: [ + { + id: 'control', + name: 'Control ($99)', + isControl: true, + visitors: 15000, + conversions: 600, + conversionRate: 4.0, + improvement: 0, + confidenceInterval: [3.7, 4.3], + isWinner: false, + isSignificant: false, + }, + { + id: 'variant-b', + name: 'Variant B ($89)', + isControl: false, + visitors: 15000, + conversions: 600, + conversionRate: 4.0, + improvement: 0, + confidenceInterval: [3.7, 4.3], + isWinner: false, + isSignificant: false, + }, + ], + }, + ]; + + const dailyMetrics: DailyMetric[] = [ + { date: '2024-03-01', control: 4.2, variant: 4.5 }, + { date: '2024-03-02', control: 4.3, variant: 4.8 }, + { date: '2024-03-03', control: 4.1, variant: 4.6 }, + { date: '2024-03-04', control: 4.4, variant: 5.0 }, + { date: '2024-03-05', control: 4.5, variant: 5.2 }, + { date: '2024-03-06', control: 4.3, variant: 5.1 }, + { date: '2024-03-07', control: 4.6, variant: 5.3 }, + ]; + + const trafficDistribution = [ + { name: 'Control', value: 50, color: '#1890ff' }, + { name: 'Variant B', value: 50, color: '#52c41a' }, + ]; + + const getStatusColor = (status: string) => { + switch (status) { + case 'RUNNING': return 'processing'; + case 'COMPLETED': return 'success'; + case 'PAUSED': return 'warning'; + case 'DRAFT': return 'default'; + default: return 'default'; + } + }; + + const getStatusIcon = (status: string) => { + switch (status) { + case 'RUNNING': return ; + case 'COMPLETED': return ; + case 'PAUSED': return ; + case 'DRAFT': return ; + default: return null; + } + }; + + const columns = [ + { + title: 'Test Name', + dataIndex: 'name', + key: 'name', + render: (text: string, record: TestResult) => ( + + {text} + + {getStatusIcon(record.status)} {record.status} + + + ), + }, + { + title: 'Type', + dataIndex: 'type', + key: 'type', + render: (type: string) => ( + {type} + ), + }, + { + title: 'Duration', + key: 'duration', + render: (_: any, record: TestResult) => ( + {record.startDate} {record.endDate ? `to ${record.endDate}` : 'to present'} + ), + }, + { + title: 'Visitors', + dataIndex: 'totalVisitors', + key: 'visitors', + render: (value: number) => value.toLocaleString(), + }, + { + title: 'Winner', + key: 'winner', + render: (_: any, record: TestResult) => ( + record.winner ? ( + + + {record.winner} + + ) : ( + No winner yet + ) + ), + }, + { + title: 'Actions', + key: 'actions', + render: (_: any, record: TestResult) => ( + + + + + ), + }, + ]; + + const selectedTestData = testResults.find(t => t.id === selectedTest); + + return ( +
+ + <ExperimentOutlined /> A/B Test Results + + + Monitor and analyze your A/B test performance + + + + + setDateRange(dates as [Dayjs, Dayjs])} /> + + + + + + + + {selectedTestData && ( + + + + + + (value as number).toLocaleString()} + /> + + + (value as number).toLocaleString()} + /> + + + + + + = 95 ? '#52c41a' : '#faad14' }} + /> + + + + {selectedTestData.winner && ( + + + {selectedTestData.winner} + is the winner with + + +{selectedTestData.variants.find(v => v.name === selectedTestData.winner)?.improvement.toFixed(1)}% improvement + + + } + type="success" + showIcon + style={{ marginBottom: 24 }} + /> + )} + + Variant Performance + + {selectedTestData.variants.map(variant => ( + + + {variant.name} + {variant.isWinner && ( + }>Winner + )} + {variant.isControl && ( + Control + )} + + } + > + + + (value as number).toLocaleString()} + /> + + + (value as number).toLocaleString()} + /> + + + + + + + + + 0 ? '#52c41a' : variant.improvement < 0 ? '#ff4d4f' : '#666' }} + prefix={variant.improvement > 0 ? : variant.improvement < 0 ? : null} + /> + + +
+ + Confidence Interval: [{variant.confidenceInterval[0].toFixed(1)}%, {variant.confidenceInterval[1].toFixed(1)}%] + + {variant.isSignificant && ( + Statistically Significant + )} +
+ + + ))} + + + + + + + + + + + `${value.toFixed(2)}%`} /> + + + + + + + + +
+ + + + + {trafficDistribution.map((entry, index) => ( + + ))} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + P < 0.05 indicates statistical significance + + + + + + + Probability of detecting true effect + + + + + + Sample Size Analysis + `${percent}% of required sample`} + /> + + Current sample size is sufficient for detecting a 20% improvement with 95% confidence. + + + + + + )} + + {!selectedTestData && ( + + + + )} + + ); +}; + +export default ABTestResultsPage; diff --git a/dashboard/src/pages/ABTest/index.ts b/dashboard/src/pages/ABTest/index.ts new file mode 100644 index 0000000..ec62b33 --- /dev/null +++ b/dashboard/src/pages/ABTest/index.ts @@ -0,0 +1,2 @@ +export { ABTestConfigPage } from './ABTestConfig'; +export { ABTestResultsPage } from './ABTestResults'; diff --git a/dashboard/src/pages/Ad/AdDelivery.tsx b/dashboard/src/pages/Ad/AdDelivery.tsx new file mode 100644 index 0000000..8abadd4 --- /dev/null +++ b/dashboard/src/pages/Ad/AdDelivery.tsx @@ -0,0 +1,690 @@ +import React, { useState, useEffect } from 'react'; +import { + Card, + Table, + Button, + Space, + Modal, + Form, + Input, + InputNumber, + Select, + Tag, + Tooltip, + Row, + Col, + Statistic, + message, + Progress, + Divider, + Alert, + Descriptions, +} from 'antd'; +import { + RocketOutlined, + PlayCircleOutlined, + PauseCircleOutlined, + EyeOutlined, + DollarOutlined, + ThunderboltOutlined, + CheckCircleOutlined, + CloseCircleOutlined, + SyncOutlined, +} from '@ant-design/icons'; +import type { ColumnsType } from 'antd/es/table'; +import dayjs from 'dayjs'; + +const { Option } = Select; + +type CampaignStatus = 'PENDING' | 'RUNNING' | 'PAUSED' | 'COMPLETED' | 'FAILED'; + +interface AdCampaign { + id: string; + ad_plan_id: string; + tenant_id: string; + product_id?: string; + sku_id?: string; + bid_amount: number; + clicks: number; + impressions: number; + spend: number; + sales: number; + conversions: number; + status: CampaignStatus; + created_at: string; + updated_at: string; +} + +interface AdPlan { + id: string; + name: string; + platform: string; + budget: number; + status: string; +} + +interface ExecuteCampaignParams { + adPlanId: string; + productId?: string; + skuId?: string; + bidAmount: number; +} + +const CAMPAIGN_STATUS_COLORS: Record = { + PENDING: 'default', + RUNNING: 'green', + PAUSED: 'orange', + COMPLETED: 'blue', + FAILED: 'red', +}; + +const CAMPAIGN_STATUS_ICONS: Record = { + PENDING: , + RUNNING: , + PAUSED: , + COMPLETED: , + FAILED: , +}; + +const MOCK_AD_PLANS: AdPlan[] = [ + { id: 'ADP-001', name: 'Summer Sale Campaign', platform: 'AMAZON', budget: 5000, status: 'ACTIVE' }, + { id: 'ADP-002', name: 'New Product Launch', platform: 'SHOPEE', budget: 15000, status: 'ACTIVE' }, + { id: 'ADP-003', name: 'Brand Awareness Q3', platform: 'TIKTOK', budget: 8000, status: 'DRAFT' }, +]; + +const MOCK_CAMPAIGNS: AdCampaign[] = [ + { + id: 'CAMP-001', + ad_plan_id: 'ADP-001', + tenant_id: 'T001', + product_id: 'P001', + sku_id: 'SKU001', + bid_amount: 2.5, + clicks: 1250, + impressions: 45000, + spend: 312.5, + sales: 1562.5, + conversions: 45, + status: 'RUNNING', + created_at: '2026-06-01T10:00:00Z', + updated_at: '2026-06-15T14:30:00Z', + }, + { + id: 'CAMP-002', + ad_plan_id: 'ADP-001', + tenant_id: 'T001', + product_id: 'P002', + sku_id: 'SKU002', + bid_amount: 1.8, + clicks: 890, + impressions: 32000, + spend: 160.2, + sales: 890.0, + conversions: 28, + status: 'RUNNING', + created_at: '2026-06-02T09:00:00Z', + updated_at: '2026-06-15T14:30:00Z', + }, + { + id: 'CAMP-003', + ad_plan_id: 'ADP-002', + tenant_id: 'T001', + product_id: 'P003', + bid_amount: 3.0, + clicks: 2100, + impressions: 78000, + spend: 630.0, + sales: 3150.0, + conversions: 72, + status: 'RUNNING', + created_at: '2026-07-01T08:00:00Z', + updated_at: '2026-07-10T16:45:00Z', + }, +]; + +const MOCK_PRODUCTS = [ + { id: 'P001', name: 'Wireless Earbuds Pro', sku: 'SKU001' }, + { id: 'P002', name: 'Smart Watch X200', sku: 'SKU002' }, + { id: 'P003', name: 'Bluetooth Speaker Mini', sku: 'SKU003' }, + { id: 'P004', name: 'USB-C Hub 7-in-1', sku: 'SKU004' }, +]; + +export const AdDelivery: React.FC = () => { + const [form] = Form.useForm(); + const [loading, setLoading] = useState(false); + const [campaigns, setCampaigns] = useState(MOCK_CAMPAIGNS); + const [adPlans, setAdPlans] = useState(MOCK_AD_PLANS); + const [modalVisible, setModalVisible] = useState(false); + const [detailModalVisible, setDetailModalVisible] = useState(false); + const [selectedCampaign, setSelectedCampaign] = useState(null); + const [selectedAdPlanId, setSelectedAdPlanId] = useState(null); + + useEffect(() => { + loadCampaigns(); + }, []); + + const loadCampaigns = async () => { + setLoading(true); + try { + await new Promise(resolve => setTimeout(resolve, 500)); + setCampaigns(MOCK_CAMPAIGNS); + } catch (error) { + message.error('Failed to load campaigns'); + } finally { + setLoading(false); + } + }; + + const handleExecuteCampaign = async (values: ExecuteCampaignParams) => { + setLoading(true); + try { + const newCampaign: AdCampaign = { + id: `CAMP-${Date.now()}`, + ad_plan_id: values.adPlanId, + tenant_id: 'T001', + product_id: values.productId, + sku_id: values.skuId, + bid_amount: values.bidAmount, + clicks: 0, + impressions: 0, + spend: 0, + sales: 0, + conversions: 0, + status: 'RUNNING', + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + + setCampaigns([...campaigns, newCampaign]); + setModalVisible(false); + form.resetFields(); + message.success('Campaign started successfully'); + } catch (error) { + message.error('Failed to start campaign'); + } finally { + setLoading(false); + } + }; + + const handlePauseCampaign = async (campaignId: string) => { + setLoading(true); + try { + setCampaigns(campaigns.map(c => + c.id === campaignId + ? { ...c, status: 'PAUSED' as CampaignStatus, updated_at: new Date().toISOString() } + : c + )); + message.success('Campaign paused'); + } catch (error) { + message.error('Failed to pause campaign'); + } finally { + setLoading(false); + } + }; + + const handleResumeCampaign = async (campaignId: string) => { + setLoading(true); + try { + setCampaigns(campaigns.map(c => + c.id === campaignId + ? { ...c, status: 'RUNNING' as CampaignStatus, updated_at: new Date().toISOString() } + : c + )); + message.success('Campaign resumed'); + } catch (error) { + message.error('Failed to resume campaign'); + } finally { + setLoading(false); + } + }; + + const openDetailModal = (campaign: AdCampaign) => { + setSelectedCampaign(campaign); + setDetailModalVisible(true); + }; + + const getAdPlanName = (adPlanId: string) => { + const plan = adPlans.find(p => p.id === adPlanId); + return plan ? plan.name : adPlanId; + }; + + const calculateCTR = (clicks: number, impressions: number) => { + if (impressions === 0) return 0; + return ((clicks / impressions) * 100).toFixed(2); + }; + + const calculateCPC = (spend: number, clicks: number) => { + if (clicks === 0) return 0; + return (spend / clicks).toFixed(2); + }; + + const calculateROAS = (sales: number, spend: number) => { + if (spend === 0) return 0; + return (sales / spend).toFixed(2); + }; + + const columns: ColumnsType = [ + { + title: 'Campaign ID', + dataIndex: 'id', + key: 'id', + width: 120, + render: (id: string) => {id}, + }, + { + title: 'Ad Plan', + dataIndex: 'ad_plan_id', + key: 'ad_plan_id', + width: 180, + render: (adPlanId: string) => getAdPlanName(adPlanId), + }, + { + title: 'Product', + dataIndex: 'product_id', + key: 'product_id', + width: 150, + render: (productId?: string) => { + const product = MOCK_PRODUCTS.find(p => p.id === productId); + return product ? product.name : productId || '-'; + }, + }, + { + title: 'Bid Amount', + dataIndex: 'bid_amount', + key: 'bid_amount', + width: 100, + render: (bid: number) => ${bid.toFixed(2)}, + }, + { + title: 'Impressions', + dataIndex: 'impressions', + key: 'impressions', + width: 100, + render: (impressions: number) => impressions.toLocaleString(), + }, + { + title: 'Clicks', + dataIndex: 'clicks', + key: 'clicks', + width: 80, + render: (clicks: number) => clicks.toLocaleString(), + }, + { + title: 'CTR', + key: 'ctr', + width: 80, + render: (_, record) => ( + {calculateCTR(record.clicks, record.impressions)}% + ), + }, + { + title: 'Spend', + dataIndex: 'spend', + key: 'spend', + width: 100, + render: (spend: number) => ( + ${spend.toFixed(2)} + ), + }, + { + title: 'Sales', + dataIndex: 'sales', + key: 'sales', + width: 100, + render: (sales: number) => ( + ${sales.toFixed(2)} + ), + }, + { + title: 'ROAS', + key: 'roas', + width: 80, + render: (_, record) => { + const roas = parseFloat(calculateROAS(record.sales, record.spend)); + const color = roas >= 3 ? '#52c41a' : roas >= 2 ? '#faad14' : '#ff4d4f'; + return {roas.toFixed(2)}x; + }, + }, + { + title: 'Status', + dataIndex: 'status', + key: 'status', + width: 100, + render: (status: CampaignStatus) => ( + + {status} + + ), + }, + { + title: 'Actions', + key: 'actions', + width: 150, + fixed: 'right', + render: (_, record) => ( + + + + + } + /> + + + + + } + /> + + + + + } + /> + + + + + 0 ? (totalSales / totalSpend).toFixed(2) : 0} + suffix="x" + valueStyle={{ + color: totalSpend > 0 && totalSales / totalSpend >= 3 ? '#52c41a' : '#faad14' + }} + /> + + + + + {runningCampaigns > 0 && ( + } + style={{ marginBottom: 16 }} + /> + )} + + + + + + } + > +
c.ad_plan_id === selectedAdPlanId) + : campaigns + } + rowKey="id" + loading={loading} + scroll={{ x: 1400 }} + pagination={{ + pageSize: 10, + showSizeChanger: true, + showTotal: (total) => `Total ${total} campaigns`, + }} + /> + + + { + setModalVisible(false); + form.resetFields(); + }} + footer={null} + width={500} + > +
+ + + + + + + + + + + + + + + + +
+ + { + setDetailModalVisible(false); + setSelectedCampaign(null); + }} + footer={[ + , + ]} + width={700} + > + {selectedCampaign && ( +
+ + + {selectedCampaign.id} + + + + {selectedCampaign.status} + + + {getAdPlanName(selectedCampaign.ad_plan_id)} + ${selectedCampaign.bid_amount.toFixed(2)} + + {MOCK_PRODUCTS.find(p => p.id === selectedCampaign.product_id)?.name || selectedCampaign.product_id || '-'} + + {selectedCampaign.sku_id || '-'} + + + Performance Metrics + + +
+ + + + + + + + + + + + + + + + + + + + + + + + = 3 + ? '#52c41a' + : '#faad14' + }} + /> + + + + Timeline + + + {dayjs(selectedCampaign.created_at).format('YYYY-MM-DD HH:mm:ss')} + + + {dayjs(selectedCampaign.updated_at).format('YYYY-MM-DD HH:mm:ss')} + + + + )} + + + ); +}; + +export default AdDelivery; diff --git a/dashboard/src/pages/Ad/AdPlanPage.tsx b/dashboard/src/pages/Ad/AdPlanPage.tsx new file mode 100644 index 0000000..d3435fe --- /dev/null +++ b/dashboard/src/pages/Ad/AdPlanPage.tsx @@ -0,0 +1,703 @@ +import React, { useState, useEffect } from 'react'; +import { + Card, + Table, + Button, + Space, + Modal, + Form, + Input, + InputNumber, + Select, + DatePicker, + Tag, + Tooltip, + Row, + Col, + Statistic, + message, + Popconfirm, + Badge, +} from 'antd'; +import { + PlusOutlined, + EditOutlined, + DeleteOutlined, + PlayCircleOutlined, + PauseCircleOutlined, + EyeOutlined, + DollarOutlined, + CalendarOutlined, + CheckCircleOutlined, + ClockCircleOutlined, + WarningOutlined, +} from '@ant-design/icons'; +import type { ColumnsType } from 'antd/es/table'; +import dayjs from 'dayjs'; + +const { Option } = Select; +const { RangePicker } = DatePicker; + +type AdPlatform = 'AMAZON' | 'EBAY' | 'SHOPEE' | 'TIKTOK'; +type AdPlanStatus = 'DRAFT' | 'ACTIVE' | 'PAUSED' | 'COMPLETED' | 'PENDING_REVIEW'; + +interface AdPlan { + id: string; + tenant_id: string; + shop_id: string; + name: string; + platform: AdPlatform; + budget: number; + daily_budget?: number; + start_date?: string; + end_date?: string; + status: AdPlanStatus; + target_audience?: string; + keywords?: string[]; + created_at: string; + updated_at: string; +} + +interface CreateAdPlanParams { + name: string; + platform: AdPlatform; + budget: number; + dailyBudget?: number; + startDate?: string; + endDate?: string; + targetAudience?: string; + keywords?: string[]; +} + +const PLATFORM_COLORS: Record = { + AMAZON: 'orange', + EBAY: 'blue', + SHOPEE: 'red', + TIKTOK: 'cyan', +}; + +const STATUS_COLORS: Record = { + DRAFT: 'default', + ACTIVE: 'green', + PAUSED: 'orange', + COMPLETED: 'blue', + PENDING_REVIEW: 'gold', +}; + +const STATUS_ICONS: Record = { + DRAFT: , + ACTIVE: , + PAUSED: , + COMPLETED: , + PENDING_REVIEW: , +}; + +const MOCK_AD_PLANS: AdPlan[] = [ + { + id: 'ADP-001', + tenant_id: 'T001', + shop_id: 'S001', + name: 'Summer Sale Campaign', + platform: 'AMAZON', + budget: 5000, + daily_budget: 200, + start_date: '2026-06-01', + end_date: '2026-06-30', + status: 'ACTIVE', + target_audience: 'Electronics enthusiasts', + keywords: ['electronics', 'gadgets', 'tech'], + created_at: '2026-05-15T10:00:00Z', + updated_at: '2026-05-15T10:00:00Z', + }, + { + id: 'ADP-002', + tenant_id: 'T001', + shop_id: 'S001', + name: 'New Product Launch', + platform: 'SHOPEE', + budget: 15000, + daily_budget: 500, + start_date: '2026-07-01', + end_date: '2026-07-31', + status: 'PENDING_REVIEW', + target_audience: 'Young professionals', + keywords: ['lifestyle', 'trending'], + created_at: '2026-05-20T14:30:00Z', + updated_at: '2026-05-20T14:30:00Z', + }, + { + id: 'ADP-003', + tenant_id: 'T001', + shop_id: 'S002', + name: 'Brand Awareness Q3', + platform: 'TIKTOK', + budget: 8000, + daily_budget: 300, + start_date: '2026-07-01', + end_date: '2026-09-30', + status: 'DRAFT', + created_at: '2026-05-22T09:15:00Z', + updated_at: '2026-05-22T09:15:00Z', + }, +]; + +export const AdPlanPage: React.FC = () => { + const [form] = Form.useForm(); + const [loading, setLoading] = useState(false); + const [adPlans, setAdPlans] = useState(MOCK_AD_PLANS); + const [modalVisible, setModalVisible] = useState(false); + const [detailModalVisible, setDetailModalVisible] = useState(false); + const [selectedPlan, setSelectedPlan] = useState(null); + const [editingPlan, setEditingPlan] = useState(null); + const [filterStatus, setFilterStatus] = useState('all'); + const [filterPlatform, setFilterPlatform] = useState('all'); + + useEffect(() => { + loadAdPlans(); + }, []); + + const loadAdPlans = async () => { + setLoading(true); + try { + await new Promise(resolve => setTimeout(resolve, 500)); + setAdPlans(MOCK_AD_PLANS); + } catch (error) { + message.error('Failed to load ad plans'); + } finally { + setLoading(false); + } + }; + + const handleCreatePlan = async (values: CreateAdPlanParams) => { + setLoading(true); + try { + const newPlan: AdPlan = { + id: `ADP-${Date.now()}`, + tenant_id: 'T001', + shop_id: 'S001', + name: values.name, + platform: values.platform, + budget: values.budget, + daily_budget: values.dailyBudget, + start_date: values.startDate, + end_date: values.endDate, + status: values.budget >= 10000 ? 'PENDING_REVIEW' : 'DRAFT', + target_audience: values.targetAudience, + keywords: values.keywords?.filter(k => k.trim()), + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + + setAdPlans([...adPlans, newPlan]); + setModalVisible(false); + form.resetFields(); + message.success('Ad plan created successfully'); + + if (newPlan.status === 'PENDING_REVIEW') { + message.warning('Budget exceeds $10,000, requires manual review'); + } + } catch (error) { + message.error('Failed to create ad plan'); + } finally { + setLoading(false); + } + }; + + const handleUpdateStatus = async (planId: string, newStatus: AdPlanStatus) => { + setLoading(true); + try { + setAdPlans(adPlans.map(plan => + plan.id === planId + ? { ...plan, status: newStatus, updated_at: new Date().toISOString() } + : plan + )); + message.success(`Ad plan status updated to ${newStatus}`); + } catch (error) { + message.error('Failed to update status'); + } finally { + setLoading(false); + } + }; + + const handleDeletePlan = async (planId: string) => { + setLoading(true); + try { + setAdPlans(adPlans.filter(plan => plan.id !== planId)); + message.success('Ad plan deleted successfully'); + } catch (error) { + message.error('Failed to delete ad plan'); + } finally { + setLoading(false); + } + }; + + const openCreateModal = () => { + setEditingPlan(null); + form.resetFields(); + setModalVisible(true); + }; + + const openEditModal = (plan: AdPlan) => { + setEditingPlan(plan); + form.setFieldsValue({ + name: plan.name, + platform: plan.platform, + budget: plan.budget, + dailyBudget: plan.daily_budget, + targetAudience: plan.target_audience, + keywords: plan.keywords, + }); + setModalVisible(true); + }; + + const openDetailModal = (plan: AdPlan) => { + setSelectedPlan(plan); + setDetailModalVisible(true); + }; + + const filteredPlans = adPlans.filter(plan => { + if (filterStatus !== 'all' && plan.status !== filterStatus) return false; + if (filterPlatform !== 'all' && plan.platform !== filterPlatform) return false; + return true; + }); + + const columns: ColumnsType = [ + { + title: 'Plan ID', + dataIndex: 'id', + key: 'id', + width: 120, + render: (id: string) => {id}, + }, + { + title: 'Name', + dataIndex: 'name', + key: 'name', + width: 200, + ellipsis: true, + }, + { + title: 'Platform', + dataIndex: 'platform', + key: 'platform', + width: 100, + render: (platform: AdPlatform) => ( + {platform} + ), + }, + { + title: 'Budget', + dataIndex: 'budget', + key: 'budget', + width: 120, + render: (budget: number) => ( + ${budget.toLocaleString()} + ), + }, + { + title: 'Daily Budget', + dataIndex: 'daily_budget', + key: 'daily_budget', + width: 120, + render: (budget?: number) => budget ? `$${budget.toLocaleString()}` : '-', + }, + { + title: 'Duration', + key: 'duration', + width: 180, + render: (_, record) => ( + + + + {record.start_date ? dayjs(record.start_date).format('YYYY-MM-DD') : '-'} + {' ~ '} + {record.end_date ? dayjs(record.end_date).format('YYYY-MM-DD') : '-'} + + + ), + }, + { + title: 'Status', + dataIndex: 'status', + key: 'status', + width: 130, + render: (status: AdPlanStatus) => ( + + {status.replace('_', ' ')} + + ), + }, + { + title: 'Actions', + key: 'actions', + width: 200, + fixed: 'right', + render: (_, record) => ( + + + + + )} + + + + + + + + + } + valueStyle={{ color: '#1890ff' }} + /> + + + + + } + /> + + + + + 0 ? '#faad14' : '#52c41a' }} + prefix={} + /> + + + + + + + + + + } + > +
`Total ${total} plans`, + }} + /> + + + { + setModalVisible(false); + setEditingPlan(null); + form.resetFields(); + }} + footer={null} + width={600} + > +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + setSelectedAdPlanId(value)} + > + {AD_PLANS.map(plan => ( + + ))} + + + + } + > + + + + + + Total Spend + + + + + } + value={totalSpend} + precision={2} + prefix={} + /> + + + + + + Total Sales + + + + + } + value={totalSales} + precision={2} + prefix={} + /> + + + + + + Average ROI + + + + + } + value={avgROI} + precision={2} + suffix="%" + prefix={} + valueStyle={{ color: getROIColor(avgROI) }} + /> + + + + + + Average ROAS + + + + + } + value={avgROAS} + precision={2} + suffix="x" + valueStyle={{ color: getROASColor(avgROAS) }} + /> + + + + + + + + + + + + + + + + + +
+ {filteredData.slice(0, 3).map(item => ( +
+ {item.adPlanName} + `${item.roi.toFixed(0)}%`} + strokeColor={getROIColor(item.roi)} + size="small" + /> +
+ ))} +
+
+ + + + ROI Details by Ad Plan + + {filteredData.length === 0 ? ( + + ) : ( +
({ + onClick: () => setSelectedRow(record), + style: { cursor: 'pointer' }, + })} + /> + )} + + + + {selectedRow && ( + setSelectedRow(null)}>Close + } + > + + + + + {selectedRow.adPlanId} + + + {selectedRow.platform} + + + {dateRange[0].format('YYYY-MM-DD')} ~ {dateRange[1].format('YYYY-MM-DD')} + + + + + + + (Sales - Spend) / Spend × 100% + + + Sales / Spend + + + Spend / Sales × 100% + + + + + + Performance Breakdown + + + + + 0 ? '#52c41a' : '#ff4d4f' + }} + /> + + + + + 0 ? (selectedRow.totalConversions / selectedRow.clicks * 100) : 0} + precision={2} + suffix="%" + /> + + + + + 0 ? selectedRow.totalSpend / selectedRow.totalConversions : 0} + precision={2} + prefix="$" + /> + + + + + )} + + + + + Minimum 15% + + + Minimum 20% + + + ≥ 3.0x (Recommended) + + + ≤ 30% (Recommended) + + + + + ); +}; + +export default ROIAnalysis; diff --git a/dashboard/src/pages/Ad/index.ts b/dashboard/src/pages/Ad/index.ts new file mode 100644 index 0000000..ef19eab --- /dev/null +++ b/dashboard/src/pages/Ad/index.ts @@ -0,0 +1,3 @@ +export { AdPlanPage } from './AdPlanPage'; +export { AdDelivery } from './AdDelivery'; +export { ROIAnalysis } from './ROIAnalysis'; diff --git a/dashboard/src/pages/AfterSales/CustomerService.tsx b/dashboard/src/pages/AfterSales/CustomerService.tsx new file mode 100644 index 0000000..7dcc882 --- /dev/null +++ b/dashboard/src/pages/AfterSales/CustomerService.tsx @@ -0,0 +1,849 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { + Card, + Table, + Tag, + Button, + Space, + Modal, + Form, + Input, + Select, + Descriptions, + Divider, + message, + Tabs, + Badge, + Tooltip, + Row, + Col, + Statistic, + List, + Avatar, + Typography, + Dropdown, + Menu, + Popconfirm, +} from 'antd'; +import { + MessageOutlined, + UserOutlined, + SendOutlined, + ClockCircleOutlined, + CheckCircleOutlined, + CloseCircleOutlined, + MoreOutlined, + PhoneOutlined, + MailOutlined, + FileTextOutlined, + ReloadOutlined, +} from '@ant-design/icons'; +import type { ColumnsType } from 'antd/es/table'; + +const { TextArea } = Input; +const { Option } = Select; +const { TabPane } = Tabs; +const { Text, Paragraph } = Typography; + +interface CustomerServiceTicket { + id: string; + ticketId: string; + orderId: string; + customerId: string; + customerName: string; + customerEmail: string; + subject: string; + category: string; + priority: 'LOW' | 'MEDIUM' | 'HIGH' | 'URGENT'; + status: 'OPEN' | 'IN_PROGRESS' | 'WAITING_CUSTOMER' | 'RESOLVED' | 'CLOSED'; + assignedTo?: string; + createdAt: string; + updatedAt: string; + platform: string; + lastMessage?: string; + messageCount: number; +} + +interface ChatMessage { + id: string; + ticketId: string; + sender: 'CUSTOMER' | 'AGENT' | 'SYSTEM'; + senderName: string; + content: string; + attachments?: string[]; + createdAt: string; + isRead: boolean; +} + +const TICKET_STATUS_MAP: Record = { + OPEN: { color: 'error', text: 'Open' }, + IN_PROGRESS: { color: 'processing', text: 'In Progress' }, + WAITING_CUSTOMER: { color: 'warning', text: 'Waiting Customer' }, + RESOLVED: { color: 'success', text: 'Resolved' }, + CLOSED: { color: 'default', text: 'Closed' }, +}; + +const PRIORITY_MAP: Record = { + LOW: { color: 'default', text: 'Low' }, + MEDIUM: { color: 'blue', text: 'Medium' }, + HIGH: { color: 'orange', text: 'High' }, + URGENT: { color: 'red', text: 'Urgent' }, +}; + +const CATEGORIES = [ + { value: 'ORDER_ISSUE', label: 'Order Issue' }, + { value: 'REFUND_REQUEST', label: 'Refund Request' }, + { value: 'RETURN_REQUEST', label: 'Return Request' }, + { value: 'SHIPPING_ISSUE', label: 'Shipping Issue' }, + { value: 'PRODUCT_INQUIRY', label: 'Product Inquiry' }, + { value: 'PAYMENT_ISSUE', label: 'Payment Issue' }, + { value: 'OTHER', label: 'Other' }, +]; + +const QUICK_REPLIES = [ + 'Thank you for contacting us. How can I help you today?', + 'I understand your concern. Let me check that for you.', + 'I apologize for the inconvenience. We will resolve this as soon as possible.', + 'Your request has been processed. Is there anything else I can help with?', + 'Thank you for your patience. We are looking into this issue.', +]; + +export const CustomerService: React.FC = () => { + const [form] = Form.useForm(); + const [loading, setLoading] = useState(false); + const [tickets, setTickets] = useState([]); + const [selectedTicket, setSelectedTicket] = useState(null); + const [messages, setMessages] = useState([]); + const [detailModalVisible, setDetailModalVisible] = useState(false); + const [chatModalVisible, setChatModalVisible] = useState(false); + const [newTicketModalVisible, setNewTicketModalVisible] = useState(false); + const [activeTab, setActiveTab] = useState('all'); + const [messageInput, setMessageInput] = useState(''); + const [stats, setStats] = useState({ + total: 0, + open: 0, + inProgress: 0, + resolved: 0, + avgResponseTime: 0, + }); + const chatEndRef = useRef(null); + + useEffect(() => { + fetchTickets(); + }, []); + + useEffect(() => { + if (chatModalVisible && chatEndRef.current) { + chatEndRef.current.scrollIntoView({ behavior: 'smooth' }); + } + }, [messages, chatModalVisible]); + + const fetchTickets = async () => { + setLoading(true); + try { + const mockTickets: CustomerServiceTicket[] = [ + { + id: '1', + ticketId: 'TKT-2026-001', + orderId: 'ORD-2026-001', + customerId: 'CUST_001', + customerName: 'John Smith', + customerEmail: 'john.smith@email.com', + subject: 'Order not received', + category: 'SHIPPING_ISSUE', + priority: 'HIGH', + status: 'OPEN', + createdAt: '2026-03-18 10:30:00', + updatedAt: '2026-03-18 11:00:00', + platform: 'Amazon', + lastMessage: 'When will my order arrive?', + messageCount: 3, + }, + { + id: '2', + ticketId: 'TKT-2026-002', + orderId: 'ORD-2026-002', + customerId: 'CUST_002', + customerName: 'Emma Wilson', + customerEmail: 'emma.wilson@email.com', + subject: 'Request for refund', + category: 'REFUND_REQUEST', + priority: 'MEDIUM', + status: 'IN_PROGRESS', + assignedTo: 'Agent_001', + createdAt: '2026-03-17 14:20:00', + updatedAt: '2026-03-18 09:00:00', + platform: 'eBay', + lastMessage: 'I would like to request a refund for my order.', + messageCount: 5, + }, + { + id: '3', + ticketId: 'TKT-2026-003', + orderId: 'ORD-2026-003', + customerId: 'CUST_003', + customerName: 'Michael Brown', + customerEmail: 'michael.brown@email.com', + subject: 'Product quality issue', + category: 'RETURN_REQUEST', + priority: 'LOW', + status: 'RESOLVED', + assignedTo: 'Agent_002', + createdAt: '2026-03-16 09:15:00', + updatedAt: '2026-03-17 16:30:00', + platform: 'Shopify', + lastMessage: 'Thank you for resolving my issue.', + messageCount: 8, + }, + { + id: '4', + ticketId: 'TKT-2026-004', + orderId: 'ORD-2026-004', + customerId: 'CUST_004', + customerName: 'Sarah Davis', + customerEmail: 'sarah.davis@email.com', + subject: 'Payment failed', + category: 'PAYMENT_ISSUE', + priority: 'URGENT', + status: 'WAITING_CUSTOMER', + assignedTo: 'Agent_001', + createdAt: '2026-03-18 08:00:00', + updatedAt: '2026-03-18 10:00:00', + platform: 'Amazon', + lastMessage: 'Please provide your payment screenshot.', + messageCount: 4, + }, + ]; + + setTickets(mockTickets); + calculateStats(mockTickets); + } catch (error) { + message.error('Failed to load tickets'); + } finally { + setLoading(false); + } + }; + + const calculateStats = (ticketList: CustomerServiceTicket[]) => { + setStats({ + total: ticketList.length, + open: ticketList.filter((t) => t.status === 'OPEN').length, + inProgress: ticketList.filter((t) => t.status === 'IN_PROGRESS').length, + resolved: ticketList.filter((t) => t.status === 'RESOLVED').length, + avgResponseTime: 2.5, + }); + }; + + const fetchMessages = async (ticketId: string) => { + const mockMessages: ChatMessage[] = [ + { + id: '1', + ticketId, + sender: 'CUSTOMER', + senderName: selectedTicket?.customerName || 'Customer', + content: 'Hello, I have an issue with my order.', + createdAt: '2026-03-18 10:30:00', + isRead: true, + }, + { + id: '2', + ticketId, + sender: 'AGENT', + senderName: 'Support Agent', + content: 'Hello! Thank you for contacting us. How can I help you today?', + createdAt: '2026-03-18 10:32:00', + isRead: true, + }, + { + id: '3', + ticketId, + sender: 'CUSTOMER', + senderName: selectedTicket?.customerName || 'Customer', + content: 'My order was supposed to arrive yesterday but it hasnt been delivered yet.', + createdAt: '2026-03-18 10:35:00', + isRead: true, + }, + { + id: '4', + ticketId, + sender: 'AGENT', + senderName: 'Support Agent', + content: 'I apologize for the delay. Let me check the status of your order for you.', + createdAt: '2026-03-18 10:38:00', + isRead: true, + }, + ]; + setMessages(mockMessages); + }; + + const handleViewDetail = (ticket: CustomerServiceTicket) => { + setSelectedTicket(ticket); + setDetailModalVisible(true); + }; + + const handleOpenChat = (ticket: CustomerServiceTicket) => { + setSelectedTicket(ticket); + setChatModalVisible(true); + fetchMessages(ticket.ticketId); + }; + + const handleSendMessage = async () => { + if (!messageInput.trim()) return; + + const newMessage: ChatMessage = { + id: Date.now().toString(), + ticketId: selectedTicket?.ticketId || '', + sender: 'AGENT', + senderName: 'Support Agent', + content: messageInput, + createdAt: new Date().toISOString(), + isRead: true, + }; + + setMessages([...messages, newMessage]); + setMessageInput(''); + message.success('Message sent'); + }; + + const handleQuickReply = (reply: string) => { + setMessageInput(reply); + }; + + const handleStatusChange = async (ticketId: string, newStatus: string) => { + setLoading(true); + try { + console.log('Updating ticket status:', { ticketId, newStatus }); + await new Promise((resolve) => setTimeout(resolve, 500)); + message.success('Status updated successfully'); + fetchTickets(); + } catch (error) { + message.error('Failed to update status'); + } finally { + setLoading(false); + } + }; + + const handleCreateTicket = async () => { + try { + const values = await form.validateFields(); + setLoading(true); + + console.log('Creating new ticket:', values); + + await new Promise((resolve) => setTimeout(resolve, 1000)); + + message.success('Ticket created successfully'); + setNewTicketModalVisible(false); + form.resetFields(); + fetchTickets(); + } catch (error) { + message.error('Failed to create ticket'); + } finally { + setLoading(false); + } + }; + + const getFilteredTickets = () => { + if (activeTab === 'all') return tickets; + return tickets.filter((t) => t.status === activeTab.toUpperCase()); + }; + + const columns: ColumnsType = [ + { + title: 'Ticket ID', + dataIndex: 'ticketId', + key: 'ticketId', + width: 130, + render: (text: string, record) => ( + handleViewDetail(record)}>{text} + ), + }, + { + title: 'Subject', + dataIndex: 'subject', + key: 'subject', + width: 200, + ellipsis: true, + }, + { + title: 'Customer', + dataIndex: 'customerName', + key: 'customerName', + width: 120, + }, + { + title: 'Platform', + dataIndex: 'platform', + key: 'platform', + width: 90, + render: (platform: string) => {platform}, + }, + { + title: 'Category', + dataIndex: 'category', + key: 'category', + width: 130, + render: (category: string) => ( + {CATEGORIES.find((c) => c.value === category)?.label || category} + ), + }, + { + title: 'Priority', + dataIndex: 'priority', + key: 'priority', + width: 90, + render: (priority: string) => { + const config = PRIORITY_MAP[priority]; + return {config.text}; + }, + }, + { + title: 'Status', + dataIndex: 'status', + key: 'status', + width: 130, + render: (status: string) => { + const config = TICKET_STATUS_MAP[status]; + return {config.text}; + }, + }, + { + title: 'Messages', + dataIndex: 'messageCount', + key: 'messageCount', + width: 80, + render: (count: number) => ( + + ), + }, + { + title: 'Updated', + dataIndex: 'updatedAt', + key: 'updatedAt', + width: 150, + }, + { + title: 'Actions', + key: 'action', + width: 150, + fixed: 'right', + render: (_, record) => ( + + + + + } + /> + + + + + } + /> + + + + + } + /> + + + + + } + /> + + + + + + + + + + + + + + } + > + + + + All + + + } + key="all" + /> + + + Open + + + } + key="open" + /> + + + + + + +
`Total ${total} tickets`, + }} + /> + + + setDetailModalVisible(false)} + footer={[ + , + , + ]} + width={700} + > + {selectedTicket && ( + + + {selectedTicket.ticketId} + + {selectedTicket.orderId} + {selectedTicket.platform} + + {selectedTicket.subject} + + + {CATEGORIES.find((c) => c.value === selectedTicket.category)?.label} + + + + {PRIORITY_MAP[selectedTicket.priority].text} + + + + + {TICKET_STATUS_MAP[selectedTicket.status].text} + + + + {selectedTicket.assignedTo || 'Unassigned'} + + + {selectedTicket.customerName} + + + {selectedTicket.customerEmail} + + + {selectedTicket.createdAt} + + + {selectedTicket.updatedAt} + + + {selectedTicket.lastMessage} + + + )} + + + + + Chat - {selectedTicket?.ticketId} + + } + open={chatModalVisible} + onCancel={() => setChatModalVisible(false)} + footer={null} + width={700} + > + {selectedTicket && ( + <> + + + + {selectedTicket.customerName} + + {selectedTicket.orderId} + + {selectedTicket.platform} + + + + +
+ ( + +
+ } + /> +
+
+ + {msg.senderName} - {msg.createdAt} + + {msg.content} +
+
+
+
+ )} + /> +
+
+ +
+ + Quick Replies: + +
+ {QUICK_REPLIES.map((reply, index) => ( + handleQuickReply(reply)} + > + {reply.substring(0, 30)}... + + ))} +
+
+ + +