Browse Source
feat: implement reusable navigation and action button components with modal and countdown support
front-test-2
feat: implement reusable navigation and action button components with modal and countdown support
front-test-2
18 changed files with 420 additions and 66 deletions
-
BINpublic/assets/images/diamond-black.png
-
BINpublic/assets/images/diamond-color.png
-
BINpublic/assets/images/diamond-gradient.png
-
BINpublic/assets/images/diamond-outline.png
-
22src/app/globals.css
-
4src/app/new-match/profile/page.tsx
-
2src/app/questions-list/[slug]/page.tsx
-
9src/app/questions-list/[slug]/question-detail-client.tsx
-
4src/app/questions-list/sections-request.tsx
-
22src/components/questions/question-exit-navigation-button.tsx
-
21src/components/questions/question-section-flow.tsx
-
103src/components/questions/question-snap-list.tsx
-
2src/components/ui/button.tsx
-
173src/components/ui/help-modal.tsx
-
88src/components/ui/navigation-button.tsx
-
22src/components/ui/token-switcher.tsx
-
6src/translations/locales/en.json
-
8src/translations/locales/fa.json
|
After Width: 25 | Height: 25 | Size: 744 B |
|
After Width: 25 | Height: 25 | Size: 1.3 KiB |
|
After Width: 25 | Height: 25 | Size: 1.3 KiB |
|
After Width: 25 | Height: 25 | Size: 744 B |
@ -0,0 +1,173 @@ |
|||||
|
"use client"; |
||||
|
|
||||
|
import type { ReactNode } from "react"; |
||||
|
import { useCallback, useEffect, useState } from "react"; |
||||
|
import { createPortal } from "react-dom"; |
||||
|
import Button from "@/components/ui/button"; |
||||
|
import { useI18n } from "@/translations/provider"; |
||||
|
|
||||
|
const EXIT_ANIMATION_MS = 220; |
||||
|
|
||||
|
export type HelpModalProps = { |
||||
|
isOpen: boolean; |
||||
|
onClose: () => void; |
||||
|
title?: ReactNode; |
||||
|
description?: ReactNode; |
||||
|
buttonText?: ReactNode; |
||||
|
}; |
||||
|
|
||||
|
export function HelpModal({ |
||||
|
isOpen, |
||||
|
onClose, |
||||
|
title, |
||||
|
description, |
||||
|
buttonText, |
||||
|
}: HelpModalProps) { |
||||
|
const { dictionary: t } = useI18n(); |
||||
|
const [isVisible, setIsVisible] = useState(isOpen); |
||||
|
const [isEntering, setIsEntering] = useState(true); |
||||
|
const [isClosing, setIsClosing] = useState(false); |
||||
|
const [mounted, setMounted] = useState(false); |
||||
|
|
||||
|
useEffect(() => { |
||||
|
setMounted(true); |
||||
|
}, []); |
||||
|
|
||||
|
const resolvedTitle = "Tips"; |
||||
|
const resolvedDescription = |
||||
|
description ?? |
||||
|
(t.common as any)?.helpDescription ?? |
||||
|
"Psychologically, this practice fosters a sense of empathy and contentment, which can reduce financial stress. Socially, lending strengthens neighborhood bonds and creates support networks that can lead to economic opportunities. This hadith encourages believers to lend dough, bread, and fire to increase their sustenance."; |
||||
|
const resolvedButtonText = |
||||
|
buttonText ?? (t.common as any)?.gotIt ?? "Got it"; |
||||
|
|
||||
|
const closeSheet = useCallback(() => { |
||||
|
if (isClosing) return; |
||||
|
setIsClosing(true); |
||||
|
}, [isClosing]); |
||||
|
|
||||
|
// Sync with isOpen prop
|
||||
|
useEffect(() => { |
||||
|
if (isOpen) { |
||||
|
setIsVisible(true); |
||||
|
setIsClosing(false); |
||||
|
setIsEntering(true); |
||||
|
} |
||||
|
}, [isOpen]); |
||||
|
|
||||
|
// Entry animation
|
||||
|
useEffect(() => { |
||||
|
if (!isVisible) return; |
||||
|
const frameId = window.requestAnimationFrame(() => { |
||||
|
setIsEntering(false); |
||||
|
}); |
||||
|
return () => window.cancelAnimationFrame(frameId); |
||||
|
}, [isVisible]); |
||||
|
|
||||
|
// Lock body scroll
|
||||
|
useEffect(() => { |
||||
|
if (!isVisible) return; |
||||
|
const previousBodyOverflow = document.body.style.overflow; |
||||
|
const previousHtmlOverflow = document.documentElement.style.overflow; |
||||
|
document.body.style.overflow = "hidden"; |
||||
|
document.documentElement.style.overflow = "hidden"; |
||||
|
return () => { |
||||
|
document.body.style.overflow = previousBodyOverflow; |
||||
|
document.documentElement.style.overflow = previousHtmlOverflow; |
||||
|
}; |
||||
|
}, [isVisible]); |
||||
|
|
||||
|
// Exit animation
|
||||
|
useEffect(() => { |
||||
|
if (!isClosing) return; |
||||
|
const timeoutId = window.setTimeout(() => { |
||||
|
setIsVisible(false); |
||||
|
onClose(); |
||||
|
}, EXIT_ANIMATION_MS); |
||||
|
return () => window.clearTimeout(timeoutId); |
||||
|
}, [isClosing, onClose]); |
||||
|
|
||||
|
if (!isVisible || !mounted) return null; |
||||
|
|
||||
|
return createPortal( |
||||
|
<div |
||||
|
className={[ |
||||
|
"fixed inset-0 z-[100] flex items-end justify-center transition-all duration-[220ms]", |
||||
|
isClosing || isEntering |
||||
|
? "bg-[#171717]/0 opacity-0" |
||||
|
: "bg-[#171717]/55 opacity-100", |
||||
|
].join(" ")} |
||||
|
role="dialog" |
||||
|
aria-modal="true" |
||||
|
aria-label={String(resolvedTitle)} |
||||
|
tabIndex={-1} |
||||
|
onClick={(e) => { |
||||
|
if (e.target === e.currentTarget) closeSheet(); |
||||
|
}} |
||||
|
onKeyDown={(e) => { |
||||
|
if ( |
||||
|
e.target === e.currentTarget && |
||||
|
(e.key === "Escape" || e.key === "Enter" || e.key === " ") |
||||
|
) { |
||||
|
e.preventDefault(); |
||||
|
closeSheet(); |
||||
|
} |
||||
|
}} |
||||
|
> |
||||
|
<section |
||||
|
className={[ |
||||
|
"w-full sm:max-w-[375px] rounded-t-[15px] bg-[#F9F8F8] px-4 pb-5 pt-4 text-center shadow-[0_20px_60px_rgba(15,23,42,0.08)] transition-transform duration-[220ms] ease-out will-change-transform", |
||||
|
isClosing || isEntering ? "translate-y-full" : "translate-y-0", |
||||
|
].join(" ")} |
||||
|
> |
||||
|
<div className="mx-auto flex flex-col items-center"> |
||||
|
<h2 className="w-full text-[14px] leading-[1.3] font-bold tracking-[-0.02em] text-[#171717]"> |
||||
|
{resolvedTitle} |
||||
|
</h2> |
||||
|
|
||||
|
{(() => { |
||||
|
const items = Array.isArray(resolvedDescription) |
||||
|
? resolvedDescription |
||||
|
: typeof resolvedDescription === "string" |
||||
|
? resolvedDescription |
||||
|
.split(/\.\s+/) |
||||
|
.map((s) => s.replace(/\.$/, "").trim()) |
||||
|
.filter(Boolean) |
||||
|
: null; |
||||
|
|
||||
|
if (items && Array.isArray(items)) { |
||||
|
return ( |
||||
|
<ul className="mt-3.5 w-full text-[12px] leading-[1.6] text-[#4D4D4D] text-start space-y-2"> |
||||
|
{items.map((item, i) => ( |
||||
|
<li key={i} className="flex items-start gap-2.5 w-full"> |
||||
|
<span className="shrink-0 mt-1.5 size-1.5 rounded-full bg-[#4D4D4D]" /> |
||||
|
<span className="flex-1 min-w-0">{item}</span> |
||||
|
</li> |
||||
|
))} |
||||
|
</ul> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
return ( |
||||
|
<div className="mt-3.5 w-full text-[12px] leading-[1.6] text-[#4D4D4D] text-start"> |
||||
|
{resolvedDescription} |
||||
|
</div> |
||||
|
); |
||||
|
})()} |
||||
|
|
||||
|
<div className="mt-5 w-full"> |
||||
|
<Button |
||||
|
onClick={closeSheet} |
||||
|
className="py-[14px]" |
||||
|
> |
||||
|
{resolvedButtonText} |
||||
|
</Button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</section> |
||||
|
</div>, |
||||
|
document.body, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
export default HelpModal; |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue