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.on('error', err => console.error('Redis Client Error:', err));
|
||||||
this.redis.connect().catch(err => console.error('Redis connection 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({
|
this.apiClient = axios.create({
|
||||||
baseURL: 'https://api.typeform.com',
|
baseURL: 'https://api.typeform.com',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${process.env.TYPEFORM_ACCESS_TOKEN}`,
|
'Authorization': `Bearer ${token}`,
|
||||||
'Content-Type': 'application/json'
|
'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 = {}) {
|
async getFormResponses(formId, params = {}) {
|
||||||
@@ -61,8 +83,19 @@ class TypeformService {
|
|||||||
return JSON.parse(cachedData);
|
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
|
// Fetch from API
|
||||||
const response = await this.apiClient.get(`/insights/${formId}/summary`);
|
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;
|
const data = response.data;
|
||||||
|
|
||||||
// Save to Redis with 5 minute expiry
|
// Save to Redis with 5 minute expiry
|
||||||
@@ -74,7 +107,11 @@ class TypeformService {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error fetching form insights for ${formId}:`, {
|
console.error(`Error fetching form insights for ${formId}:`, {
|
||||||
error: error.message,
|
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;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import GorgiasOverview from "@/components/dashboard/GorgiasOverview";
|
|||||||
import AnalyticsDashboard from "@/components/dashboard/AnalyticsDashboard";
|
import AnalyticsDashboard from "@/components/dashboard/AnalyticsDashboard";
|
||||||
import RealtimeAnalytics from "@/components/dashboard/RealtimeAnalytics";
|
import RealtimeAnalytics from "@/components/dashboard/RealtimeAnalytics";
|
||||||
import UserBehaviorDashboard from "@/components/dashboard/UserBehaviorDashboard";
|
import UserBehaviorDashboard from "@/components/dashboard/UserBehaviorDashboard";
|
||||||
|
import TypeformDashboard from "@/components/dashboard/TypeformDashboard";
|
||||||
|
|
||||||
// Public layout
|
// Public layout
|
||||||
const PublicLayout = () => (
|
const PublicLayout = () => (
|
||||||
@@ -92,7 +93,9 @@ const DashboardLayout = () => {
|
|||||||
<Header />
|
<Header />
|
||||||
</div>
|
</div>
|
||||||
<Navigation />
|
<Navigation />
|
||||||
|
|
||||||
<div className="p-4 space-y-4">
|
<div className="p-4 space-y-4">
|
||||||
|
<TypeformDashboard />
|
||||||
<div className="grid grid-cols-1 xl:grid-cols-6 gap-4">
|
<div className="grid grid-cols-1 xl:grid-cols-6 gap-4">
|
||||||
<div className="xl:col-span-4 col-span-6">
|
<div className="xl:col-span-4 col-span-6">
|
||||||
<div className="space-y-4 h-full w-full">
|
<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