From 814fefc065375e59a63e725de5741c698efd1bf7 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 2 Jun 2026 12:52:54 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20PWA=20&=20mobile=20UI/UX=20improvements?= =?UTF-8?q?=20=E2=80=94=2012=20enhancements=20across=203=20tiers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tier 1 (Critical): - Mobile bottom navigation bar (5-tab persistent bar replaces sidebar drawer) - PWA install prompt (smart banner with beforeinstallprompt detection) - Offline status indicator (toast on connectivity drop/restore) - Responsive layout fixes (mobile-first padding, 2-col grid on mobile) Tier 2 (Polish): - Pull-to-refresh gesture (touch-based with spring animation) - Loading skeleton components (CardSkeleton, TableSkeleton, DashboardSkeleton, etc) - Page transition animations (subtle fade/slide between routes) - Dark mode toggle in header (uses existing ThemeProvider, now switchable) Tier 3 (Premium): - Touch-optimized 44px minimum hit targets on mobile (Apple HIG) - Enhanced header with breadcrumb trail (group > page) - Floating action button (quick actions: File Claim, Get Quote, Emergency) - Haptic feedback on mobile interactions (navigator.vibrate) Additional: - PWA manifest: share_target, launch_handler, 2 new shortcuts - CSS: safe-area insets, custom scrollbars, overscroll containment - Viewport: viewport-fit=cover for notched devices - Dark mode support added to Dashboard page Co-Authored-By: Patrick Munis --- customer-portal-full/client/index.html | 2 +- .../client/public/manifest.json | 29 ++++- customer-portal-full/client/src/App.tsx | 2 +- .../src/components/FloatingActionButton.tsx | 70 +++++++++++ .../client/src/components/MobileBottomNav.tsx | 91 ++++++++++++++ .../src/components/OfflineIndicator.tsx | 57 +++++++++ .../src/components/PWAInstallPrompt.tsx | 105 ++++++++++++++++ .../client/src/components/PageSkeleton.tsx | 104 +++++++++++++++ .../client/src/components/PullToRefresh.tsx | 103 +++++++++++++++ .../client/src/components/UnifiedLayout.tsx | 63 ++++++++-- customer-portal-full/client/src/index.css | 119 +++++++++++++++--- .../client/src/pages/Dashboard.tsx | 82 ++++++------ 12 files changed, 761 insertions(+), 66 deletions(-) create mode 100644 customer-portal-full/client/src/components/FloatingActionButton.tsx create mode 100644 customer-portal-full/client/src/components/MobileBottomNav.tsx create mode 100644 customer-portal-full/client/src/components/OfflineIndicator.tsx create mode 100644 customer-portal-full/client/src/components/PWAInstallPrompt.tsx create mode 100644 customer-portal-full/client/src/components/PageSkeleton.tsx create mode 100644 customer-portal-full/client/src/components/PullToRefresh.tsx diff --git a/customer-portal-full/client/index.html b/customer-portal-full/client/index.html index 350f76c79e..a2a152b050 100644 --- a/customer-portal-full/client/index.html +++ b/customer-portal-full/client/index.html @@ -3,7 +3,7 @@ - + Unified Insurance Platform diff --git a/customer-portal-full/client/public/manifest.json b/customer-portal-full/client/public/manifest.json index a13e1d3879..32a9d6c1b8 100644 --- a/customer-portal-full/client/public/manifest.json +++ b/customer-portal-full/client/public/manifest.json @@ -80,6 +80,33 @@ "description": "View your policies", "url": "/policies", "icons": [{ "src": "/icons/icon-96x96.png", "sizes": "96x96" }] + }, + { + "name": "Emergency SOS", + "short_name": "SOS", + "description": "Emergency assistance", + "url": "/emergency-sos", + "icons": [{ "src": "/icons/icon-96x96.png", "sizes": "96x96" }] + }, + { + "name": "Payments", + "short_name": "Pay", + "description": "Manage payments", + "url": "/payments", + "icons": [{ "src": "/icons/icon-96x96.png", "sizes": "96x96" }] + } + ], + "share_target": { + "action": "/claims", + "method": "GET", + "params": { + "title": "title", + "text": "text", + "url": "url" } - ] + }, + "handle_links": "preferred", + "launch_handler": { + "client_mode": "navigate-existing" + } } diff --git a/customer-portal-full/client/src/App.tsx b/customer-portal-full/client/src/App.tsx index ec72f45575..a0cad34d0f 100644 --- a/customer-portal-full/client/src/App.tsx +++ b/customer-portal-full/client/src/App.tsx @@ -716,7 +716,7 @@ function Router() { function App() { return ( - + diff --git a/customer-portal-full/client/src/components/FloatingActionButton.tsx b/customer-portal-full/client/src/components/FloatingActionButton.tsx new file mode 100644 index 0000000000..0e78057c79 --- /dev/null +++ b/customer-portal-full/client/src/components/FloatingActionButton.tsx @@ -0,0 +1,70 @@ +import { useState, useCallback } from "react"; +import { useLocation } from "wouter"; +import { Plus, X, FileText, Shield, Phone } from "lucide-react"; +import { useIsMobile } from "@/hooks/useMobile"; +import { cn } from "@/lib/utils"; + +const QUICK_ACTIONS = [ + { label: "File Claim", icon: FileText, path: "/claims", color: "bg-blue-600" }, + { label: "Get Quote", icon: Shield, path: "/insurance-marketplace", color: "bg-green-600" }, + { label: "Emergency", icon: Phone, path: "/emergency-sos", color: "bg-red-600" }, +] as const; + +export default function FloatingActionButton() { + const isMobile = useIsMobile(); + const [, setLocation] = useLocation(); + const [expanded, setExpanded] = useState(false); + + const handleAction = useCallback( + (path: string) => { + setLocation(path); + setExpanded(false); + if (navigator.vibrate) navigator.vibrate(10); + }, + [setLocation] + ); + + if (!isMobile) return null; + + return ( +
+ {expanded && + QUICK_ACTIONS.map((action, i) => ( + + ))} + + +
+ ); +} diff --git a/customer-portal-full/client/src/components/MobileBottomNav.tsx b/customer-portal-full/client/src/components/MobileBottomNav.tsx new file mode 100644 index 0000000000..ef2b6faf31 --- /dev/null +++ b/customer-portal-full/client/src/components/MobileBottomNav.tsx @@ -0,0 +1,91 @@ +import { useLocation } from "wouter"; +import { + LayoutDashboard, + Shield, + FileText, + CreditCard, + MoreHorizontal, +} from "lucide-react"; +import { useIsMobile } from "@/hooks/useMobile"; +import { cn } from "@/lib/utils"; + +const NAV_ITEMS = [ + { label: "Home", icon: LayoutDashboard, path: "/dashboard" }, + { label: "Policies", icon: Shield, path: "/policies" }, + { label: "Claims", icon: FileText, path: "/claims" }, + { label: "Payments", icon: CreditCard, path: "/payments" }, + { label: "More", icon: MoreHorizontal, path: "__more__" }, +] as const; + +interface MobileBottomNavProps { + onMorePress: () => void; +} + +export default function MobileBottomNav({ onMorePress }: MobileBottomNavProps) { + const isMobile = useIsMobile(); + const [location, setLocation] = useLocation(); + + if (!isMobile) return null; + + return ( + + ); +} diff --git a/customer-portal-full/client/src/components/OfflineIndicator.tsx b/customer-portal-full/client/src/components/OfflineIndicator.tsx new file mode 100644 index 0000000000..5a38f8150e --- /dev/null +++ b/customer-portal-full/client/src/components/OfflineIndicator.tsx @@ -0,0 +1,57 @@ +import { useState, useEffect } from "react"; +import { WifiOff, Wifi } from "lucide-react"; +import { cn } from "@/lib/utils"; + +export default function OfflineIndicator() { + const [isOnline, setIsOnline] = useState(navigator.onLine); + const [showReconnected, setShowReconnected] = useState(false); + const [wasOffline, setWasOffline] = useState(false); + + useEffect(() => { + const goOnline = () => { + setIsOnline(true); + if (wasOffline) { + setShowReconnected(true); + setTimeout(() => setShowReconnected(false), 3000); + } + }; + const goOffline = () => { + setIsOnline(false); + setWasOffline(true); + }; + + window.addEventListener("online", goOnline); + window.addEventListener("offline", goOffline); + return () => { + window.removeEventListener("online", goOnline); + window.removeEventListener("offline", goOffline); + }; + }, [wasOffline]); + + if (isOnline && !showReconnected) return null; + + return ( +
+ {!isOnline ? ( + <> + + You are offline. Some features may be unavailable. + + ) : ( + <> + + Back online + + )} +
+ ); +} diff --git a/customer-portal-full/client/src/components/PWAInstallPrompt.tsx b/customer-portal-full/client/src/components/PWAInstallPrompt.tsx new file mode 100644 index 0000000000..170d8509ba --- /dev/null +++ b/customer-portal-full/client/src/components/PWAInstallPrompt.tsx @@ -0,0 +1,105 @@ +import { useState, useEffect, useCallback } from "react"; +import { Download, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +const DISMISS_KEY = "pwa_install_dismissed"; +const DISMISS_DURATION_MS = 7 * 24 * 60 * 60 * 1000; + +interface BeforeInstallPromptEvent extends Event { + prompt: () => Promise; + userChoice: Promise<{ outcome: "accepted" | "dismissed" }>; +} + +export default function PWAInstallPrompt() { + const [deferredPrompt, setDeferredPrompt] = + useState(null); + const [visible, setVisible] = useState(false); + const [installing, setInstalling] = useState(false); + + useEffect(() => { + const dismissed = localStorage.getItem(DISMISS_KEY); + if (dismissed && Date.now() - parseInt(dismissed, 10) < DISMISS_DURATION_MS) { + return; + } + + const handler = (e: Event) => { + e.preventDefault(); + setDeferredPrompt(e as BeforeInstallPromptEvent); + setTimeout(() => setVisible(true), 3000); + }; + + window.addEventListener("beforeinstallprompt", handler); + return () => window.removeEventListener("beforeinstallprompt", handler); + }, []); + + const handleInstall = useCallback(async () => { + if (!deferredPrompt) return; + setInstalling(true); + try { + await deferredPrompt.prompt(); + const { outcome } = await deferredPrompt.userChoice; + if (outcome === "accepted") { + setVisible(false); + } + } finally { + setInstalling(false); + setDeferredPrompt(null); + } + }, [deferredPrompt]); + + const handleDismiss = useCallback(() => { + setVisible(false); + localStorage.setItem(DISMISS_KEY, String(Date.now())); + }, []); + + if (!visible) return null; + + return ( +
+ +
+
+ +
+
+

Install InsurePortal

+

+ Add to your home screen for faster access and offline support. +

+
+ + +
+
+
+
+ ); +} diff --git a/customer-portal-full/client/src/components/PageSkeleton.tsx b/customer-portal-full/client/src/components/PageSkeleton.tsx new file mode 100644 index 0000000000..795dd4e564 --- /dev/null +++ b/customer-portal-full/client/src/components/PageSkeleton.tsx @@ -0,0 +1,104 @@ +import { cn } from "@/lib/utils"; + +function Shimmer({ className }: { className?: string }) { + return ( +
+ ); +} + +export function CardSkeleton() { + return ( +
+ + + +
+ ); +} + +export function TableSkeleton({ rows = 5 }: { rows?: number }) { + return ( +
+
+ + + + +
+ {Array.from({ length: rows }).map((_, i) => ( +
+ + + + +
+ ))} +
+ ); +} + +export function DashboardSkeleton() { + return ( +
+
+ + +
+
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+
+
+ + +
+ +
+
+ ); +} + +export function ListSkeleton({ items = 6 }: { items?: number }) { + return ( +
+ {Array.from({ length: items }).map((_, i) => ( +
+ +
+ + +
+ +
+ ))} +
+ ); +} + +export function FormSkeleton() { + return ( +
+
+ + +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ + +
+ ))} + +
+ ); +} diff --git a/customer-portal-full/client/src/components/PullToRefresh.tsx b/customer-portal-full/client/src/components/PullToRefresh.tsx new file mode 100644 index 0000000000..db23d27a1f --- /dev/null +++ b/customer-portal-full/client/src/components/PullToRefresh.tsx @@ -0,0 +1,103 @@ +import { useState, useRef, useCallback, type ReactNode } from "react"; +import { Loader2, ArrowDown } from "lucide-react"; +import { useIsMobile } from "@/hooks/useMobile"; +import { cn } from "@/lib/utils"; + +const THRESHOLD = 80; +const MAX_PULL = 120; + +interface PullToRefreshProps { + children: ReactNode; + onRefresh?: () => Promise | void; +} + +export default function PullToRefresh({ + children, + onRefresh, +}: PullToRefreshProps) { + const isMobile = useIsMobile(); + const [pullDistance, setPullDistance] = useState(0); + const [refreshing, setRefreshing] = useState(false); + const startY = useRef(0); + const pulling = useRef(false); + const containerRef = useRef(null); + + const handleTouchStart = useCallback( + (e: React.TouchEvent) => { + if (!isMobile || refreshing) return; + const scrollTop = containerRef.current?.scrollTop ?? 0; + if (scrollTop <= 0) { + startY.current = e.touches[0].clientY; + pulling.current = true; + } + }, + [isMobile, refreshing] + ); + + const handleTouchMove = useCallback( + (e: React.TouchEvent) => { + if (!pulling.current || refreshing) return; + const currentY = e.touches[0].clientY; + const diff = currentY - startY.current; + if (diff > 0) { + const dampened = Math.min(diff * 0.5, MAX_PULL); + setPullDistance(dampened); + } + }, + [refreshing] + ); + + const handleTouchEnd = useCallback(async () => { + if (!pulling.current) return; + pulling.current = false; + + if (pullDistance >= THRESHOLD && onRefresh) { + setRefreshing(true); + if (navigator.vibrate) navigator.vibrate(15); + try { + await onRefresh(); + } finally { + setRefreshing(false); + setPullDistance(0); + } + } else { + setPullDistance(0); + } + }, [pullDistance, onRefresh]); + + const progress = Math.min(pullDistance / THRESHOLD, 1); + + return ( +
+
+ {refreshing ? ( + + ) : pullDistance > 0 ? ( +
+ +
+ ) : null} +
+ + {children} +
+ ); +} diff --git a/customer-portal-full/client/src/components/UnifiedLayout.tsx b/customer-portal-full/client/src/components/UnifiedLayout.tsx index 8f367ac173..a8d7622389 100644 --- a/customer-portal-full/client/src/components/UnifiedLayout.tsx +++ b/customer-portal-full/client/src/components/UnifiedLayout.tsx @@ -127,6 +127,13 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/component import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { CSSProperties, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useLocation } from "wouter"; +import { useTheme } from "@/contexts/ThemeContext"; +import { Moon, Sun } from "lucide-react"; +import MobileBottomNav from "@/components/MobileBottomNav"; +import PWAInstallPrompt from "@/components/PWAInstallPrompt"; +import OfflineIndicator from "@/components/OfflineIndicator"; +import PullToRefresh from "@/components/PullToRefresh"; +import FloatingActionButton from "@/components/FloatingActionButton"; interface MenuItem { icon: React.ElementType; @@ -498,6 +505,24 @@ function NavItemButton({ ); } +function ThemeToggle() { + const { theme, toggleTheme, switchable } = useTheme(); + if (!switchable || !toggleTheme) return null; + return ( + + ); +} + export default function UnifiedLayout({ children }: { children: ReactNode }) { return ( g.items) .find((item) => item.path === location); + const activeMenuGroup = menuGroups + .find((g) => g.items.some((item) => item.path === location))?.label; + const handleNavigate = useCallback((path: string) => { addRecent(path); setLocation(path); @@ -819,23 +847,42 @@ function UnifiedLayoutContent({ children }: { children: ReactNode }) { -
-
- -
-

+
+
+ +
+ {!isMobile && activeMenuGroup && ( + <> + + {activeMenuGroup} + + + + )} +

{activeMenuItem?.label ?? "Dashboard"}

-
- +
+ + {roleLabels[role]}
-
{children}
+ +
{children}
+
+ + { + const trigger = document.querySelector('[data-sidebar="trigger"]') as HTMLButtonElement; + trigger?.click(); + }} /> + + + ); } diff --git a/customer-portal-full/client/src/index.css b/customer-portal-full/client/src/index.css index 72b423db91..7788897fde 100644 --- a/customer-portal-full/client/src/index.css +++ b/customer-portal-full/client/src/index.css @@ -136,22 +136,12 @@ @layer components { /** * Custom container utility that centers content and adds responsive padding. - * - * This overrides Tailwind's default container behavior to: - * - Auto-center content (mx-auto) - * - Add responsive horizontal padding - * - Set max-width for large screens - * - * Usage:
...
- * - * For custom widths, use max-w-* utilities directly: - *
...
*/ .container { width: 100%; margin-left: auto; margin-right: auto; - padding-left: 1rem; /* 16px - mobile padding */ + padding-left: 1rem; padding-right: 1rem; } @@ -162,16 +152,117 @@ @media (min-width: 640px) { .container { - padding-left: 1.5rem; /* 24px - tablet padding */ + padding-left: 1.5rem; padding-right: 1.5rem; } } @media (min-width: 1024px) { .container { - padding-left: 2rem; /* 32px - desktop padding */ + padding-left: 2rem; padding-right: 2rem; - max-width: 1280px; /* Standard content width */ + max-width: 1280px; + } + } +} + +/* Mobile-first PWA enhancements */ +@layer base { + html { + -webkit-tap-highlight-color: transparent; + -webkit-text-size-adjust: 100%; + scroll-behavior: smooth; + } + + body { + overscroll-behavior-y: contain; + -webkit-overflow-scrolling: touch; + } + + /* Touch target minimum sizing on mobile */ + @media (max-width: 767px) { + button, + [role="button"], + a[href], + input[type="checkbox"], + input[type="radio"], + select { + min-height: 44px; + min-width: 44px; + } + + /* Smaller minimum for inline/icon-only elements */ + .h-8, + .h-7, + .h-6 { + min-height: unset; + min-width: unset; + } + } + + /* Safe area padding for notched devices */ + .safe-area-top { + padding-top: env(safe-area-inset-top, 0px); + } + + .safe-area-bottom { + padding-bottom: env(safe-area-inset-bottom, 0px); + } + + /* Smooth page transitions */ + main { + animation: page-enter 0.2s ease-out; + } + + @keyframes page-enter { + from { + opacity: 0.85; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + + /* Prevent text selection on interactive elements for native feel */ + nav button, + .bottom-nav button { + -webkit-user-select: none; + user-select: none; + } + + /* Improved scrollbar styling */ + ::-webkit-scrollbar { + width: 6px; + height: 6px; + } + + ::-webkit-scrollbar-track { + background: transparent; + } + + ::-webkit-scrollbar-thumb { + background: oklch(0.7 0 0 / 30%); + border-radius: 3px; + } + + ::-webkit-scrollbar-thumb:hover { + background: oklch(0.5 0 0 / 50%); + } + + .dark ::-webkit-scrollbar-thumb { + background: oklch(0.5 0 0 / 30%); + } + + /* Hide scrollbar on mobile for native feel */ + @media (max-width: 767px) { + ::-webkit-scrollbar { + display: none; + } + + * { + scrollbar-width: none; } } } \ No newline at end of file diff --git a/customer-portal-full/client/src/pages/Dashboard.tsx b/customer-portal-full/client/src/pages/Dashboard.tsx index f518e0ceba..d630058ab9 100644 --- a/customer-portal-full/client/src/pages/Dashboard.tsx +++ b/customer-portal-full/client/src/pages/Dashboard.tsx @@ -96,97 +96,97 @@ export default function Dashboard() { ].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()).slice(0, 3); return ( -
-