Condense and restyle frontend component
This commit is contained in:
@@ -7,7 +7,6 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -20,17 +19,8 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { AlertCircle, Activity, Eye, MessageSquare, ThumbsUp, Clock, Users, Star, ArrowUp, ArrowDown, BarChart3 } from "lucide-react";
|
import { AlertCircle } from "lucide-react";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import {
|
import {
|
||||||
BarChart,
|
BarChart,
|
||||||
Bar,
|
Bar,
|
||||||
@@ -40,9 +30,6 @@ import {
|
|||||||
Tooltip,
|
Tooltip,
|
||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
Cell,
|
Cell,
|
||||||
PieChart,
|
|
||||||
Pie,
|
|
||||||
Legend,
|
|
||||||
ReferenceLine
|
ReferenceLine
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
|
|
||||||
@@ -57,12 +44,6 @@ const FORM_NAMES = {
|
|||||||
[FORM_IDS.FORM_2]: 'Winback Survey',
|
[FORM_IDS.FORM_2]: 'Winback Survey',
|
||||||
};
|
};
|
||||||
|
|
||||||
const METRIC_COLORS = {
|
|
||||||
total: "#8b5cf6",
|
|
||||||
completed: "#10b981",
|
|
||||||
today: "#f59e0b",
|
|
||||||
};
|
|
||||||
|
|
||||||
// Loading skeleton components
|
// Loading skeleton components
|
||||||
const SkeletonChart = () => (
|
const SkeletonChart = () => (
|
||||||
<div className="h-[300px] w-full bg-card rounded-lg p-6">
|
<div className="h-[300px] w-full bg-card rounded-lg p-6">
|
||||||
@@ -102,9 +83,6 @@ const SkeletonTable = () => (
|
|||||||
<TableHead>
|
<TableHead>
|
||||||
<Skeleton className="h-4 w-[80px] bg-muted" />
|
<Skeleton className="h-4 w-[80px] bg-muted" />
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="text-right">
|
|
||||||
<Skeleton className="h-4 w-[60px] bg-muted ml-auto" />
|
|
||||||
</TableHead>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -117,10 +95,7 @@ const SkeletonTable = () => (
|
|||||||
<Skeleton className="h-4 w-[90px] bg-muted" />
|
<Skeleton className="h-4 w-[90px] bg-muted" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Skeleton className="h-6 w-[70px] bg-muted" />
|
<Skeleton className="h-4 w-[70px] bg-muted" />
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
<Skeleton className="h-4 w-[40px] bg-muted ml-auto" />
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
@@ -129,241 +104,6 @@ const SkeletonTable = () => (
|
|||||||
</div>
|
</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 }) => (
|
const ResponseFeed = ({ responses, title, renderSummary }) => (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
@@ -371,23 +111,31 @@ const ResponseFeed = ({ responses, title, renderSummary }) => (
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<ScrollArea className="h-[400px]">
|
<ScrollArea className="h-[400px]">
|
||||||
<div className="space-y-2">
|
<div className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||||
{responses.items.map((response) => (
|
{responses.items.map((response) => (
|
||||||
<Card key={response.token} className="p-3">
|
<div key={response.token} className="p-4 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors">
|
||||||
<div className="flex justify-between items-start gap-4">
|
<div className="space-y-1.5">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="flex items-center justify-between">
|
||||||
<div className="font-medium truncate">
|
<div className="flex items-center gap-2">
|
||||||
{response.hidden?.name || 'Anonymous'}
|
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
</div>
|
{response.hidden?.name || 'Anonymous'}
|
||||||
<div className="text-sm text-muted-foreground">
|
</span>
|
||||||
{format(new Date(response.submitted_at), "MMM d, yyyy HH:mm")}
|
{response.hidden?.email && (
|
||||||
</div>
|
<span className="text-sm text-muted-foreground">
|
||||||
<div className="mt-1">
|
({response.hidden.email})
|
||||||
{renderSummary(response)}
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<time
|
||||||
|
className="text-xs text-muted-foreground"
|
||||||
|
dateTime={response.submitted_at}
|
||||||
|
>
|
||||||
|
{format(new Date(response.submitted_at), "MMM d, h:mm a")}
|
||||||
|
</time>
|
||||||
</div>
|
</div>
|
||||||
|
{renderSummary(response)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
@@ -401,14 +149,20 @@ const ProductRelevanceFeed = ({ responses }) => (
|
|||||||
title="Product Relevance Responses"
|
title="Product Relevance Responses"
|
||||||
renderSummary={(response) => {
|
renderSummary={(response) => {
|
||||||
const answer = response.answers?.find(a => a.type === 'boolean');
|
const answer = response.answers?.find(a => a.type === 'boolean');
|
||||||
|
const textAnswer = response.answers?.find(a => a.type === 'text')?.text;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant={answer?.boolean ? "success" : "destructive"}>
|
<Badge
|
||||||
|
className={
|
||||||
|
answer?.boolean ? "bg-green-200 text-green-700" : "bg-red-200 text-red-700"
|
||||||
|
}
|
||||||
|
>
|
||||||
{answer?.boolean ? "Yes" : "No"}
|
{answer?.boolean ? "Yes" : "No"}
|
||||||
</Badge>
|
</Badge>
|
||||||
{response.answers?.find(a => a.type === 'text')?.text && (
|
{textAnswer && (
|
||||||
<span className="text-sm">
|
<span className="text-sm text-foreground">
|
||||||
"{response.answers.find(a => a.type === 'text').text}"
|
"{textAnswer}"
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -424,25 +178,39 @@ const WinbackFeed = ({ responses }) => (
|
|||||||
renderSummary={(response) => {
|
renderSummary={(response) => {
|
||||||
const likelihoodAnswer = response.answers?.find(a => a.type === 'number');
|
const likelihoodAnswer = response.answers?.find(a => a.type === 'number');
|
||||||
const reasonsAnswer = response.answers?.find(a => a.type === 'choices');
|
const reasonsAnswer = response.answers?.find(a => a.type === 'choices');
|
||||||
const feedbackAnswer = response.answers?.find(a => a.type === 'text');
|
const otherAnswer = response.answers?.find(a => a.type === 'text' && a.field.ref.includes('other'));
|
||||||
|
const feedbackAnswer = response.answers?.find(a => a.type === 'text' && !a.field.ref.includes('other'));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant={likelihoodAnswer?.number >= 4 ? "success" : "warning"}>
|
<Badge
|
||||||
Rating: {likelihoodAnswer?.number}/5
|
className={
|
||||||
|
likelihoodAnswer?.number === 1 ? "bg-red-200 text-red-700" :
|
||||||
|
likelihoodAnswer?.number === 2 ? "bg-orange-200 text-orange-700" :
|
||||||
|
likelihoodAnswer?.number === 3 ? "bg-yellow-200 text-yellow-700" :
|
||||||
|
likelihoodAnswer?.number === 4 ? "bg-lime-200 text-lime-700" :
|
||||||
|
likelihoodAnswer?.number === 5 ? "bg-green-200 text-green-700" :
|
||||||
|
"bg-gray-200 text-gray-700"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{likelihoodAnswer?.number}/5
|
||||||
</Badge>
|
</Badge>
|
||||||
|
{(reasonsAnswer?.choices?.labels || []).map((label, idx) => (
|
||||||
|
<Badge key={idx} variant="secondary" className="text-xs">
|
||||||
|
{label}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
{reasonsAnswer?.choices?.labels && (
|
{otherAnswer?.text && (
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
<span className="font-medium">Reasons:</span>{" "}
|
<span className="font-medium">Other:</span>{" "}
|
||||||
{reasonsAnswer.choices.labels.join(", ")}
|
{otherAnswer.text}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{feedbackAnswer?.text && (
|
{feedbackAnswer?.text && (
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
<span className="font-medium">Feedback:</span>{" "}
|
{feedbackAnswer.text}
|
||||||
"{feedbackAnswer.text}"
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -454,10 +222,14 @@ const WinbackFeed = ({ responses }) => (
|
|||||||
const renderProductRelevanceBar = (metrics) => (
|
const renderProductRelevanceBar = (metrics) => (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-lg font-semibold">Product Relevance Results</CardTitle>
|
<div className="flex items-baseline justify-between">
|
||||||
<CardDescription>
|
<CardTitle className="text-lg font-semibold">Were the suggested products in this email relevant to you?</CardTitle>
|
||||||
{metrics.productRelevance.yesPercentage}% Positive • {metrics.productRelevance.yesCount} Yes / {metrics.productRelevance.noCount} No
|
<div className="flex flex-col items-end">
|
||||||
</CardDescription>
|
<span className="text-2xl font-bold text-green-600">
|
||||||
|
{metrics.productRelevance.yesPercentage}% Positive
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="h-[100px]">
|
<div className="h-[100px]">
|
||||||
@@ -470,11 +242,12 @@ const renderProductRelevanceBar = (metrics) => (
|
|||||||
}]}
|
}]}
|
||||||
layout="vertical"
|
layout="vertical"
|
||||||
stackOffset="expand"
|
stackOffset="expand"
|
||||||
margin={{ top: 10, right: 0, left: -20, bottom: 0 }}
|
margin={{ top: 0, right: 0, left: -20, bottom: 0 }}
|
||||||
>
|
>
|
||||||
<XAxis type="number" hide domain={[0, 1]} />
|
<XAxis type="number" hide domain={[0, 1]} />
|
||||||
<YAxis type="category" hide />
|
<YAxis type="category" hide />
|
||||||
<Tooltip
|
<Tooltip
|
||||||
|
cursor={false}
|
||||||
content={({ payload }) => {
|
content={({ payload }) => {
|
||||||
if (payload && payload.length) {
|
if (payload && payload.length) {
|
||||||
const yesCount = payload[0].payload.yes;
|
const yesCount = payload[0].payload.yes;
|
||||||
@@ -483,38 +256,50 @@ const renderProductRelevanceBar = (metrics) => (
|
|||||||
const yesPercent = Math.round((yesCount / total) * 100);
|
const yesPercent = Math.round((yesCount / total) * 100);
|
||||||
const noPercent = Math.round((noCount / total) * 100);
|
const noPercent = Math.round((noCount / total) * 100);
|
||||||
return (
|
return (
|
||||||
<div className="bg-white dark:bg-gray-800 p-2 rounded-md shadow-md border">
|
<Card className="p-3 shadow-lg bg-white dark:bg-gray-800 border-none">
|
||||||
<div className="text-sm">
|
<CardContent className="p-0 space-y-2">
|
||||||
<div className="text-emerald-500">Yes: {yesCount} ({yesPercent}%)</div>
|
<div className="space-y-1">
|
||||||
<div className="text-red-500">No: {noCount} ({noPercent}%)</div>
|
<div className="flex justify-between items-center text-sm">
|
||||||
</div>
|
<span className="text-emerald-500 font-medium">Yes:</span>
|
||||||
</div>
|
<span className="ml-4">{yesCount} ({yesPercent}%)</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center text-sm">
|
||||||
|
<span className="text-red-500 font-medium">No:</span>
|
||||||
|
<span className="ml-4">{noCount} ({noPercent}%)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Bar dataKey="yes" stackId="stack" fill="#10b981" />
|
<Bar dataKey="yes" stackId="stack" fill="#10b981" radius={[4, 4, 0, 0]}>
|
||||||
<Bar dataKey="no" stackId="stack" fill="#ef4444" />
|
<text
|
||||||
<ReferenceLine
|
x="50%"
|
||||||
x={metrics.productRelevance.yesPercentage / 100}
|
y="50%"
|
||||||
stroke="#6b7280"
|
textAnchor="middle"
|
||||||
strokeDasharray="3 3"
|
fill="#fff"
|
||||||
label={{
|
fontSize={14}
|
||||||
value: `${metrics.productRelevance.yesPercentage}%`,
|
fontWeight="bold"
|
||||||
position: 'right',
|
>
|
||||||
fill: '#6b7280'
|
{metrics.productRelevance.yesPercentage}%
|
||||||
}}
|
</text>
|
||||||
/>
|
</Bar>
|
||||||
|
<Bar dataKey="no" stackId="stack" fill="#ef4444" radius={[0, 0, 4, 4]} />
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex justify-between mt-2 text-md font-semibold mx-1 text-muted-foreground">
|
||||||
|
<div>Yes: {metrics.productRelevance.yesCount}</div>
|
||||||
|
<div>No: {metrics.productRelevance.noCount}</div>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderLikelihoodChart = (metrics, responses) => {
|
const renderLikelihoodChart = (metrics, responses) => {
|
||||||
// Get likelihood distribution from responses
|
|
||||||
const likelihoodCounts = [1, 2, 3, 4, 5].map(rating => ({
|
const likelihoodCounts = [1, 2, 3, 4, 5].map(rating => ({
|
||||||
rating: rating.toString(),
|
rating: rating.toString(),
|
||||||
count: responses.items
|
count: responses.items
|
||||||
@@ -525,34 +310,46 @@ const renderLikelihoodChart = (metrics, responses) => {
|
|||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-lg font-semibold">Return Likelihood</CardTitle>
|
<div className="flex items-baseline justify-between">
|
||||||
<CardDescription>
|
<CardTitle className="text-lg font-semibold">How likely are you to place another order with us?</CardTitle>
|
||||||
Average Rating: {metrics.winback.averageRating}/5 • {responses.items.length} Total Responses
|
<span className={`text-2xl font-bold ${
|
||||||
</CardDescription>
|
metrics.winback.averageRating <= 1 ? "text-red-700" :
|
||||||
|
metrics.winback.averageRating <= 2 ? "text-orange-700" :
|
||||||
|
metrics.winback.averageRating <= 3 ? "text-yellow-700" :
|
||||||
|
metrics.winback.averageRating <= 4 ? "text-lime-700" :
|
||||||
|
"text-green-700"
|
||||||
|
}`}>
|
||||||
|
{metrics.winback.averageRating}
|
||||||
|
<span className="text-base font-normal text-muted-foreground">/5 avg</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="h-[200px]">
|
<div className="h-[200px]">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<BarChart
|
<BarChart
|
||||||
data={likelihoodCounts}
|
data={likelihoodCounts}
|
||||||
margin={{ top: 20, right: 20, left: -10, bottom: 20 }}
|
margin={{ top: 0, right: 0, left: -20, bottom: 0 }}
|
||||||
>
|
>
|
||||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="rating"
|
dataKey="rating"
|
||||||
label={{ value: 'Rating', position: 'bottom', offset: -10 }}
|
tickFormatter={(value) => {
|
||||||
/>
|
return value === "1" ? "Not at all likely" : value === "5" ? "Extremely likely" : "";
|
||||||
<YAxis
|
}}
|
||||||
label={{ value: 'Responses', angle: -90, position: 'insideLeft', offset: 10 }}
|
textAnchor="middle"
|
||||||
|
interval={0}
|
||||||
|
height={50}
|
||||||
/>
|
/>
|
||||||
|
<YAxis />
|
||||||
<Tooltip
|
<Tooltip
|
||||||
content={({ payload }) => {
|
content={({ payload }) => {
|
||||||
if (payload && payload.length) {
|
if (payload && payload.length) {
|
||||||
const { rating, count } = payload[0].payload;
|
const { rating, count } = payload[0].payload;
|
||||||
return (
|
return (
|
||||||
<div className="bg-white dark:bg-gray-800 p-2 rounded-md shadow-md border">
|
<div className="bg-white dark:bg-gray-800 p-2 rounded-md shadow-md border">
|
||||||
<div className="text-sm">
|
<div className="text-sm font-medium">
|
||||||
Rating {rating}: {count} responses
|
{rating} Rating: {count} responses
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -564,20 +361,16 @@ const renderLikelihoodChart = (metrics, responses) => {
|
|||||||
{likelihoodCounts.map((_, index) => (
|
{likelihoodCounts.map((_, index) => (
|
||||||
<Cell
|
<Cell
|
||||||
key={`cell-${index}`}
|
key={`cell-${index}`}
|
||||||
fill={index >= 3 ? "#10b981" : index === 2 ? "#f59e0b" : "#ef4444"}
|
fill={
|
||||||
|
index === 0 ? "#ef4444" : // red
|
||||||
|
index === 1 ? "#f97316" : // orange
|
||||||
|
index === 2 ? "#eab308" : // yellow
|
||||||
|
index === 3 ? "#84cc16" : // lime
|
||||||
|
"#10b981" // green
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Bar>
|
</Bar>
|
||||||
<ReferenceLine
|
|
||||||
x={metrics.winback.averageRating.toString()}
|
|
||||||
stroke="#6b7280"
|
|
||||||
strokeDasharray="3 3"
|
|
||||||
label={{
|
|
||||||
value: `Avg: ${metrics.winback.averageRating}`,
|
|
||||||
position: 'top',
|
|
||||||
fill: '#6b7280'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
@@ -586,88 +379,6 @@ const renderLikelihoodChart = (metrics, responses) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
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 TypeformDashboard = () => {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
@@ -677,7 +388,7 @@ const TypeformDashboard = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const fetchResponses = async (formId, before = null) => {
|
const fetchResponses = async (formId, before = null) => {
|
||||||
const params = { page_size: 1000 }; // Start with max page size
|
const params = { page_size: 1000 };
|
||||||
if (before) params.before = before;
|
if (before) params.before = before;
|
||||||
|
|
||||||
const response = await axios.get(`/api/typeform/forms/${formId}/responses`, { params });
|
const response = await axios.get(`/api/typeform/forms/${formId}/responses`, { params });
|
||||||
@@ -787,52 +498,56 @@ const TypeformDashboard = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<Card>
|
||||||
{loading ? (
|
<CardContent className="space-y-6">
|
||||||
<div className="space-y-6">
|
{loading ? (
|
||||||
<SkeletonChart />
|
<div className="space-y-6">
|
||||||
<SkeletonTable />
|
<SkeletonChart />
|
||||||
</div>
|
<SkeletonTable />
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="grid grid-cols-2 gap-6">
|
|
||||||
{renderProductRelevanceBar(metrics)}
|
|
||||||
{renderLikelihoodChart(metrics, formData.form2.responses)}
|
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mt-6">
|
||||||
|
{renderProductRelevanceBar(metrics)}
|
||||||
|
{renderLikelihoodChart(metrics, formData.form2.responses)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<Card>
|
<div className="grid grid-cols-2 xl:grid-cols-12 gap-6">
|
||||||
<CardHeader>
|
<div className="col-span-4">
|
||||||
<CardTitle className="text-lg font-semibold">Reasons for Not Ordering</CardTitle>
|
<Card>
|
||||||
</CardHeader>
|
<CardHeader>
|
||||||
<CardContent>
|
<CardTitle className="text-lg font-semibold">Reasons for Not Ordering</CardTitle>
|
||||||
<Table>
|
</CardHeader>
|
||||||
<TableHeader>
|
<CardContent>
|
||||||
<TableRow>
|
<Table>
|
||||||
<TableHead>Reason</TableHead>
|
<TableHeader>
|
||||||
<TableHead className="text-right">Count</TableHead>
|
<TableRow>
|
||||||
<TableHead className="text-right w-[80px]">%</TableHead>
|
<TableHead>Reason</TableHead>
|
||||||
</TableRow>
|
<TableHead className="text-right">Count</TableHead>
|
||||||
</TableHeader>
|
<TableHead className="text-right w-[80px]">%</TableHead>
|
||||||
<TableBody>
|
</TableRow>
|
||||||
{metrics.winback.reasons.map((reason, index) => (
|
</TableHeader>
|
||||||
<TableRow key={index}>
|
<TableBody>
|
||||||
<TableCell className="font-medium">{reason.reason}</TableCell>
|
{metrics.winback.reasons.map((reason, index) => (
|
||||||
<TableCell className="text-right">{reason.count}</TableCell>
|
<TableRow key={index}>
|
||||||
<TableCell className="text-right">{reason.percentage}%</TableCell>
|
<TableCell className="font-medium">{reason.reason}</TableCell>
|
||||||
</TableRow>
|
<TableCell className="text-right">{reason.count}</TableCell>
|
||||||
))}
|
<TableCell className="text-right">{reason.percentage}%</TableCell>
|
||||||
</TableBody>
|
</TableRow>
|
||||||
</Table>
|
))}
|
||||||
</CardContent>
|
</TableBody>
|
||||||
</Card>
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-4 lg:col-span-1 xl:col-span-4"><ProductRelevanceFeed responses={formData.form1.responses} /></div>
|
||||||
|
<div className="col-span-4 lg:col-span-1 xl:col-span-4"><WinbackFeed responses={formData.form2.responses} /></div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-6">
|
</div>
|
||||||
<ProductRelevanceFeed responses={formData.form1.responses} />
|
</>
|
||||||
<WinbackFeed responses={formData.form2.responses} />
|
)}
|
||||||
</div>
|
</CardContent>
|
||||||
</>
|
</Card>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user