Add typeform frontend component
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
839
dashboard/src/components/dashboard/TypeformDashboard.jsx
Normal file
839
dashboard/src/components/dashboard/TypeformDashboard.jsx
Normal 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;
|
||||
Reference in New Issue
Block a user