From 46084b71a65cd1223667b10ade81c3330dd6251f Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Thu, 12 Mar 2026 16:54:38 +0100 Subject: [PATCH] feat: add frustration table component with view-all modal --- components/behavior/FrustrationTable.tsx | 177 +++++++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 components/behavior/FrustrationTable.tsx diff --git a/components/behavior/FrustrationTable.tsx b/components/behavior/FrustrationTable.tsx new file mode 100644 index 0000000..13e1276 --- /dev/null +++ b/components/behavior/FrustrationTable.tsx @@ -0,0 +1,177 @@ +'use client' + +import { useState, useEffect } from 'react' +import { formatNumber, Modal } from '@ciphera-net/ui' +import { FrameCornersIcon } from '@phosphor-icons/react' +import type { FrustrationElement } from '@/lib/api/stats' +import { ListSkeleton } from '@/components/skeletons' + +interface FrustrationTableProps { + title: string + description: string + items: FrustrationElement[] + total: number + showAvgClicks?: boolean + loading: boolean + fetchAll?: () => Promise<{ items: FrustrationElement[]; total: number }> +} + +function SkeletonRows() { + return ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+
+
+
+
+
+
+ ))} +
+ ) +} + +function Row({ + item, + showAvgClicks, +}: { + item: FrustrationElement + showAvgClicks?: boolean +}) { + return ( +
+
+ + {item.selector} + + + {item.page_path} + +
+
+ {showAvgClicks && item.avg_click_count != null && ( + + avg {item.avg_click_count.toFixed(1)} + + )} + + {item.sessions} {item.sessions === 1 ? 'session' : 'sessions'} + + + {formatNumber(item.count)} + +
+
+ ) +} + +export default function FrustrationTable({ + title, + description, + items, + total, + showAvgClicks, + loading, + fetchAll, +}: FrustrationTableProps) { + const [isModalOpen, setIsModalOpen] = useState(false) + const [fullData, setFullData] = useState([]) + const [isLoadingFull, setIsLoadingFull] = useState(false) + const hasData = items.length > 0 + const showViewAll = hasData && total > items.length + + useEffect(() => { + if (isModalOpen && fetchAll) { + const load = async () => { + setIsLoadingFull(true) + try { + const result = await fetchAll() + setFullData(result.items) + } catch { + // silent + } finally { + setIsLoadingFull(false) + } + } + load() + } else { + setFullData([]) + } + }, [isModalOpen, fetchAll]) + + return ( + <> +
+
+
+

+ {title} +

+ {showViewAll && ( + + )} +
+
+

+ {description} +

+ +
+ {loading ? ( + + ) : hasData ? ( +
+ {items.map((item, i) => ( + + ))} +
+ ) : ( +
+

+ No {title.toLowerCase()} detected in this period +

+
+ )} +
+
+ + setIsModalOpen(false)} + title={title} + className="max-w-2xl" + > +
+ {isLoadingFull ? ( +
+ +
+ ) : fullData.length > 0 ? ( +
+ {fullData.map((item, i) => ( + + ))} +
+ ) : ( +

+ No data available +

+ )} +
+
+ + ) +}