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 }) {
-