Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion customer-portal-full/client/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5, viewport-fit=cover" />
<title>Unified Insurance Platform</title>
<meta name="description" content="End-to-end unified insurance management platform for policyholders, agents, brokers, underwriters, adjusters, and administrators." />
<meta name="application-name" content="Unified Insurance Platform" />
Expand Down
29 changes: 28 additions & 1 deletion customer-portal-full/client/public/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
2 changes: 1 addition & 1 deletion customer-portal-full/client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -716,7 +716,7 @@ function Router() {
function App() {
return (
<ErrorBoundary>
<ThemeProvider defaultTheme="light">
<ThemeProvider defaultTheme="light" switchable>
<RoleProvider>
<TooltipProvider>
<Toaster />
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<div className="fixed bottom-[88px] right-4 z-50 flex flex-col-reverse items-end gap-3">
{expanded &&
QUICK_ACTIONS.map((action, i) => (
<button
key={action.label}
onClick={() => handleAction(action.path)}
className={cn(
"flex items-center gap-2 pl-3 pr-4 py-2.5 rounded-full shadow-lg text-white text-sm font-medium",
"animate-in slide-in-from-bottom-2 fade-in duration-200",
action.color
)}
style={{ animationDelay: `${i * 50}ms` }}
>
<action.icon className="h-4 w-4" />
{action.label}
</button>
))}

<button
onClick={() => {
setExpanded(!expanded);
if (navigator.vibrate) navigator.vibrate(10);
}}
className={cn(
"h-14 w-14 rounded-full bg-primary text-primary-foreground shadow-lg",
"flex items-center justify-center transition-transform duration-200",
"active:scale-95 hover:shadow-xl",
expanded && "rotate-45"
)}
aria-label={expanded ? "Close quick actions" : "Quick actions"}
aria-expanded={expanded}
>
{expanded ? (
<X className="h-6 w-6" />
) : (
<Plus className="h-6 w-6" />
)}
</button>
</div>
);
}
91 changes: 91 additions & 0 deletions customer-portal-full/client/src/components/MobileBottomNav.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<nav
className="fixed bottom-0 left-0 right-0 z-50 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 border-t"
style={{ paddingBottom: "env(safe-area-inset-bottom, 0px)" }}
role="navigation"
aria-label="Main navigation"
>
<div className="flex items-center justify-around h-16 px-1">
{NAV_ITEMS.map((item) => {
const isMore = item.path === "__more__";
const isActive = !isMore && location === item.path;
const Icon = item.icon;

return (
<button
key={item.label}
onClick={() => {
if (isMore) {
onMorePress();
} else {
setLocation(item.path);
if (navigator.vibrate) navigator.vibrate(10);
}
}}
className={cn(
"flex flex-col items-center justify-center gap-0.5 min-w-[64px] h-12 rounded-xl transition-all duration-200 active:scale-95",
isActive
? "text-primary"
: "text-muted-foreground hover:text-foreground"
)}
aria-current={isActive ? "page" : undefined}
aria-label={item.label}
>
<div
className={cn(
"flex items-center justify-center w-10 h-7 rounded-full transition-all duration-200",
isActive && "bg-primary/10"
)}
>
<Icon
className={cn(
"h-5 w-5 transition-all duration-200",
isActive && "scale-110"
)}
strokeWidth={isActive ? 2.5 : 2}
/>
</div>
<span
className={cn(
"text-[10px] leading-tight font-medium transition-all duration-200",
isActive && "font-semibold"
)}
>
{item.label}
</span>
</button>
);
})}
</div>
</nav>
);
}
57 changes: 57 additions & 0 deletions customer-portal-full/client/src/components/OfflineIndicator.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
className={cn(
"fixed top-0 left-0 right-0 z-[100] flex items-center justify-center gap-2 py-2 px-4 text-xs font-medium transition-all duration-300",
!isOnline
? "bg-destructive text-destructive-foreground"
: "bg-green-600 text-white animate-in slide-in-from-top-2"
)}
role="status"
aria-live="assertive"
>
{!isOnline ? (
<>
<WifiOff className="h-3.5 w-3.5" />
<span>You are offline. Some features may be unavailable.</span>
</>
) : (
<>
<Wifi className="h-3.5 w-3.5" />
<span>Back online</span>
</>
)}
</div>
);
}
105 changes: 105 additions & 0 deletions customer-portal-full/client/src/components/PWAInstallPrompt.tsx
Original file line number Diff line number Diff line change
@@ -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<void>;
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
}

export default function PWAInstallPrompt() {
const [deferredPrompt, setDeferredPrompt] =
useState<BeforeInstallPromptEvent | null>(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 (
<div
className={cn(
"fixed bottom-20 left-4 right-4 md:left-auto md:right-6 md:bottom-6 md:max-w-sm z-[60]",
"bg-card border shadow-lg rounded-2xl p-4",
"animate-in slide-in-from-bottom-5 duration-300"
)}
role="alert"
>
<button
onClick={handleDismiss}
className="absolute top-3 right-3 text-muted-foreground hover:text-foreground p-1 rounded-full"
aria-label="Dismiss"
>
<X className="h-4 w-4" />
</button>
<div className="flex items-start gap-3 pr-6">
<div className="flex items-center justify-center h-10 w-10 rounded-xl bg-primary/10 shrink-0">
<Download className="h-5 w-5 text-primary" />
</div>
<div className="min-w-0">
<p className="font-semibold text-sm">Install InsurePortal</p>
<p className="text-xs text-muted-foreground mt-0.5">
Add to your home screen for faster access and offline support.
</p>
<div className="flex gap-2 mt-3">
<Button
size="sm"
onClick={handleInstall}
disabled={installing}
className="h-8 text-xs rounded-lg"
>
{installing ? "Installing..." : "Install App"}
</Button>
<Button
size="sm"
variant="ghost"
onClick={handleDismiss}
className="h-8 text-xs rounded-lg"
>
Not now
</Button>
</div>
</div>
</div>
</div>
);
}
Loading