diff --git a/dashboard/src/components/dashboard/TypeformDashboard.jsx b/dashboard/src/components/dashboard/TypeformDashboard.jsx
new file mode 100644
index 0000000..c2a07a4
--- /dev/null
+++ b/dashboard/src/components/dashboard/TypeformDashboard.jsx
@@ -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 = () => (
+
+
+ {[...Array(5)].map((_, i) => (
+
+ ))}
+
+ {[...Array(5)].map((_, i) => (
+
+ ))}
+
+
+ {[...Array(3)].map((_, i) => (
+
+ ))}
+
+
+
+);
+
+const SkeletonTable = () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {[...Array(5)].map((_, i) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+
+);
+
+const SkeletonMetricCard = () => (
+
+
+
+
+
+);
+
+const CustomTooltip = ({ active, payload }) => {
+ if (active && payload && payload.length) {
+ return (
+
+
+ {payload.map((entry, index) => (
+
+ {entry.name}:
+ {entry.value.toLocaleString()}
+
+ ))}
+
+
+ );
+ }
+ return null;
+};
+
+const ResponseDialog = ({ response }) => (
+
+);
+
+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 (
+
+
+
+
No responses yet
+
+ Responses will appear here when they come in
+
+
+
+ );
+ }
+
+ return (
+
+
+ Form Responses
+
+ Latest form submissions and their status
+
+
+
+
+
+
+
+ Submitted At
+ Respondent
+ Response Summary
+ Platform
+ Actions
+
+
+
+ {responses.items.map((response) => (
+
+
+ {format(new Date(response.submitted_at), "MMM d, yyyy HH:mm")}
+
+
+
+ {response.hidden?.name || 'Anonymous'}
+ {response.hidden?.email || 'No email'}
+
+
+
+
+ {getResponseSummary(response)}
+
+
+
+
+ {response.metadata.platform}
+
+
+
+
+
+
+ ))}
+
+
+
+
+
+ );
+};
+
+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 }) => (
+
+
+ {title}
+
+
+
+
+ {responses.items.map((response) => (
+
+
+
+
+ {response.hidden?.name || 'Anonymous'}
+
+
+ {format(new Date(response.submitted_at), "MMM d, yyyy HH:mm")}
+
+
+ {renderSummary(response)}
+
+
+
+
+ ))}
+
+
+
+
+);
+
+const ProductRelevanceFeed = ({ responses }) => (
+
{
+ const answer = response.answers?.find(a => a.type === 'boolean');
+ return (
+
+
+ {answer?.boolean ? "Yes" : "No"}
+
+ {response.answers?.find(a => a.type === 'text')?.text && (
+
+ "{response.answers.find(a => a.type === 'text').text}"
+
+ )}
+
+ );
+ }}
+ />
+);
+
+const WinbackFeed = ({ responses }) => (
+ {
+ 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 (
+
+
+ = 4 ? "success" : "warning"}>
+ Rating: {likelihoodAnswer?.number}/5
+
+
+ {reasonsAnswer?.choices?.labels && (
+
+ Reasons:{" "}
+ {reasonsAnswer.choices.labels.join(", ")}
+
+ )}
+ {feedbackAnswer?.text && (
+
+ Feedback:{" "}
+ "{feedbackAnswer.text}"
+
+ )}
+
+ );
+ }}
+ />
+);
+
+const renderProductRelevanceBar = (metrics) => (
+
+
+ Product Relevance Results
+
+ {metrics.productRelevance.yesPercentage}% Positive • {metrics.productRelevance.yesCount} Yes / {metrics.productRelevance.noCount} No
+
+
+
+
+
+
+
+
+ {
+ 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 (
+
+
+
Yes: {yesCount} ({yesPercent}%)
+
No: {noCount} ({noPercent}%)
+
+
+ );
+ }
+ return null;
+ }}
+ />
+
+
+
+
+
+
+
+
+);
+
+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 (
+
+
+ Return Likelihood
+
+ Average Rating: {metrics.winback.averageRating}/5 • {responses.items.length} Total Responses
+
+
+
+
+
+
+
+
+
+ {
+ if (payload && payload.length) {
+ const { rating, count } = payload[0].payload;
+ return (
+
+
+ Rating {rating}: {count} responses
+
+
+ );
+ }
+ return null;
+ }}
+ />
+
+ {likelihoodCounts.map((_, index) => (
+ = 3 ? "#10b981" : index === 2 ? "#f59e0b" : "#ef4444"}
+ />
+ ))}
+ |
+
+
+
+
+
+
+ );
+};
+
+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 (
+
+
+
+
+ {loading ? (
+ <>
+
+
+
+
+
+ >
+ ) : (
+ <>
+
{title}
+
+
+ {typeof value === "number"
+ ? value.toLocaleString() + suffix
+ : value}
+
+ {delta !== undefined && delta !== 0 && (
+
+ {delta > 0 ? (
+
+ ) : (
+
+ )}
+
+ {formatDelta(delta)}
+
+
+ )}
+
+ >
+ )}
+
+ {!loading && Icon && (
+
+ )}
+ {loading && (
+
+ )}
+
+
+
+ );
+};
+
+// 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 (
+
+
+ Error
+ {error}
+
+ );
+ }
+
+ return (
+
+ {loading ? (
+
+
+
+
+ ) : (
+ <>
+
+ {renderProductRelevanceBar(metrics)}
+ {renderLikelihoodChart(metrics, formData.form2.responses)}
+
+
+
+
+ Reasons for Not Ordering
+
+
+
+
+
+ Reason
+ Count
+ %
+
+
+
+ {metrics.winback.reasons.map((reason, index) => (
+
+ {reason.reason}
+ {reason.count}
+ {reason.percentage}%
+
+ ))}
+
+
+
+
+
+
+ >
+ )}
+
+ );
+};
+
+export default TypeformDashboard;
\ No newline at end of file