saafe-redirection-flow 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/build-and-deploy.yml +41 -0
- package/.gitlab-ci.yml +108 -0
- package/.releaserc.json +18 -0
- package/.storybook/main.ts +28 -0
- package/.storybook/preview.ts +16 -0
- package/.storybook/vitest.setup.ts +9 -0
- package/.vite/deps/@radix-ui_react-avatar.js +230 -0
- package/.vite/deps/@radix-ui_react-avatar.js.map +7 -0
- package/.vite/deps/@radix-ui_react-slot.js +12 -0
- package/.vite/deps/@radix-ui_react-slot.js.map +7 -0
- package/.vite/deps/_metadata.json +79 -0
- package/.vite/deps/chunk-5VGQBUCU.js +597 -0
- package/.vite/deps/chunk-5VGQBUCU.js.map +7 -0
- package/.vite/deps/chunk-DC5AMYBS.js +38 -0
- package/.vite/deps/chunk-DC5AMYBS.js.map +7 -0
- package/.vite/deps/chunk-HUIEPYH7.js +11265 -0
- package/.vite/deps/chunk-HUIEPYH7.js.map +7 -0
- package/.vite/deps/chunk-TKHB4QMX.js +281 -0
- package/.vite/deps/chunk-TKHB4QMX.js.map +7 -0
- package/.vite/deps/chunk-YLDSBLSF.js +1139 -0
- package/.vite/deps/chunk-YLDSBLSF.js.map +7 -0
- package/.vite/deps/class-variance-authority.js +63 -0
- package/.vite/deps/class-variance-authority.js.map +7 -0
- package/.vite/deps/lucide-react.js +36984 -0
- package/.vite/deps/lucide-react.js.map +7 -0
- package/.vite/deps/package.json +3 -0
- package/.vite/deps/react-dom_client.js +17917 -0
- package/.vite/deps/react-dom_client.js.map +7 -0
- package/.vite/deps/react-router-dom.js +452 -0
- package/.vite/deps/react-router-dom.js.map +7 -0
- package/.vite/deps/react-router.js +234 -0
- package/.vite/deps/react-router.js.map +7 -0
- package/.vite/deps/react.js +5 -0
- package/.vite/deps/react.js.map +7 -0
- package/.vite/deps/react_jsx-dev-runtime.js +470 -0
- package/.vite/deps/react_jsx-dev-runtime.js.map +7 -0
- package/CHANGELOG.md +420 -0
- package/LICENSE +21 -0
- package/README.md +129 -0
- package/RELEASE_CHEATSHEET.md +93 -0
- package/RELEASE_NOTES.md +120 -0
- package/components.json +21 -0
- package/docs/DEPLOYMENT_WORKFLOW.md +262 -0
- package/docs/RELEASE_GUIDE.md +591 -0
- package/docs/architecture.md +432 -0
- package/docs/components.md +199 -0
- package/docs/index.md +69 -0
- package/docs/local-release-workflow.md +234 -0
- package/docs/routes.md +118 -0
- package/docs/sdk-integration.md +325 -0
- package/docs/semantic-release.md +124 -0
- package/docs/user-flow.md +206 -0
- package/eslint.config.js +28 -0
- package/index.html +19 -0
- package/install.sh +198 -0
- package/package.json +115 -0
- package/public/images/bank-logo.png +0 -0
- package/public/saafe-icon.svg +9 -0
- package/src/App.tsx +171 -0
- package/src/__tests__/url-parameters.test.ts +82 -0
- package/src/assets/brand/applestore.svg +13 -0
- package/src/assets/brand/playstore.svg +23 -0
- package/src/assets/brand/saafe-color-white-logo.svg +14 -0
- package/src/assets/brand/saafe-icon.svg +9 -0
- package/src/assets/brand/saafe-logo.svg +18 -0
- package/src/assets/icons/check-icon-dark.svg +27 -0
- package/src/assets/icons/check-icon.svg +23 -0
- package/src/components/ErrorBoundary.tsx +132 -0
- package/src/components/alert/alert.tsx +27 -0
- package/src/components/auth/AuthGuard.tsx +76 -0
- package/src/components/cards/BankCard.stories.tsx +69 -0
- package/src/components/cards/BankCard.tsx +227 -0
- package/src/components/cards/OuterCard.tsx +109 -0
- package/src/components/cards/WrapperCard.tsx +64 -0
- package/src/components/documents/PrivacyContent.tsx +1 -0
- package/src/components/dummyFooter.tsx +29 -0
- package/src/components/icons/github.tsx +12 -0
- package/src/components/language/LanguageSwitcher.tsx +44 -0
- package/src/components/layouts/FrostedLayout.stories.tsx +42 -0
- package/src/components/layouts/FrostedLayout.tsx +333 -0
- package/src/components/layouts/MobileLayout.tsx +403 -0
- package/src/components/mobile-background.tsx +136 -0
- package/src/components/mobileAppDownload.tsx +30 -0
- package/src/components/modal/ModalComp.tsx +27 -0
- package/src/components/mode-toggle.tsx +36 -0
- package/src/components/page-header.tsx +50 -0
- package/src/components/session/SessionTimeoutScreen.tsx +134 -0
- package/src/components/session/SessionTimer.tsx +173 -0
- package/src/components/step-navigation.tsx +87 -0
- package/src/components/title/AppBar.stories.tsx +50 -0
- package/src/components/title/AppBar.tsx +150 -0
- package/src/components/title/SectionTitle.tsx +31 -0
- package/src/components/ui/AnimatedButton.module.css +13 -0
- package/src/components/ui/alert.tsx +66 -0
- package/src/components/ui/animatedButton.tsx +111 -0
- package/src/components/ui/avatar.tsx +51 -0
- package/src/components/ui/badge.tsx +36 -0
- package/src/components/ui/bottom-sheet.tsx +122 -0
- package/src/components/ui/button.tsx +59 -0
- package/src/components/ui/calendar.tsx +86 -0
- package/src/components/ui/card.tsx +92 -0
- package/src/components/ui/checkbox.stories.tsx +49 -0
- package/src/components/ui/checkbox.tsx +67 -0
- package/src/components/ui/collapsible.tsx +45 -0
- package/src/components/ui/dialog.tsx +134 -0
- package/src/components/ui/document-link.tsx +26 -0
- package/src/components/ui/dot-stepper.tsx +57 -0
- package/src/components/ui/dropdown-menu.tsx +255 -0
- package/src/components/ui/form.tsx +165 -0
- package/src/components/ui/frosted-panel.stories.tsx +86 -0
- package/src/components/ui/frosted-panel.tsx +276 -0
- package/src/components/ui/input.tsx +39 -0
- package/src/components/ui/label.stories.tsx +67 -0
- package/src/components/ui/label.tsx +23 -0
- package/src/components/ui/mobile-footer.tsx +54 -0
- package/src/components/ui/modal.tsx +90 -0
- package/src/components/ui/otp-input.stories.tsx +62 -0
- package/src/components/ui/otp-input.tsx +221 -0
- package/src/components/ui/platform-specific-behavior.tsx +28 -0
- package/src/components/ui/popover.tsx +46 -0
- package/src/components/ui/progress.tsx +103 -0
- package/src/components/ui/radio-group.tsx +45 -0
- package/src/components/ui/scroll-area.tsx +56 -0
- package/src/components/ui/sdk-params-docs.tsx +53 -0
- package/src/components/ui/select.tsx +159 -0
- package/src/components/ui/separator.tsx +28 -0
- package/src/components/ui/sheet.tsx +137 -0
- package/src/components/ui/sidebar.tsx +724 -0
- package/src/components/ui/skeleton.stories.tsx +50 -0
- package/src/components/ui/skeleton.tsx +15 -0
- package/src/components/ui/sonner.tsx +23 -0
- package/src/components/ui/step.stories.tsx +132 -0
- package/src/components/ui/step.tsx +234 -0
- package/src/components/ui/stepper-progress.tsx +136 -0
- package/src/components/ui/stepper.tsx +259 -0
- package/src/components/ui/tabs.tsx +55 -0
- package/src/components/ui/tooltip.tsx +61 -0
- package/src/components/ui/url-decode-loader.tsx +36 -0
- package/src/components/ui/version-display.tsx +104 -0
- package/src/components/ui/web-footer.tsx +36 -0
- package/src/config/environments.ts +99 -0
- package/src/config/urls.ts +53 -0
- package/src/const/fiTypeCategoryMap.ts +19 -0
- package/src/contexts/LanguageContext.tsx +41 -0
- package/src/contexts/RTLContext.tsx +42 -0
- package/src/contexts/ThemeContext.tsx +93 -0
- package/src/hooks/use-account-discovery.ts +205 -0
- package/src/hooks/use-auth-query.ts +141 -0
- package/src/hooks/use-fip-query.ts +72 -0
- package/src/hooks/use-media-query.ts +32 -0
- package/src/hooks/use-mobile.ts +24 -0
- package/src/hooks/use-page-title.tsx +48 -0
- package/src/hooks/use-platform.ts +52 -0
- package/src/hooks/use-trusted-count.ts +21 -0
- package/src/hooks/use-url-decode.ts +90 -0
- package/src/hooks/useStep.ts +170 -0
- package/src/index.css +154 -0
- package/src/interfaces/app.interfaces.ts +39 -0
- package/src/interfaces/services.interfaces.ts +65 -0
- package/src/lib/i18n.ts +68 -0
- package/src/lib/utils.ts +6 -0
- package/src/locales/en/common.json +167 -0
- package/src/locales/hi/common.json +137 -0
- package/src/locales/kn/common.json +137 -0
- package/src/locales/ml/common.json +137 -0
- package/src/locales/ta/common.json +137 -0
- package/src/locales/te/common.json +137 -0
- package/src/locales/ur/common.json +138 -0
- package/src/main.tsx +46 -0
- package/src/pages/Login.tsx +363 -0
- package/src/pages/accounts/AccountsToProceed.tsx +396 -0
- package/src/pages/accounts/Discover.tsx +76 -0
- package/src/pages/accounts/DiscoverAccount.tsx +751 -0
- package/src/pages/accounts/LinkSelectedAccounts.tsx +638 -0
- package/src/pages/accounts/OldUser.tsx +329 -0
- package/src/pages/accounts/link-accounts.tsx +913 -0
- package/src/pages/consent/ReviewConsent.tsx +836 -0
- package/src/pages/consent/rejected.tsx +253 -0
- package/src/pages/consent/success.tsx +220 -0
- package/src/providers/query-provider.tsx +24 -0
- package/src/providers/toast-provider.tsx +26 -0
- package/src/services/api/account.service.ts +296 -0
- package/src/services/api/auth.service.ts +206 -0
- package/src/services/api/axios.ts +138 -0
- package/src/services/api/consent.service.ts +142 -0
- package/src/services/api/decode.service.ts +53 -0
- package/src/services/api/feedback.service.ts +34 -0
- package/src/services/api/fip.service.ts +187 -0
- package/src/services/api/index.ts +9 -0
- package/src/services/api/public.service.ts +18 -0
- package/src/services/api.ts +2 -0
- package/src/services/postMessage.service.ts +179 -0
- package/src/store/NavigationBlockContext.tsx +34 -0
- package/src/store/auth.store.ts +79 -0
- package/src/store/fip.store.ts +396 -0
- package/src/store/mandatoryConsent.store.ts +24 -0
- package/src/store/redirect.store.ts +73 -0
- package/src/store/step.store.ts +124 -0
- package/src/stories/Button.stories.ts +53 -0
- package/src/stories/Button.tsx +37 -0
- package/src/stories/Configure.mdx +364 -0
- package/src/stories/Header.stories.ts +33 -0
- package/src/stories/Header.tsx +56 -0
- package/src/stories/Page.stories.ts +32 -0
- package/src/stories/Page.tsx +73 -0
- package/src/stories/button.css +30 -0
- package/src/stories/header.css +32 -0
- package/src/stories/page.css +68 -0
- package/src/styles/rtl-utils.css +90 -0
- package/src/styles/rtl.css +105 -0
- package/src/utils/api-error.ts +26 -0
- package/src/utils/cn.ts +10 -0
- package/src/utils/error-callback.ts +116 -0
- package/src/utils/formatAccountNumber.ts +9 -0
- package/src/utils/handleIdentifiers.ts +90 -0
- package/src/utils/posthog.ts +67 -0
- package/src/utils/toast-helpers.ts +61 -0
- package/src/vite-env.d.ts +1 -0
- package/stage-aa-2506251021.zip +0 -0
- package/tsconfig.app.json +33 -0
- package/tsconfig.json +13 -0
- package/tsconfig.node.json +24 -0
- package/vite.config.ts +45 -0
- package/vitest.shims.d.ts +1 -0
- package/vitest.workspace.ts +46 -0
@@ -0,0 +1,134 @@
|
|
1
|
+
import { useState, useEffect, useRef } from "react";
|
2
|
+
import { useNavigate } from "react-router-dom";
|
3
|
+
import { useRedirectStore } from "@/store/redirect.store";
|
4
|
+
import { XCircle } from 'lucide-react';
|
5
|
+
import logo from "../../assets/brand/saafe-logo.svg";
|
6
|
+
import { motion } from "framer-motion";
|
7
|
+
|
8
|
+
export const SessionTimeoutScreen = () => {
|
9
|
+
const navigate = useNavigate();
|
10
|
+
const { decodedInfo } = useRedirectStore();
|
11
|
+
const [countdown, setCountdown] = useState(10);
|
12
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
13
|
+
|
14
|
+
useEffect(() => {
|
15
|
+
// Prevent scrolling when this screen is shown
|
16
|
+
document.body.style.overflow = 'hidden';
|
17
|
+
|
18
|
+
// Prevent back navigation
|
19
|
+
const preventNavigation = (e: PopStateEvent) => {
|
20
|
+
// Push another state to prevent going back
|
21
|
+
window.history.pushState(null, '', window.location.href);
|
22
|
+
e.preventDefault();
|
23
|
+
e.stopPropagation();
|
24
|
+
};
|
25
|
+
|
26
|
+
// Handle keyboard shortcuts
|
27
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
28
|
+
// Prevent navigation keys, refresh, etc.
|
29
|
+
if (
|
30
|
+
e.key === 'Escape' ||
|
31
|
+
e.key === 'Backspace' ||
|
32
|
+
(e.key === 'r' && (e.ctrlKey || e.metaKey)) ||
|
33
|
+
(e.key === 'w' && (e.ctrlKey || e.metaKey)) ||
|
34
|
+
(e.key === 'F5')
|
35
|
+
) {
|
36
|
+
e.preventDefault();
|
37
|
+
e.stopPropagation();
|
38
|
+
return false;
|
39
|
+
}
|
40
|
+
};
|
41
|
+
|
42
|
+
// Focus trap - keep focus inside this component
|
43
|
+
const handleFocus = () => {
|
44
|
+
if (containerRef.current && !containerRef.current.contains(document.activeElement)) {
|
45
|
+
containerRef.current.focus();
|
46
|
+
}
|
47
|
+
};
|
48
|
+
|
49
|
+
// Block context menu
|
50
|
+
const blockContextMenu = (e: MouseEvent) => {
|
51
|
+
e.preventDefault();
|
52
|
+
return false;
|
53
|
+
};
|
54
|
+
|
55
|
+
// Push current state to history so we can intercept back button
|
56
|
+
window.history.pushState(null, '', window.location.href);
|
57
|
+
|
58
|
+
// Add event listeners
|
59
|
+
window.addEventListener('popstate', preventNavigation);
|
60
|
+
window.addEventListener('keydown', handleKeyDown, { capture: true });
|
61
|
+
document.addEventListener('focus', handleFocus, { capture: true });
|
62
|
+
document.addEventListener('contextmenu', blockContextMenu);
|
63
|
+
|
64
|
+
// Redirect after countdown reaches 0
|
65
|
+
if (countdown <= 0) {
|
66
|
+
// Check if there's a redirect URL (might be undefined/null)
|
67
|
+
const redirectUrl = decodedInfo?.redirect;
|
68
|
+
if (redirectUrl) {
|
69
|
+
window.location.href = redirectUrl;
|
70
|
+
}
|
71
|
+
return;
|
72
|
+
}
|
73
|
+
|
74
|
+
const timer = setTimeout(() => {
|
75
|
+
setCountdown((prev) => prev - 1);
|
76
|
+
}, 1000);
|
77
|
+
|
78
|
+
return () => {
|
79
|
+
clearTimeout(timer);
|
80
|
+
document.body.style.overflow = ''; // Re-enable scrolling if component unmounts
|
81
|
+
|
82
|
+
// Remove event listeners
|
83
|
+
window.removeEventListener('popstate', preventNavigation);
|
84
|
+
window.removeEventListener('keydown', handleKeyDown, { capture: true });
|
85
|
+
document.removeEventListener('focus', handleFocus, { capture: true });
|
86
|
+
document.removeEventListener('contextmenu', blockContextMenu);
|
87
|
+
};
|
88
|
+
}, [countdown, decodedInfo, navigate]);
|
89
|
+
|
90
|
+
return (
|
91
|
+
<div
|
92
|
+
ref={containerRef}
|
93
|
+
className="fixed inset-0 w-full h-full bg-white z-50 dark:bg-background"
|
94
|
+
tabIndex={-1} // Make div focusable
|
95
|
+
onContextMenu={(e) => e.preventDefault()} // Extra measure to prevent context menu
|
96
|
+
>
|
97
|
+
<div className="w-full h-full flex flex-col items-center justify-center">
|
98
|
+
{decodedInfo?.redirect && (
|
99
|
+
<p className="text-center mb-8 absolute top-8 text-muted-secondary dark:text-muted-foreground">
|
100
|
+
Redirecting in <span className="font-semibold">{countdown < 10 ? `0${countdown}` : countdown}</span>
|
101
|
+
</p>
|
102
|
+
)}
|
103
|
+
<motion.div
|
104
|
+
initial={{ y: 20, opacity: 0 }}
|
105
|
+
animate={{ y: 0, opacity: 1 }}
|
106
|
+
transition={{ delay: 0.2, duration: 0.5 }}
|
107
|
+
className="flex flex-col items-center dark:text-foreground"
|
108
|
+
>
|
109
|
+
<div className="rounded-full bg-amber-100 p-6 mb-8">
|
110
|
+
<XCircle className="h-16 w-16 text-amber-600" />
|
111
|
+
</div>
|
112
|
+
|
113
|
+
<h1 className="text-3xl font-semibold mb-4">Session Timed Out</h1>
|
114
|
+
|
115
|
+
{decodedInfo?.redirect ? (
|
116
|
+
<p className="text-center mb-8 text-muted-secondary dark:text-muted-foreground">
|
117
|
+
Please wait while we redirect you to {decodedInfo?.fiuName || "your bank's website"}.
|
118
|
+
</p>
|
119
|
+
) : (
|
120
|
+
<p className="text-center mb-8 text-muted-secondary dark:text-muted-foreground">
|
121
|
+
Please close the window.
|
122
|
+
</p>
|
123
|
+
)}
|
124
|
+
</motion.div>
|
125
|
+
<div className="mt-16 text-center absolute bottom-8">
|
126
|
+
<div className="text-sm flex items-center justify-center gap-2 opacity-70">
|
127
|
+
<p>Powered by</p>
|
128
|
+
<img src={logo} alt="Saafe Logo" className="w-16 h-auto" />
|
129
|
+
</div>
|
130
|
+
</div>
|
131
|
+
</div>
|
132
|
+
</div>
|
133
|
+
);
|
134
|
+
};
|
@@ -0,0 +1,173 @@
|
|
1
|
+
import { useState, useEffect } from "react";
|
2
|
+
import { useTranslation } from "react-i18next";
|
3
|
+
import { Button } from "../ui/button";
|
4
|
+
import { DialogFooter } from "../ui/dialog";
|
5
|
+
import { DialogContent } from "../ui/dialog";
|
6
|
+
import { Dialog } from "../ui/dialog";
|
7
|
+
import { CircleHelp, Clock, Clock4 } from "lucide-react";
|
8
|
+
import { useNavigationBlock } from "@/store/NavigationBlockContext";
|
9
|
+
import { SESSION } from "@/config/urls";
|
10
|
+
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
|
11
|
+
|
12
|
+
export const SessionTimer = ({ onTimeout, mobile = false }: { onTimeout: () => void, mobile?: boolean }) => {
|
13
|
+
const [timeLeft, setTimeLeft] = useState<number>(30 * 1000); // Default to 30 seconds initially
|
14
|
+
const { t } = useTranslation();
|
15
|
+
const [showWarningModal, setShowWarningModal] = useState(false);
|
16
|
+
const warningThreshold = 5 * 60 * 1000; // 5 minutes in milliseconds
|
17
|
+
const SESSION_TIMER_KEY = 'sessionTimer';
|
18
|
+
const { allowNextNavigation } = useNavigationBlock();
|
19
|
+
|
20
|
+
// Get session timeout from env or use default (30 seconds)
|
21
|
+
useEffect(() => {
|
22
|
+
try {
|
23
|
+
// Check if there's a saved timer state
|
24
|
+
const savedTimer = sessionStorage.getItem(SESSION_TIMER_KEY);
|
25
|
+
if (savedTimer) {
|
26
|
+
const { endTime } = JSON.parse(savedTimer);
|
27
|
+
const remainingTime = endTime - Date.now();
|
28
|
+
if (remainingTime > 0) {
|
29
|
+
setTimeLeft(remainingTime);
|
30
|
+
return;
|
31
|
+
} else {
|
32
|
+
// Clear expired session data
|
33
|
+
sessionStorage.clear();
|
34
|
+
}
|
35
|
+
}
|
36
|
+
|
37
|
+
// If no saved state or expired, set new timer
|
38
|
+
const sessionTimeoutSec = SESSION.TIMEOUT ? parseInt(SESSION.TIMEOUT) : 30;
|
39
|
+
const sessionTimeout = sessionTimeoutSec * 1000;
|
40
|
+
setTimeLeft(sessionTimeout);
|
41
|
+
|
42
|
+
// Save the end time in session storage
|
43
|
+
const endTime = Date.now() + sessionTimeout;
|
44
|
+
sessionStorage.setItem(SESSION_TIMER_KEY, JSON.stringify({ endTime }));
|
45
|
+
} catch (error) {
|
46
|
+
setTimeLeft(30 * 1000);
|
47
|
+
}
|
48
|
+
}, []);
|
49
|
+
|
50
|
+
// Timer logic
|
51
|
+
useEffect(() => {
|
52
|
+
if (timeLeft <= 0) {
|
53
|
+
// Clear session storage when timer expires
|
54
|
+
allowNextNavigation();
|
55
|
+
sessionStorage.clear();
|
56
|
+
onTimeout();
|
57
|
+
return;
|
58
|
+
}
|
59
|
+
|
60
|
+
// Show warning modal when 5 minutes remaining or less (if we just crossed the threshold)
|
61
|
+
if (
|
62
|
+
timeLeft <= warningThreshold &&
|
63
|
+
timeLeft > warningThreshold - 1000 &&
|
64
|
+
!showWarningModal
|
65
|
+
) {
|
66
|
+
setShowWarningModal(true);
|
67
|
+
}
|
68
|
+
|
69
|
+
const timer = setTimeout(() => {
|
70
|
+
setTimeLeft((prevTime) => prevTime - 1000);
|
71
|
+
}, 1000);
|
72
|
+
|
73
|
+
return () => clearTimeout(timer);
|
74
|
+
}, [timeLeft, onTimeout, showWarningModal, warningThreshold, allowNextNavigation]);
|
75
|
+
|
76
|
+
// Format time as mm:ss
|
77
|
+
const formatTime = (ms: number) => {
|
78
|
+
const totalSeconds = Math.floor(ms / 1000);
|
79
|
+
const hours = Math.floor(totalSeconds / 3600);
|
80
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
81
|
+
const seconds = totalSeconds % 60;
|
82
|
+
|
83
|
+
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
84
|
+
};
|
85
|
+
|
86
|
+
// Extend session by 30 minutes
|
87
|
+
const extendSession = () => {
|
88
|
+
const newTimeLeft = 30 * 60 * 1000; // 30 minutes
|
89
|
+
setTimeLeft(newTimeLeft);
|
90
|
+
// Update session storage with new end time
|
91
|
+
const endTime = Date.now() + newTimeLeft;
|
92
|
+
sessionStorage.setItem(SESSION_TIMER_KEY, JSON.stringify({ endTime }));
|
93
|
+
setShowWarningModal(false);
|
94
|
+
};
|
95
|
+
|
96
|
+
// Continue with remaining 5 minutes
|
97
|
+
const continueSession = () => {
|
98
|
+
setShowWarningModal(false);
|
99
|
+
};
|
100
|
+
|
101
|
+
return (
|
102
|
+
<div className={mobile ? "text-consent-secondary w-full" : "text-white"}>
|
103
|
+
{mobile ? (
|
104
|
+
<div className="flex flex-col">
|
105
|
+
<div className="flex items-start gap-1 justify-items-start w-full">
|
106
|
+
<Clock4 size={12} className="mt-[1px]" />
|
107
|
+
<span className={`font-medium ${mobile ? "text-xs" : ""}`}>{formatTime(timeLeft)}</span>
|
108
|
+
</div>
|
109
|
+
<Popover>
|
110
|
+
<PopoverTrigger>
|
111
|
+
<div className="flex items-center gap-1 cursor-pointer">
|
112
|
+
<CircleHelp size={12} />
|
113
|
+
<span className="text-xs mt-0.5">
|
114
|
+
{t("session.help")}
|
115
|
+
</span>
|
116
|
+
</div>
|
117
|
+
</PopoverTrigger>
|
118
|
+
<PopoverContent className="w-68">
|
119
|
+
<div className="text-xs text-muted-secondary">
|
120
|
+
For any questions or concerns, please report it to our Customer Grievance Redressal Officer (<a href="https://saafe.in/contact/" target="_blank" rel="noopener noreferrer"><span className="text-primary cursor-pointer">https://saafe.in/contact/</span></a>) - AA
|
121
|
+
</div>
|
122
|
+
</PopoverContent>
|
123
|
+
</Popover>
|
124
|
+
</div>
|
125
|
+
) : (
|
126
|
+
<div className="flex justify-between gap-1.5">
|
127
|
+
<div className="flex items-center gap-1.5">
|
128
|
+
<span className="font-light text-sm">{t("session.timeRemaining")}:</span>
|
129
|
+
<span className={`font-medium ${mobile ? "text-sm" : ""}`}>{formatTime(timeLeft)}</span>
|
130
|
+
</div>
|
131
|
+
<Popover>
|
132
|
+
<PopoverTrigger>
|
133
|
+
<div className="flex items-center gap-1 cursor-pointer">
|
134
|
+
<CircleHelp size={14} />
|
135
|
+
<span className="text-sm mt-0.5">
|
136
|
+
{t("session.help")}
|
137
|
+
</span>
|
138
|
+
</div>
|
139
|
+
</PopoverTrigger>
|
140
|
+
<PopoverContent className="w-68">
|
141
|
+
<div className="text-sm text-muted-secondary">
|
142
|
+
For any questions or concerns, please report it to our Customer Grievance Redressal Officer (<a href="https://saafe.in/contact/" target="_blank" rel="noopener noreferrer"><span className="text-primary cursor-pointer">https://saafe.in/contact/</span></a>) - AA
|
143
|
+
</div>
|
144
|
+
</PopoverContent>
|
145
|
+
</Popover>
|
146
|
+
</div>
|
147
|
+
)}
|
148
|
+
|
149
|
+
<Dialog open={showWarningModal} onOpenChange={setShowWarningModal}>
|
150
|
+
<DialogContent className="text-center">
|
151
|
+
<div className="flex flex-col items-center justify-center gap-2">
|
152
|
+
<div className="bg-amber-100 w-10 h-10 rounded-full flex items-center justify-center">
|
153
|
+
<Clock className="w-6 h-6 text-amber-800" />
|
154
|
+
</div>
|
155
|
+
</div>
|
156
|
+
<div className="flex flex-col gap-2 py-4">
|
157
|
+
<h1 className="text-[20px] md:text-2xl font-medium">Session Timeout Approaching</h1>
|
158
|
+
<p className="text-xs md:text-sm text-muted-secondary">
|
159
|
+
Your session will expire in 5 minutes. Would you like to extend it
|
160
|
+
by 30 minutes or finish now?
|
161
|
+
</p>
|
162
|
+
</div>
|
163
|
+
<DialogFooter className="flex-col md:flex-row items-center justify-center gap-2">
|
164
|
+
<Button onClick={extendSession} size="lg" className="w-full md:w-fit">Extend by 30 mins</Button>
|
165
|
+
<Button variant="ghost" onClick={continueSession} size="lg" className="w-full md:w-fit">
|
166
|
+
Continue for 5 mins
|
167
|
+
</Button>
|
168
|
+
</DialogFooter>
|
169
|
+
</DialogContent>
|
170
|
+
</Dialog>
|
171
|
+
</div>
|
172
|
+
);
|
173
|
+
};
|
@@ -0,0 +1,87 @@
|
|
1
|
+
import React from "react";
|
2
|
+
import { ChevronRight } from "lucide-react";
|
3
|
+
import { cn } from "@/lib/utils";
|
4
|
+
|
5
|
+
export interface StepItem {
|
6
|
+
id: string;
|
7
|
+
title: string;
|
8
|
+
isActive: boolean;
|
9
|
+
isCompleted: boolean;
|
10
|
+
childSteps?: StepItem[];
|
11
|
+
}
|
12
|
+
|
13
|
+
interface StepNavigationProps {
|
14
|
+
steps: StepItem[];
|
15
|
+
onStepClick?: (stepId: string) => void;
|
16
|
+
onChildStepClick?: (parentId: string, childId: string) => void;
|
17
|
+
className?: string;
|
18
|
+
}
|
19
|
+
|
20
|
+
export const StepNavigation: React.FC<StepNavigationProps> = ({
|
21
|
+
steps,
|
22
|
+
onStepClick,
|
23
|
+
onChildStepClick,
|
24
|
+
className,
|
25
|
+
}) => {
|
26
|
+
return (
|
27
|
+
<div className={cn("flex flex-col gap-4", className)}>
|
28
|
+
{steps.map((step) => (
|
29
|
+
<div key={step.id} className="flex flex-col">
|
30
|
+
{/* Parent step */}
|
31
|
+
<button
|
32
|
+
className={cn(
|
33
|
+
"flex items-center gap-2 py-2 text-left focus:outline-none transition-colors",
|
34
|
+
step.isActive
|
35
|
+
? "text-primary font-semibold"
|
36
|
+
: step.isCompleted
|
37
|
+
? "text-muted-foreground"
|
38
|
+
: "text-muted-foreground/50",
|
39
|
+
onStepClick && "cursor-pointer hover:text-primary"
|
40
|
+
)}
|
41
|
+
onClick={() => onStepClick?.(step.id)}
|
42
|
+
disabled={!onStepClick}
|
43
|
+
>
|
44
|
+
<div
|
45
|
+
className={cn(
|
46
|
+
"w-6 h-6 rounded-full flex items-center justify-center text-xs",
|
47
|
+
step.isActive
|
48
|
+
? "bg-primary text-white"
|
49
|
+
: step.isCompleted
|
50
|
+
? "bg-primary/20 text-primary"
|
51
|
+
: "bg-muted text-muted-foreground/50"
|
52
|
+
)}
|
53
|
+
>
|
54
|
+
{step.isCompleted ? "✓" : steps.indexOf(step) + 1}
|
55
|
+
</div>
|
56
|
+
<span>{step.title}</span>
|
57
|
+
</button>
|
58
|
+
|
59
|
+
{/* Child steps if parent is active and has children */}
|
60
|
+
{step.isActive && step.childSteps && step.childSteps.length > 0 && (
|
61
|
+
<div className="ml-8 mt-2 flex flex-col gap-2">
|
62
|
+
{step.childSteps.map((childStep) => (
|
63
|
+
<button
|
64
|
+
key={childStep.id}
|
65
|
+
className={cn(
|
66
|
+
"flex items-center gap-2 py-1 text-left text-sm focus:outline-none transition-colors",
|
67
|
+
childStep.isActive
|
68
|
+
? "text-primary font-medium"
|
69
|
+
: childStep.isCompleted
|
70
|
+
? "text-muted-foreground"
|
71
|
+
: "text-muted-foreground/50",
|
72
|
+
onChildStepClick && "cursor-pointer hover:text-primary"
|
73
|
+
)}
|
74
|
+
onClick={() => onChildStepClick?.(step.id, childStep.id)}
|
75
|
+
disabled={!onChildStepClick}
|
76
|
+
>
|
77
|
+
<ChevronRight size={14} />
|
78
|
+
<span>{childStep.title}</span>
|
79
|
+
</button>
|
80
|
+
))}
|
81
|
+
</div>
|
82
|
+
)}
|
83
|
+
</div>
|
84
|
+
))}
|
85
|
+
</div>
|
86
|
+
);
|
87
|
+
};
|
@@ -0,0 +1,50 @@
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
2
|
+
import AppBar from "./AppBar";
|
3
|
+
import { Settings, Bell } from "lucide-react";
|
4
|
+
|
5
|
+
const meta: Meta<typeof AppBar> = {
|
6
|
+
title: "UI/AppBar",
|
7
|
+
component: AppBar,
|
8
|
+
parameters: {
|
9
|
+
layout: "padded",
|
10
|
+
},
|
11
|
+
tags: ["autodocs"],
|
12
|
+
};
|
13
|
+
|
14
|
+
export default meta;
|
15
|
+
type Story = StoryObj<typeof AppBar>;
|
16
|
+
|
17
|
+
export const Default: Story = {
|
18
|
+
args: {
|
19
|
+
title: "Default AppBar",
|
20
|
+
},
|
21
|
+
};
|
22
|
+
|
23
|
+
export const WithCustomLeftSection: Story = {
|
24
|
+
args: {
|
25
|
+
title: "Custom Left Section",
|
26
|
+
leftSection: <Settings className="w-6 h-6 text-consent-primary" />,
|
27
|
+
},
|
28
|
+
};
|
29
|
+
|
30
|
+
export const WithCustomRightSection: Story = {
|
31
|
+
args: {
|
32
|
+
title: "Custom Right Section",
|
33
|
+
rightSection: <Bell className="w-6 h-6 text-consent-primary" />,
|
34
|
+
},
|
35
|
+
};
|
36
|
+
|
37
|
+
export const WithCustomActions: Story = {
|
38
|
+
args: {
|
39
|
+
title: "Custom Actions",
|
40
|
+
leftSectionAction: () => alert("Left section clicked"),
|
41
|
+
rightSectionAction: () => alert("Right section clicked"),
|
42
|
+
},
|
43
|
+
};
|
44
|
+
|
45
|
+
export const WithCustomClassName: Story = {
|
46
|
+
args: {
|
47
|
+
title: "Custom Styling",
|
48
|
+
className: "bg-gray-100 p-4 rounded-lg",
|
49
|
+
},
|
50
|
+
};
|
@@ -0,0 +1,150 @@
|
|
1
|
+
import { ChevronLeft, ChevronRight, Info } from 'lucide-react'
|
2
|
+
import { useLocation, useNavigate } from 'react-router-dom';
|
3
|
+
import { usePageTitle } from '@/hooks/use-page-title';
|
4
|
+
import { useMediaQuery } from '@/hooks/use-media-query';
|
5
|
+
import { cn } from '@/lib/utils';
|
6
|
+
import Modal from '../ui/modal';
|
7
|
+
import { Label } from '../ui/label';
|
8
|
+
import { AnimatedButton } from '../ui/animatedButton';
|
9
|
+
import { useFipStore } from '@/store/fip.store';
|
10
|
+
import { EVENTS, trackEvent } from '@/utils/posthog';
|
11
|
+
import { useNavigationBlock } from '@/store/NavigationBlockContext';
|
12
|
+
import { useRTL } from '@/contexts/RTLContext';
|
13
|
+
import { useTranslation } from 'react-i18next';
|
14
|
+
interface AppBarProps {
|
15
|
+
title?: string;
|
16
|
+
leftSection?: React.ReactNode;
|
17
|
+
leftSectionAction?: () => void;
|
18
|
+
rightSection?: React.ReactNode | boolean;
|
19
|
+
rightSectionAction?: () => void;
|
20
|
+
className?: string;
|
21
|
+
}
|
22
|
+
|
23
|
+
const AppBar = ({
|
24
|
+
title: propTitle,
|
25
|
+
leftSection,
|
26
|
+
leftSectionAction,
|
27
|
+
rightSection,
|
28
|
+
className = '',
|
29
|
+
}: AppBarProps) => {
|
30
|
+
const navigate = useNavigate();
|
31
|
+
const location = useLocation();
|
32
|
+
const [contextTitle] = usePageTitle();
|
33
|
+
const isMobile = useMediaQuery("(max-width: 768px)");
|
34
|
+
const {
|
35
|
+
completeCategory,
|
36
|
+
categories,
|
37
|
+
setActiveCategory,
|
38
|
+
activeCategory,
|
39
|
+
completedCategories,
|
40
|
+
} = useFipStore()
|
41
|
+
// Only navigate back if we have history within our app
|
42
|
+
const { allowNextNavigation } = useNavigationBlock();
|
43
|
+
const { isRTL } = useRTL();
|
44
|
+
const { t } = useTranslation();
|
45
|
+
// Use prop title if provided, otherwise use context title
|
46
|
+
const title = contextTitle || propTitle;
|
47
|
+
|
48
|
+
const handleLeftSectionAction = () => {
|
49
|
+
if (leftSectionAction) {
|
50
|
+
leftSectionAction();
|
51
|
+
} else {
|
52
|
+
allowNextNavigation(); // mark this as an allowed navigation
|
53
|
+
navigate(-1);
|
54
|
+
}
|
55
|
+
};
|
56
|
+
|
57
|
+
if (location.pathname?.split('/')?.includes('login')) {
|
58
|
+
return null;
|
59
|
+
}
|
60
|
+
|
61
|
+
const handleComplete = () => {
|
62
|
+
// Mark this category as complete
|
63
|
+
completeCategory(activeCategory || 'BANKS')
|
64
|
+
trackEvent(EVENTS.SKIP_LINKING, { activeCategory })
|
65
|
+
|
66
|
+
// Check if there are more categories to process
|
67
|
+
const currentIndex = categories.indexOf(activeCategory || 'BANKS')
|
68
|
+
if (currentIndex < categories.length - 1) {
|
69
|
+
// Move to the next category
|
70
|
+
const nextCategory = categories[currentIndex + 1]
|
71
|
+
setActiveCategory(nextCategory)
|
72
|
+
|
73
|
+
// Navigate directly to the next category path for proper step highlighting
|
74
|
+
navigate(`/link-accounts/${nextCategory.toLowerCase()}`, {
|
75
|
+
state: {
|
76
|
+
completedCategories: [...(completedCategories || []), activeCategory]
|
77
|
+
}
|
78
|
+
})
|
79
|
+
} else {
|
80
|
+
// All categories done, move to review consent
|
81
|
+
navigate('/review', {
|
82
|
+
state: {
|
83
|
+
// selectedFips: state?.selectedFips,
|
84
|
+
completedCategories: [...(completedCategories || []), activeCategory]
|
85
|
+
}
|
86
|
+
})
|
87
|
+
}
|
88
|
+
}
|
89
|
+
|
90
|
+
return (
|
91
|
+
<div className={cn(
|
92
|
+
"flex justify-between items-center w-full py-2",
|
93
|
+
// isMobile ? "px-4" : "px-6",
|
94
|
+
isRTL ? "flex-row-reverse" : "",
|
95
|
+
className
|
96
|
+
)}>
|
97
|
+
<div className="cursor-pointer" onClick={handleLeftSectionAction}>
|
98
|
+
{leftSection ? leftSection : <ChevronLeft className='text-consent-primary dark:text-gray-300' />}
|
99
|
+
</div>
|
100
|
+
<div>
|
101
|
+
<p className={cn(
|
102
|
+
"font-semibold",
|
103
|
+
isMobile ? "text-sm" : "text-lg",
|
104
|
+
"text-black text-center dark:text-gray-300"
|
105
|
+
)}>
|
106
|
+
{title}
|
107
|
+
</p>
|
108
|
+
</div>
|
109
|
+
<Modal className='w-[500px]'>
|
110
|
+
<Modal.Trigger>
|
111
|
+
<p className={cn(
|
112
|
+
isMobile ? "text-sm" : "text-base",
|
113
|
+
"text-consent-primary cursor-pointer"
|
114
|
+
)}>
|
115
|
+
{location.pathname === '/review' ? null : (
|
116
|
+
<div className='flex items-center gap-1 dark:text-gray-300'>
|
117
|
+
{rightSection ? rightSection : t('next')}
|
118
|
+
{!rightSection ? <ChevronRight className='text-consent-primary dark:text-gray-300' /> : null}
|
119
|
+
</div>
|
120
|
+
)}
|
121
|
+
</p>
|
122
|
+
</Modal.Trigger>
|
123
|
+
<div className='flex flex-col items-center gap-4'>
|
124
|
+
<div className='flex items-center justify-center bg-orange-100/50 dark:bg-orange-900/50 p-3 rounded-full w-fit'>
|
125
|
+
<Info className='h-[32px] w-[32px] text-yellow-600' />
|
126
|
+
</div>
|
127
|
+
{
|
128
|
+
location.pathname === '/link-accounts/link' ? <>
|
129
|
+
<Label className='text-[20px] md:text-2xl font-semibold dark:text-white'>{t('skipModal.title')}</Label>
|
130
|
+
<p className='text-sm md:text-md text-gray-500 dark:text-gray-400 text-center'>{t('skipModal.description')}</p>
|
131
|
+
</> : <>
|
132
|
+
<Label className='text-[20px] md:text-2xl font-semibold dark:text-white text-center'>{t('completeModal.title')}</Label>
|
133
|
+
<p className='text-sm md:text-md text-gray-500 dark:text-gray-400 text-center'>{t('completeModal.description').replace('{activeCategory}', activeCategory)}</p>
|
134
|
+
</>
|
135
|
+
}
|
136
|
+
</div>
|
137
|
+
<div className='flex justify-center gap-4 mt-6 flex-col md:flex-row'>
|
138
|
+
<Modal.Close>
|
139
|
+
<AnimatedButton size={'lg'} className='w-full'>{t('skipModal.cancel')}</AnimatedButton>
|
140
|
+
</Modal.Close>
|
141
|
+
<Modal.Close>
|
142
|
+
<AnimatedButton variant={'secondary'} size={'lg'} className='bg-orange-100/50 dark:bg-orange-900/50 text-yellow-600 hover:bg-orange-100/70 dark:hover:bg-orange-900/70 w-full' onClick={handleComplete}>{t('skipModal.confirm')}</AnimatedButton>
|
143
|
+
</Modal.Close>
|
144
|
+
</div>
|
145
|
+
</Modal>
|
146
|
+
</div>
|
147
|
+
)
|
148
|
+
}
|
149
|
+
|
150
|
+
export default AppBar
|
@@ -0,0 +1,31 @@
|
|
1
|
+
import React from 'react'
|
2
|
+
|
3
|
+
const SectionTitle = ({
|
4
|
+
title,
|
5
|
+
rightSection,
|
6
|
+
className = '',
|
7
|
+
description
|
8
|
+
}: {
|
9
|
+
title: string
|
10
|
+
rightSection?: React.ReactNode
|
11
|
+
className?: string
|
12
|
+
description?: string
|
13
|
+
}) => {
|
14
|
+
return (
|
15
|
+
<div className='w-full flex flex-col'>
|
16
|
+
<div
|
17
|
+
className={`flex items-center gap-4 w-full justify-between ${className}`}
|
18
|
+
>
|
19
|
+
<p className='text-sm md:text-lg font-[600] text-consent-primary dark:text-muted-foreground'>
|
20
|
+
{title}
|
21
|
+
</p>
|
22
|
+
<div>{rightSection ? rightSection : null}</div>
|
23
|
+
</div>
|
24
|
+
<div>
|
25
|
+
<p className='text-sm text-consent-secondary'>{description}</p>
|
26
|
+
</div>
|
27
|
+
</div>
|
28
|
+
)
|
29
|
+
}
|
30
|
+
|
31
|
+
export default SectionTitle
|
@@ -0,0 +1,13 @@
|
|
1
|
+
.loader {
|
2
|
+
border: 4px solid #f3f3f3;
|
3
|
+
border-top: 4px solid #3498db;
|
4
|
+
border-radius: 50%;
|
5
|
+
width: 24px;
|
6
|
+
height: 24px;
|
7
|
+
animation: spin 1s linear infinite;
|
8
|
+
}
|
9
|
+
|
10
|
+
@keyframes spin {
|
11
|
+
0% { transform: rotate(0deg); }
|
12
|
+
100% { transform: rotate(360deg); }
|
13
|
+
}
|