Add typeform frontend component

This commit is contained in:
2024-12-29 22:27:35 -05:00
parent 8f99548ad0
commit e21d2d88d9
3 changed files with 881 additions and 2 deletions

View File

@@ -10,13 +10,35 @@ class TypeformService {
this.redis.on('error', err => console.error('Redis Client Error:', err));
this.redis.connect().catch(err => console.error('Redis connection error:', err));
const token = process.env.TYPEFORM_ACCESS_TOKEN;
console.log('Initializing Typeform client with token:', token ? `${token.slice(0, 10)}...` : 'missing');
this.apiClient = axios.create({
baseURL: 'https://api.typeform.com',
headers: {
'Authorization': `Bearer ${process.env.TYPEFORM_ACCESS_TOKEN}`,
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
// Test the token
this.testConnection();
}
async testConnection() {
try {
const response = await this.apiClient.get('/forms');
console.log('Typeform connection test successful:', {
status: response.status,
headers: response.headers,
});
} catch (error) {
console.error('Typeform connection test failed:', {
error: error.message,
response: error.response?.data,
status: error.response?.status,
});
}
}
async getFormResponses(formId, params = {}) {
@@ -61,8 +83,19 @@ class TypeformService {
return JSON.parse(cachedData);
}
// Log the request details
console.log(`Fetching insights for form ${formId}...`, {
url: `/insights/${formId}/summary`,
headers: this.apiClient.defaults.headers
});
// Fetch from API
const response = await this.apiClient.get(`/insights/${formId}/summary`);
console.log('Typeform insights response:', {
status: response.status,
headers: response.headers,
data: response.data
});
const data = response.data;
// Save to Redis with 5 minute expiry
@@ -74,7 +107,11 @@ class TypeformService {
} catch (error) {
console.error(`Error fetching form insights for ${formId}:`, {
error: error.message,
response: error.response?.data
response: error.response?.data,
status: error.response?.status,
headers: error.response?.headers,
requestUrl: `/insights/${formId}/summary`,
requestHeaders: this.apiClient.defaults.headers
});
throw error;
}

View File

@@ -26,6 +26,7 @@ import GorgiasOverview from "@/components/dashboard/GorgiasOverview";
import AnalyticsDashboard from "@/components/dashboard/AnalyticsDashboard";
import RealtimeAnalytics from "@/components/dashboard/RealtimeAnalytics";
import UserBehaviorDashboard from "@/components/dashboard/UserBehaviorDashboard";
import TypeformDashboard from "@/components/dashboard/TypeformDashboard";
// Public layout
const PublicLayout = () => (
@@ -92,7 +93,9 @@ const DashboardLayout = () => {
<Header />
</div>
<Navigation />
<div className="p-4 space-y-4">
<TypeformDashboard />
<div className="grid grid-cols-1 xl:grid-cols-6 gap-4">
<div className="xl:col-span-4 col-span-6">
<div className="space-y-4 h-full w-full">

View File

@@ -0,0 +1,839 @@
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Skeleton } from "@/components/ui/skeleton";
import { AlertCircle, Activity, Eye, MessageSquare, ThumbsUp, Clock, Users, Star, ArrowUp, ArrowDown, BarChart3 } from "lucide-react";
import { format } from "date-fns";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Cell,
PieChart,
Pie,
Legend,
ReferenceLine
} from "recharts";
// Get form IDs from environment variables
const FORM_IDS = {
FORM_1: import.meta.env.VITE_TYPEFORM_FORM_ID_1,
FORM_2: import.meta.env.VITE_TYPEFORM_FORM_ID_2,
};
const FORM_NAMES = {
[FORM_IDS.FORM_1]: 'Product Relevance',
[FORM_IDS.FORM_2]: 'Winback Survey',
};
const METRIC_COLORS = {
total: "#8b5cf6",
completed: "#10b981",
today: "#f59e0b",
};
// Loading skeleton components
const SkeletonChart = () => (
<div className="h-[300px] w-full bg-card rounded-lg p-6">
<div className="h-full relative">
{[...Array(5)].map((_, i) => (
<div
key={i}
className="absolute w-full h-px bg-muted"
style={{ top: `${(i + 1) * 20}%` }}
/>
))}
<div className="absolute left-0 top-0 bottom-0 w-8 flex flex-col justify-between py-4">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-3 w-6 bg-muted" />
))}
</div>
<div className="absolute left-8 right-4 bottom-0 flex justify-between">
{[...Array(3)].map((_, i) => (
<Skeleton key={i} className="h-3 w-16 bg-muted" />
))}
</div>
</div>
</div>
);
const SkeletonTable = () => (
<div className="space-y-2">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="w-[200px]">
<Skeleton className="h-4 w-[180px] bg-muted" />
</TableHead>
<TableHead>
<Skeleton className="h-4 w-[100px] bg-muted" />
</TableHead>
<TableHead>
<Skeleton className="h-4 w-[80px] bg-muted" />
</TableHead>
<TableHead className="text-right">
<Skeleton className="h-4 w-[60px] bg-muted ml-auto" />
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{[...Array(5)].map((_, i) => (
<TableRow key={i} className="hover:bg-transparent">
<TableCell>
<Skeleton className="h-4 w-[160px] bg-muted" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-[90px] bg-muted" />
</TableCell>
<TableCell>
<Skeleton className="h-6 w-[70px] bg-muted" />
</TableCell>
<TableCell className="text-right">
<Skeleton className="h-4 w-[40px] bg-muted ml-auto" />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
const SkeletonMetricCard = () => (
<Card className="h-full">
<CardContent className="pt-6 h-full">
<div className="flex justify-between items-start">
<div className="flex-1 min-w-0">
<Skeleton className="h-4 w-24 mb-4 bg-muted" />
<div className="flex items-baseline gap-2">
<Skeleton className="h-8 w-20 bg-muted" />
<Skeleton className="h-4 w-12 bg-muted" />
</div>
</div>
<Skeleton className="h-5 w-5 rounded-full bg-muted" />
</div>
</CardContent>
</Card>
);
const CustomTooltip = ({ active, payload }) => {
if (active && payload && payload.length) {
return (
<Card className="p-3 shadow-lg bg-white dark:bg-gray-800 border-none">
<CardContent className="p-0 space-y-1">
{payload.map((entry, index) => (
<div key={index} className="flex justify-between items-center text-sm">
<span style={{ color: entry.fill }}>{entry.name}:</span>
<span className="font-medium ml-4">{entry.value.toLocaleString()}</span>
</div>
))}
</CardContent>
</Card>
);
}
return null;
};
const ResponseDialog = ({ response }) => (
<Dialog>
<DialogTrigger asChild>
<Button variant="ghost" size="icon">
<Eye className="h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent className="max-w-3xl max-h-[80vh] overflow-hidden">
<DialogHeader>
<DialogTitle>Response Details</DialogTitle>
<DialogDescription>
From {response.hidden?.name || 'Anonymous'} ({response.hidden?.email || 'No email'})
<br />
Submitted at {format(new Date(response.submitted_at), "PPpp")}
</DialogDescription>
</DialogHeader>
<ScrollArea className="mt-4 h-full max-h-[60vh] rounded-md border p-4">
<div className="space-y-4">
{response.answers?.map((answer, index) => (
<div key={index} className="space-y-2">
<h4 className="font-medium">{answer.field.type.replace(/_/g, ' ').toUpperCase()}</h4>
{answer.type === 'boolean' && (
<p>{answer.boolean ? 'Yes' : 'No'}</p>
)}
{answer.type === 'number' && (
<p>Rating: {answer.number}/5</p>
)}
{answer.type === 'choices' && answer.choices?.labels && (
<p>{answer.choices.labels.join(', ')}</p>
)}
{answer.type === 'text' && (
<p className="text-sm text-muted-foreground">{answer.text}</p>
)}
</div>
))}
</div>
</ScrollArea>
</DialogContent>
</Dialog>
);
const getResponseSummary = (response) => {
if (!response.answers?.length) return 'No answers';
const firstAnswer = response.answers[0];
switch (firstAnswer.type) {
case 'boolean':
return firstAnswer.boolean ? 'Yes' : 'No';
case 'number':
return `Rating: ${firstAnswer.number}/5`;
case 'choices':
return firstAnswer.choices?.labels?.[0] || 'Multiple choice answer';
case 'text':
return firstAnswer.text ? (firstAnswer.text.slice(0, 50) + (firstAnswer.text.length > 50 ? '...' : '')) : 'Text answer';
default:
return `${firstAnswer.type} answer`;
}
};
const renderResponsesTable = (responses) => {
if (!responses?.items?.length) {
return (
<div className="flex items-center justify-center h-[400px] text-muted-foreground">
<div className="text-center">
<Activity className="h-12 w-12 mx-auto mb-4" />
<div className="font-medium mb-2">No responses yet</div>
<div className="text-sm text-muted-foreground">
Responses will appear here when they come in
</div>
</div>
</div>
);
}
return (
<Card className="mt-6">
<CardHeader>
<CardTitle className="text-lg font-semibold">Form Responses</CardTitle>
<CardDescription>
Latest form submissions and their status
</CardDescription>
</CardHeader>
<CardContent>
<ScrollArea className="h-[500px]">
<Table>
<TableHeader>
<TableRow>
<TableHead>Submitted At</TableHead>
<TableHead>Respondent</TableHead>
<TableHead>Response Summary</TableHead>
<TableHead>Platform</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{responses.items.map((response) => (
<TableRow key={response.token}>
<TableCell>
{format(new Date(response.submitted_at), "MMM d, yyyy HH:mm")}
</TableCell>
<TableCell>
<div className="flex flex-col">
<span className="font-medium">{response.hidden?.name || 'Anonymous'}</span>
<span className="text-xs text-muted-foreground">{response.hidden?.email || 'No email'}</span>
</div>
</TableCell>
<TableCell>
<span className="truncate block max-w-[200px]" title={getResponseSummary(response)}>
{getResponseSummary(response)}
</span>
</TableCell>
<TableCell>
<Badge variant="outline">
{response.metadata.platform}
</Badge>
</TableCell>
<TableCell className="text-right">
<ResponseDialog response={response} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</ScrollArea>
</CardContent>
</Card>
);
};
const calculateProductRelevanceInsights = (responses) => {
if (!responses?.items?.length) return null;
const yesResponses = responses.items.filter(r =>
r.answers?.some(a => a.type === 'boolean' && a.boolean === true)
).length;
const noResponses = responses.items.filter(r =>
r.answers?.some(a => a.type === 'boolean' && a.boolean === false)
).length;
const total = yesResponses + noResponses;
const yesPercentage = Math.round((yesResponses / total) * 100) || 0;
return {
metrics: [
{ name: 'Yes', value: yesResponses, color: "#10b981" },
{ name: 'No', value: noResponses, color: "#ef4444" },
],
summary: {
yes_percentage: yesPercentage,
total_responses: total
}
};
};
const calculateWinbackInsights = (responses) => {
if (!responses?.items?.length) return null;
// Get likelihood ratings
const likelihoodAnswers = responses.items
.map(r => r.answers?.find(a => a.type === 'number'))
.filter(Boolean)
.map(a => a.number);
const averageLikelihood = likelihoodAnswers.length
? Math.round((likelihoodAnswers.reduce((a, b) => a + b, 0) / likelihoodAnswers.length) * 10) / 10
: 0;
// Get reasons for not ordering
const reasonsMap = new Map();
responses.items.forEach(response => {
const reasonsAnswer = response.answers?.find(a => a.type === 'choices');
if (reasonsAnswer?.choices?.labels) {
reasonsAnswer.choices.labels.forEach(label => {
reasonsMap.set(label, (reasonsMap.get(label) || 0) + 1);
});
}
});
const sortedReasons = Array.from(reasonsMap.entries())
.sort(([, a], [, b]) => b - a)
.map(([label, count]) => ({
name: label,
value: count,
color: "#8b5cf6"
}));
return {
likelihood: {
ratings: [...Array(5)].map((_, i) => ({
name: (i + 1).toString(),
value: likelihoodAnswers.filter(r => r === i + 1).length,
color: "#8b5cf6"
})),
average: averageLikelihood
},
reasons: sortedReasons
};
};
const ResponseFeed = ({ responses, title, renderSummary }) => (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-lg font-semibold">{title}</CardTitle>
</CardHeader>
<CardContent>
<ScrollArea className="h-[400px]">
<div className="space-y-2">
{responses.items.map((response) => (
<Card key={response.token} className="p-3">
<div className="flex justify-between items-start gap-4">
<div className="min-w-0 flex-1">
<div className="font-medium truncate">
{response.hidden?.name || 'Anonymous'}
</div>
<div className="text-sm text-muted-foreground">
{format(new Date(response.submitted_at), "MMM d, yyyy HH:mm")}
</div>
<div className="mt-1">
{renderSummary(response)}
</div>
</div>
</div>
</Card>
))}
</div>
</ScrollArea>
</CardContent>
</Card>
);
const ProductRelevanceFeed = ({ responses }) => (
<ResponseFeed
responses={responses}
title="Product Relevance Responses"
renderSummary={(response) => {
const answer = response.answers?.find(a => a.type === 'boolean');
return (
<div className="flex items-center space-x-2">
<Badge variant={answer?.boolean ? "success" : "destructive"}>
{answer?.boolean ? "Yes" : "No"}
</Badge>
{response.answers?.find(a => a.type === 'text')?.text && (
<span className="text-sm">
"{response.answers.find(a => a.type === 'text').text}"
</span>
)}
</div>
);
}}
/>
);
const WinbackFeed = ({ responses }) => (
<ResponseFeed
responses={responses}
title="Winback Survey Responses"
renderSummary={(response) => {
const likelihoodAnswer = response.answers?.find(a => a.type === 'number');
const reasonsAnswer = response.answers?.find(a => a.type === 'choices');
const feedbackAnswer = response.answers?.find(a => a.type === 'text');
return (
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Badge variant={likelihoodAnswer?.number >= 4 ? "success" : "warning"}>
Rating: {likelihoodAnswer?.number}/5
</Badge>
</div>
{reasonsAnswer?.choices?.labels && (
<div className="text-sm">
<span className="font-medium">Reasons:</span>{" "}
{reasonsAnswer.choices.labels.join(", ")}
</div>
)}
{feedbackAnswer?.text && (
<div className="text-sm">
<span className="font-medium">Feedback:</span>{" "}
"{feedbackAnswer.text}"
</div>
)}
</div>
);
}}
/>
);
const renderProductRelevanceBar = (metrics) => (
<Card>
<CardHeader>
<CardTitle className="text-lg font-semibold">Product Relevance Results</CardTitle>
<CardDescription>
{metrics.productRelevance.yesPercentage}% Positive {metrics.productRelevance.yesCount} Yes / {metrics.productRelevance.noCount} No
</CardDescription>
</CardHeader>
<CardContent>
<div className="h-[100px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={[{
yes: metrics.productRelevance.yesCount,
no: metrics.productRelevance.noCount,
total: metrics.productRelevance.yesCount + metrics.productRelevance.noCount
}]}
layout="vertical"
stackOffset="expand"
margin={{ top: 10, right: 0, left: -20, bottom: 0 }}
>
<XAxis type="number" hide domain={[0, 1]} />
<YAxis type="category" hide />
<Tooltip
content={({ payload }) => {
if (payload && payload.length) {
const yesCount = payload[0].payload.yes;
const noCount = payload[0].payload.no;
const total = yesCount + noCount;
const yesPercent = Math.round((yesCount / total) * 100);
const noPercent = Math.round((noCount / total) * 100);
return (
<div className="bg-white dark:bg-gray-800 p-2 rounded-md shadow-md border">
<div className="text-sm">
<div className="text-emerald-500">Yes: {yesCount} ({yesPercent}%)</div>
<div className="text-red-500">No: {noCount} ({noPercent}%)</div>
</div>
</div>
);
}
return null;
}}
/>
<Bar dataKey="yes" stackId="stack" fill="#10b981" />
<Bar dataKey="no" stackId="stack" fill="#ef4444" />
<ReferenceLine
x={metrics.productRelevance.yesPercentage / 100}
stroke="#6b7280"
strokeDasharray="3 3"
label={{
value: `${metrics.productRelevance.yesPercentage}%`,
position: 'right',
fill: '#6b7280'
}}
/>
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
);
const renderLikelihoodChart = (metrics, responses) => {
// Get likelihood distribution from responses
const likelihoodCounts = [1, 2, 3, 4, 5].map(rating => ({
rating: rating.toString(),
count: responses.items
.filter(r => r.answers?.find(a => a.type === 'number')?.number === rating)
.length
}));
return (
<Card>
<CardHeader>
<CardTitle className="text-lg font-semibold">Return Likelihood</CardTitle>
<CardDescription>
Average Rating: {metrics.winback.averageRating}/5 {responses.items.length} Total Responses
</CardDescription>
</CardHeader>
<CardContent>
<div className="h-[200px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={likelihoodCounts}
margin={{ top: 20, right: 20, left: -10, bottom: 20 }}
>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="rating"
label={{ value: 'Rating', position: 'bottom', offset: -10 }}
/>
<YAxis
label={{ value: 'Responses', angle: -90, position: 'insideLeft', offset: 10 }}
/>
<Tooltip
content={({ payload }) => {
if (payload && payload.length) {
const { rating, count } = payload[0].payload;
return (
<div className="bg-white dark:bg-gray-800 p-2 rounded-md shadow-md border">
<div className="text-sm">
Rating {rating}: {count} responses
</div>
</div>
);
}
return null;
}}
/>
<Bar dataKey="count">
{likelihoodCounts.map((_, index) => (
<Cell
key={`cell-${index}`}
fill={index >= 3 ? "#10b981" : index === 2 ? "#f59e0b" : "#ef4444"}
/>
))}
</Bar>
<ReferenceLine
x={metrics.winback.averageRating.toString()}
stroke="#6b7280"
strokeDasharray="3 3"
label={{
value: `Avg: ${metrics.winback.averageRating}`,
position: 'top',
fill: '#6b7280'
}}
/>
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
);
};
const MetricCard = ({
title,
value,
delta,
suffix = "",
icon: Icon,
colorClass = "blue",
more_is_better = true,
loading = false,
}) => {
const getDeltaColor = (d) => {
if (d === 0) return "text-gray-600 dark:text-gray-400";
const isPositive = d > 0;
return isPositive === more_is_better
? "text-green-600 dark:text-green-500"
: "text-red-600 dark:text-red-500";
};
const formatDelta = (d) => {
if (d === undefined || d === null) return null;
if (d === 0) return "0";
return Math.abs(d) + suffix;
};
return (
<Card className="h-full">
<CardContent className="pt-6 h-full">
<div className="flex justify-between items-start">
<div className="flex-1 min-w-0">
{loading ? (
<>
<Skeleton className="h-4 w-24 mb-4 bg-muted" />
<div className="flex items-baseline gap-2">
<Skeleton className="h-8 w-20 bg-muted" />
<Skeleton className="h-4 w-12 bg-muted" />
</div>
</>
) : (
<>
<p className="text-sm font-medium text-muted-foreground">{title}</p>
<div className="flex items-baseline gap-2">
<p className="text-2xl font-bold">
{typeof value === "number"
? value.toLocaleString() + suffix
: value}
</p>
{delta !== undefined && delta !== 0 && (
<div className={`flex items-center ${getDeltaColor(delta)}`}>
{delta > 0 ? (
<ArrowUp className="w-3 h-3" />
) : (
<ArrowDown className="w-3 h-3" />
)}
<span className="text-xs font-medium">
{formatDelta(delta)}
</span>
</div>
)}
</div>
</>
)}
</div>
{!loading && Icon && (
<Icon className={`h-5 w-5 flex-shrink-0 ml-2 ${
colorClass === "blue" ? "text-blue-500" :
colorClass === "green" ? "text-green-500" :
colorClass === "purple" ? "text-purple-500" :
colorClass === "indigo" ? "text-indigo-500" :
colorClass === "orange" ? "text-orange-500" :
"text-blue-500"
}`} />
)}
{loading && (
<Skeleton className="h-5 w-5 rounded-full bg-muted" />
)}
</div>
</CardContent>
</Card>
);
};
// Update the TypeformDashboard component to use pagination
const TypeformDashboard = () => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [formData, setFormData] = useState({
form1: { responses: null, hasMore: false, lastToken: null },
form2: { responses: null, hasMore: false, lastToken: null }
});
const fetchResponses = async (formId, before = null) => {
const params = { page_size: 1000 }; // Start with max page size
if (before) params.before = before;
const response = await axios.get(`/api/typeform/forms/${formId}/responses`, { params });
return response.data;
};
useEffect(() => {
const fetchFormData = async () => {
try {
setLoading(true);
setError(null);
const forms = [FORM_IDS.FORM_1, FORM_IDS.FORM_2];
const results = await Promise.all(
forms.map(async (formId) => {
const responses = await fetchResponses(formId);
const hasMore = responses.items.length === 1000;
const lastToken = hasMore ? responses.items[responses.items.length - 1].token : null;
return {
responses,
hasMore,
lastToken
};
})
);
setFormData({
form1: results[0],
form2: results[1]
});
} catch (err) {
console.error('Error fetching Typeform data:', err);
setError('Failed to load form data. Please try again later.');
} finally {
setLoading(false);
}
};
fetchFormData();
}, []);
const calculateMetrics = () => {
if (!formData.form1.responses || !formData.form2.responses) return null;
const form1Responses = formData.form1.responses.items;
const form2Responses = formData.form2.responses.items;
// Product Relevance metrics
const yesResponses = form1Responses.filter(r =>
r.answers?.some(a => a.type === 'boolean' && a.boolean === true)
).length;
const totalForm1 = form1Responses.length;
const yesPercentage = Math.round((yesResponses / totalForm1) * 100) || 0;
// Winback Survey metrics
const likelihoodAnswers = form2Responses
.map(r => r.answers?.find(a => a.type === 'number'))
.filter(Boolean)
.map(a => a.number);
const averageLikelihood = likelihoodAnswers.length
? Math.round((likelihoodAnswers.reduce((a, b) => a + b, 0) / likelihoodAnswers.length) * 10) / 10
: 0;
// Get reasons for not ordering
const reasonsMap = new Map();
form2Responses.forEach(response => {
const reasonsAnswer = response.answers?.find(a => a.type === 'choices');
if (reasonsAnswer?.choices?.labels) {
reasonsAnswer.choices.labels.forEach(label => {
reasonsMap.set(label, (reasonsMap.get(label) || 0) + 1);
});
}
});
const sortedReasons = Array.from(reasonsMap.entries())
.sort(([, a], [, b]) => b - a)
.map(([label, count]) => ({
reason: label,
count,
percentage: Math.round((count / form2Responses.length) * 100)
}));
return {
productRelevance: {
yesPercentage,
yesCount: yesResponses,
noCount: totalForm1 - yesResponses
},
winback: {
averageRating: averageLikelihood,
reasons: sortedReasons
}
};
};
const metrics = loading ? null : calculateMetrics();
if (error) {
return (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
);
}
return (
<div className="space-y-6">
{loading ? (
<div className="space-y-6">
<SkeletonChart />
<SkeletonTable />
</div>
) : (
<>
<div className="grid grid-cols-2 gap-6">
{renderProductRelevanceBar(metrics)}
{renderLikelihoodChart(metrics, formData.form2.responses)}
</div>
<Card>
<CardHeader>
<CardTitle className="text-lg font-semibold">Reasons for Not Ordering</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Reason</TableHead>
<TableHead className="text-right">Count</TableHead>
<TableHead className="text-right w-[80px]">%</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{metrics.winback.reasons.map((reason, index) => (
<TableRow key={index}>
<TableCell className="font-medium">{reason.reason}</TableCell>
<TableCell className="text-right">{reason.count}</TableCell>
<TableCell className="text-right">{reason.percentage}%</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
<div className="grid grid-cols-2 gap-6">
<ProductRelevanceFeed responses={formData.form1.responses} />
<WinbackFeed responses={formData.form2.responses} />
</div>
</>
)}
</div>
);
};
export default TypeformDashboard;