From 4ae71fb836f2ad4519cd26fc437bdf3a0aa5870e Mon Sep 17 00:00:00 2001 From: ghorbani Date: Fri, 31 Jul 2026 16:30:19 +0330 Subject: [PATCH] feat: implement reusable navigation and action button components with modal and countdown support --- public/assets/images/diamond-black.png | Bin 0 -> 744 bytes public/assets/images/diamond-color.png | Bin 0 -> 1349 bytes public/assets/images/diamond-gradient.png | Bin 0 -> 1349 bytes public/assets/images/diamond-outline.png | Bin 0 -> 744 bytes src/app/globals.css | 22 +-- src/app/new-match/profile/page.tsx | 4 +- src/app/questions-list/[slug]/page.tsx | 2 +- .../[slug]/question-detail-client.tsx | 9 +- src/app/questions-list/sections-request.tsx | 4 +- .../question-exit-navigation-button.tsx | 22 ++- .../questions/question-section-flow.tsx | 21 ++- .../questions/question-snap-list.tsx | 103 +++++++++++ src/components/ui/button.tsx | 2 +- src/components/ui/help-modal.tsx | 173 ++++++++++++++++++ src/components/ui/navigation-button.tsx | 88 ++++++--- src/components/ui/token-switcher.tsx | 22 ++- src/translations/locales/en.json | 6 +- src/translations/locales/fa.json | 8 +- 18 files changed, 420 insertions(+), 66 deletions(-) create mode 100644 public/assets/images/diamond-black.png create mode 100644 public/assets/images/diamond-color.png create mode 100644 public/assets/images/diamond-gradient.png create mode 100644 public/assets/images/diamond-outline.png create mode 100644 src/components/ui/help-modal.tsx diff --git a/public/assets/images/diamond-black.png b/public/assets/images/diamond-black.png new file mode 100644 index 0000000000000000000000000000000000000000..d10d63aa901aa66bbd7a6e33c91a284553ef124c GIT binary patch literal 744 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 aR09A82i8K__FxhK0000 setIsRejectSheetOpen(true)} - className="inline-flex w-1/3 items-center justify-center rounded-[12px] border border-[#BFBFBF] bg-white px-4 py-[13px] text-[16px] font-semibold text-[#9A9A9A]" + className="inline-flex w-1/3 h-[52px] items-center justify-center rounded-[12px] border border-[#BFBFBF] bg-white px-4 text-[16px] font-semibold text-[#9A9A9A]" > Reject @@ -490,7 +490,7 @@ export default function NewMatchProfilePage() { setIsRequestSheetOpen(true); }} - className="inline-flex w-2/3 whitespace-nowrap items-center justify-center gap-1 rounded-[12px] bg-[#F0445B] px-4 py-[13px] text-[16px] font-semibold text-white shadow-[0_8px_16px_rgba(240,68,91,0.24)] disabled:cursor-not-allowed disabled:opacity-50" + className="inline-flex w-2/3 h-[52px] whitespace-nowrap items-center justify-center gap-1 rounded-[12px] bg-[#F0445B] px-4 text-[16px] font-semibold text-white shadow-[0_8px_16px_rgba(240,68,91,0.24)] disabled:cursor-not-allowed disabled:opacity-50" > ); } diff --git a/src/app/questions-list/[slug]/question-detail-client.tsx b/src/app/questions-list/[slug]/question-detail-client.tsx index 97f1f76..c11a3d6 100644 --- a/src/app/questions-list/[slug]/question-detail-client.tsx +++ b/src/app/questions-list/[slug]/question-detail-client.tsx @@ -652,7 +652,7 @@ export default function QuestionDetailClient({ icon="close" iconLabel={closeLabel} /> -

+

{item.title}

@@ -709,8 +711,9 @@ export default function QuestionDetailClient({ variant="transparent" icon="close" iconLabel={closeLabel} + exitHref={questionsListHref} /> -

+

{item.title}

diff --git a/src/app/questions-list/sections-request.tsx b/src/app/questions-list/sections-request.tsx index 85d55f2..d588ae7 100644 --- a/src/app/questions-list/sections-request.tsx +++ b/src/app/questions-list/sections-request.tsx @@ -58,7 +58,7 @@ export default function SectionsRequest() { icon={null} title={({ close }) => ( - + Bookings Terms & Conditions + ) : null} ); } diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 19fe982..440d615 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -120,9 +120,9 @@ export function Button({ widthClass, "items-center", "justify-center", + "h-[52px]", "rounded-[11px]", "px-4", - countdownLocked ? "py-[9px]" : "py-[14px]", "text-center", "transition-opacity", isOutlined diff --git a/src/components/ui/help-modal.tsx b/src/components/ui/help-modal.tsx new file mode 100644 index 0000000..63b7fb5 --- /dev/null +++ b/src/components/ui/help-modal.tsx @@ -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( +
{ + if (e.target === e.currentTarget) closeSheet(); + }} + onKeyDown={(e) => { + if ( + e.target === e.currentTarget && + (e.key === "Escape" || e.key === "Enter" || e.key === " ") + ) { + e.preventDefault(); + closeSheet(); + } + }} + > +
+
+

+ {resolvedTitle} +

+ + {(() => { + 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 ( +
    + {items.map((item, i) => ( +
  • + + {item} +
  • + ))} +
+ ); + } + + return ( +
+ {resolvedDescription} +
+ ); + })()} + +
+ +
+
+
+
, + document.body, + ); +} + +export default HelpModal; diff --git a/src/components/ui/navigation-button.tsx b/src/components/ui/navigation-button.tsx index 2f9278e..4ba8c5b 100644 --- a/src/components/ui/navigation-button.tsx +++ b/src/components/ui/navigation-button.tsx @@ -2,9 +2,11 @@ import Image from "next/image"; import { useRouter } from "next/navigation"; -import type { ButtonHTMLAttributes } from "react"; +import type { ButtonHTMLAttributes, ReactNode } from "react"; +import { useState } from "react"; import { GoArrowLeft } from "react-icons/go"; import { useI18n } from "@/translations/provider"; +import HelpModal from "@/components/ui/help-modal"; type NavigationButtonIcon = | "back" @@ -22,6 +24,10 @@ export type NavigationButtonProps = Omit< icon: NavigationButtonIcon; iconLabel?: string; variant?: NavigationButtonVariant; + helpTitle?: ReactNode; + helpDescription?: ReactNode; + helpButtonText?: ReactNode; + disableHelpModal?: boolean; }; export function NavigationButton({ @@ -30,10 +36,16 @@ export function NavigationButton({ variant = "default", type = "button", className, + helpTitle, + helpDescription, + helpButtonText, + disableHelpModal = false, ...props }: NavigationButtonProps) { const router = useRouter(); const { dictionary: t } = useI18n(); + const [isHelpOpen, setIsHelpOpen] = useState(false); + const iconNode = (() => { switch (icon) { case "back": @@ -93,38 +105,56 @@ export function NavigationButton({ } })(); + const isHelpTrigger = icon === "info" || helpTitle !== undefined || helpDescription !== undefined; + return ( - + }} + className={[ + "inline-flex items-center justify-center rounded-[15px] p-2 cursor-pointer transition-transform active:scale-95", + variant === "transparent" ? "bg-white/20" : "bg-[#FFFFFF]", + className, + ] + .filter(Boolean) + .join(" ")} + > + {iconNode} + + + {isHelpTrigger && !disableHelpModal ? ( + setIsHelpOpen(false)} + title={helpTitle} + description={helpDescription} + buttonText={helpButtonText} + /> + ) : null} + ); } diff --git a/src/components/ui/token-switcher.tsx b/src/components/ui/token-switcher.tsx index 98203cc..51b20f6 100644 --- a/src/components/ui/token-switcher.tsx +++ b/src/components/ui/token-switcher.tsx @@ -7,6 +7,9 @@ import { setClientCookie, getClientCookie } from "@/lib/cookies"; export const FEMALE_TOKEN = "545f61bb3e061ccb9b19f84715eb1b1ed4740331"; export const MALE_TOKEN = "f3a7543b44ef0a713d1ee0d4f7866b3825cf1308"; +export const FEMALE_EMAIL = "m.a.ghorbani01@gmail.com"; +export const MALE_EMAIL = "muhammadamin.ghorbani@gmail.com"; + const TOKEN_COOKIE_NAME = "HABIB_TOKEN"; type TokenSwitcherProps = { @@ -39,11 +42,12 @@ export function TokenSwitcher({ const handleSelectToken = (newToken: string) => { if (typeof window !== "undefined") { - // Clear local storage so answers and cached state from previous user are removed + // Clear local storage and session storage so answers and cached state from previous user are removed try { window.localStorage.clear(); + window.sessionStorage.clear(); } catch (e) { - console.error("Failed to clear localStorage", e); + console.error("Failed to clear storage", e); } setClientCookie(TOKEN_COOKIE_NAME, newToken); @@ -115,8 +119,11 @@ export function TokenSwitcher({ 👨
حساب آقا
-
- f3a7543b...308 +
+ {MALE_EMAIL} +
+
+ {MALE_TOKEN}
@@ -142,8 +149,11 @@ export function TokenSwitcher({ 👩
حساب خانم
-
- 545f61bb...331 +
+ {FEMALE_EMAIL} +
+
+ {FEMALE_TOKEN}
diff --git a/src/translations/locales/en.json b/src/translations/locales/en.json index 717b6c3..ee1cdae 100644 --- a/src/translations/locales/en.json +++ b/src/translations/locales/en.json @@ -11,6 +11,9 @@ "required": "Required", "estimateTime": "Estimate time", "rangeError": "The value entered seems incorrect. Please provide a realistic value.", + "help": "Help", + "gotIt": "Got it", + "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.", "finalMatchIntroduce": "Final Match Introduction", "confirmFinalMatch": "Confirm Final Match", "confirmFinalMatchQuestion": "Are you sure you want to officially introduce these two candidates to each other?", @@ -60,7 +63,8 @@ "By identifying your strongest needs, you can better communicate your expectations and build healthier relationships." ] }, - "startMatchFailed": "Sending the match request failed. Please check your connection and try again." + "startMatchFailed": "Sending the match request failed. Please check your connection and try again.", + "moveToEnd": "Move to the End" }, "match": { "title": "New Match", diff --git a/src/translations/locales/fa.json b/src/translations/locales/fa.json index 2d4c57c..ee88a6a 100644 --- a/src/translations/locales/fa.json +++ b/src/translations/locales/fa.json @@ -10,7 +10,10 @@ "back": "بازگشت", "required": "ضروری", "estimateTime": "زمان تقریبی", - "rangeError": "مقدار وارد شده صحیح به نظر نمی‌رسد. لطفاً یک عدد واقعی وارد کنید." + "rangeError": "مقدار وارد شده صحیح به نظر نمی‌رسد. لطفاً یک عدد واقعی وارد کنید.", + "help": "راهنما", + "gotIt": "متوجه شدم", + "helpDescription": "این بخش به شما کمک می‌کند تا مراحل ثبت‌نام و پاسخ به سوالات را به صورت دقیق و آگاهانه تکمیل نمایید. تمامی اطلاعات شما نزد ما محرمانه باقی می‌ماند." }, "intro": { "imageAlt": "ازدواج آسمانی", @@ -52,7 +55,8 @@ "با شناسایی قوی‌ترین نیازهای خود، می‌توانید انتظارات خود را بهتر بیان کنید و روابط سالم‌تری بسازید." ] }, - "startMatchFailed": "ارسال درخواست مچ انجام نشد. اتصال خود را بررسی کنید و دوباره تلاش کنید." + "startMatchFailed": "ارسال درخواست مچ انجام نشد. اتصال خود را بررسی کنید و دوباره تلاش کنید.", + "moveToEnd": "Move to the End" }, "match": { "title": "گزینه جدید",