diff --git a/dashboard-server/typeform-server/services/typeform.service.js b/dashboard-server/typeform-server/services/typeform.service.js index 7eaa462..e6fc67e 100644 --- a/dashboard-server/typeform-server/services/typeform.service.js +++ b/dashboard-server/typeform-server/services/typeform.service.js @@ -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; } diff --git a/dashboard/src/App.jsx b/dashboard/src/App.jsx index 5b6b95f..3fb256e 100644 --- a/dashboard/src/App.jsx +++ b/dashboard/src/App.jsx @@ -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 = () => {
+
+
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 }) => ( + + + + + + + Response Details + + From {response.hidden?.name || 'Anonymous'} ({response.hidden?.email || 'No email'}) +
+ Submitted at {format(new Date(response.submitted_at), "PPpp")} +
+
+ +
+ {response.answers?.map((answer, index) => ( +
+

{answer.field.type.replace(/_/g, ' ').toUpperCase()}

+ {answer.type === 'boolean' && ( +

{answer.boolean ? 'Yes' : 'No'}

+ )} + {answer.type === 'number' && ( +

Rating: {answer.number}/5

+ )} + {answer.type === 'choices' && answer.choices?.labels && ( +

{answer.choices.labels.join(', ')}

+ )} + {answer.type === 'text' && ( +

{answer.text}

+ )} +
+ ))} +
+
+
+
+); + +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