Browse Source

feat: implement reusable navigation and action button components with modal and countdown support

front-test-2
ghorbani 3 weeks ago
parent
commit
4ae71fb836
  1. BIN
      public/assets/images/diamond-black.png
  2. BIN
      public/assets/images/diamond-color.png
  3. BIN
      public/assets/images/diamond-gradient.png
  4. BIN
      public/assets/images/diamond-outline.png
  5. 22
      src/app/globals.css
  6. 4
      src/app/new-match/profile/page.tsx
  7. 2
      src/app/questions-list/[slug]/page.tsx
  8. 9
      src/app/questions-list/[slug]/question-detail-client.tsx
  9. 4
      src/app/questions-list/sections-request.tsx
  10. 22
      src/components/questions/question-exit-navigation-button.tsx
  11. 21
      src/components/questions/question-section-flow.tsx
  12. 103
      src/components/questions/question-snap-list.tsx
  13. 2
      src/components/ui/button.tsx
  14. 173
      src/components/ui/help-modal.tsx
  15. 88
      src/components/ui/navigation-button.tsx
  16. 22
      src/components/ui/token-switcher.tsx
  17. 6
      src/translations/locales/en.json
  18. 8
      src/translations/locales/fa.json

BIN
public/assets/images/diamond-black.png

After

Width: 25  |  Height: 25  |  Size: 744 B

BIN
public/assets/images/diamond-color.png

After

Width: 25  |  Height: 25  |  Size: 1.3 KiB

BIN
public/assets/images/diamond-gradient.png

After

Width: 25  |  Height: 25  |  Size: 1.3 KiB

BIN
public/assets/images/diamond-outline.png

After

Width: 25  |  Height: 25  |  Size: 744 B

22
src/app/globals.css

@ -161,29 +161,29 @@ body[data-page-background="custom"] .app-shell {
0 0 0 1px rgb(0 0 0 / 8%); 0 0 0 1px rgb(0 0 0 / 8%);
} }
/* 3 Dots Pulsing Wave Loader for Buttons & App */
@keyframes button-dot-slide {
0%, 100% {
transform: scale(0.35);
opacity: 0.35;
/* 3 Dots Bouncing Line Circle Loader for Buttons */
@keyframes button-dot-bounce {
0%, 80%, 100% {
transform: translateY(0) scale(0.7);
opacity: 0.4;
} }
40% { 40% {
transform: scale(1.25);
transform: translateY(-4px) scale(1.15);
opacity: 1; opacity: 1;
} }
} }
.animate-dots-slide-1 { .animate-dots-slide-1 {
animation: button-dot-slide 0.9s cubic-bezier(0.45, 0.05, 0.55, 0.95) infinite;
animation: button-dot-bounce 0.9s cubic-bezier(0.4, 0, 0.6, 1) infinite;
animation-delay: 0s; animation-delay: 0s;
} }
.animate-dots-slide-2 { .animate-dots-slide-2 {
animation: button-dot-slide 0.9s cubic-bezier(0.45, 0.05, 0.55, 0.95) infinite;
animation-delay: 0.18s;
animation: button-dot-bounce 0.9s cubic-bezier(0.4, 0, 0.6, 1) infinite;
animation-delay: 0.15s;
} }
.animate-dots-slide-3 { .animate-dots-slide-3 {
animation: button-dot-slide 0.9s cubic-bezier(0.45, 0.05, 0.55, 0.95) infinite;
animation-delay: 0.36s;
animation: button-dot-bounce 0.9s cubic-bezier(0.4, 0, 0.6, 1) infinite;
animation-delay: 0.3s;
} }

4
src/app/new-match/profile/page.tsx

@ -472,7 +472,7 @@ export default function NewMatchProfilePage() {
type="button" type="button"
disabled={!caseId || isSubmitting || isMaleAccepted} disabled={!caseId || isSubmitting || isMaleAccepted}
onClick={() => setIsRejectSheetOpen(true)} onClick={() => 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 Reject
</button> </button>
@ -490,7 +490,7 @@ export default function NewMatchProfilePage() {
setIsRequestSheetOpen(true); 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"
> >
<Image <Image
src="/assets/images/Icfdason.svg" src="/assets/images/Icfdason.svg"

2
src/app/questions-list/[slug]/page.tsx

@ -50,7 +50,7 @@ export default async function QuestionDetailPage({
description={t.questions.answerAtYourOwnPaceDescription} description={t.questions.answerAtYourOwnPaceDescription}
closeLabel={t.questions.closeQuestionsList} closeLabel={t.questions.closeQuestionsList}
informationLabel={t.sheets.informationSheet} informationLabel={t.sheets.informationSheet}
continueLabel={t.common.continue}
continueLabel={t.common.submit}
/> />
); );
} }

9
src/app/questions-list/[slug]/question-detail-client.tsx

@ -652,7 +652,7 @@ export default function QuestionDetailClient({
icon="close" icon="close"
iconLabel={closeLabel} iconLabel={closeLabel}
/> />
<h1 className="min-w-0 flex-1 text-center font-semibold text-white truncate">
<h1 className="min-w-0 flex-1 text-center text-[14px] font-semibold text-white truncate">
{item.title} {item.title}
</h1> </h1>
<NavigationButton <NavigationButton
@ -660,6 +660,8 @@ export default function QuestionDetailClient({
variant="transparent" variant="transparent"
icon="info" icon="info"
iconLabel={informationLabel} iconLabel={informationLabel}
helpTitle={item.title}
helpDescription={description}
/> />
</div> </div>
</StickyHeader> </StickyHeader>
@ -709,8 +711,9 @@ export default function QuestionDetailClient({
variant="transparent" variant="transparent"
icon="close" icon="close"
iconLabel={closeLabel} iconLabel={closeLabel}
exitHref={questionsListHref}
/> />
<h1 className="min-w-0 flex-1 text-center font-semibold text-white truncate">
<h1 className="min-w-0 flex-1 text-center text-[14px] font-semibold text-white truncate">
{item.title} {item.title}
</h1> </h1>
<NavigationButton <NavigationButton
@ -718,6 +721,8 @@ export default function QuestionDetailClient({
variant="transparent" variant="transparent"
icon="info" icon="info"
iconLabel={informationLabel} iconLabel={informationLabel}
helpTitle={item.title}
helpDescription={description}
/> />
</div> </div>
</StickyHeader> </StickyHeader>

4
src/app/questions-list/sections-request.tsx

@ -58,7 +58,7 @@ export default function SectionsRequest() {
icon={null} icon={null}
title={({ close }) => ( title={({ close }) => (
<span className="flex w-full items-start justify-between gap-3 text-left"> <span className="flex w-full items-start justify-between gap-3 text-left">
<span className="text-[13px] leading-5 font-bold tracking-normal text-[#8B8B8B]">
<span className="text-[14px] leading-5 font-bold tracking-normal text-[#8B8B8B]">
Bookings Terms &amp; Conditions Bookings Terms &amp; Conditions
</span> </span>
<button <button
@ -72,7 +72,7 @@ export default function SectionsRequest() {
</span> </span>
)} )}
description={ description={
<ul className="max-h-[60dvh] space-y-2 overflow-y-auto pl-4 text-left text-[11px] leading-[1.35] font-medium list-disc text-[#4C4C4C] marker:text-[#2B2B2B]">
<ul className="max-h-[60dvh] space-y-2 overflow-y-auto pl-4 text-left text-[12px] leading-[1.35] font-medium list-disc text-[#4C4C4C] marker:text-[#2B2B2B]">
{FIRST_ENTRY_TERMS.map((item, index) => ( {FIRST_ENTRY_TERMS.map((item, index) => (
<li key={`${index}-${item}`}>{item}</li> <li key={`${index}-${item}`}>{item}</li>
))} ))}

22
src/components/questions/question-exit-navigation-button.tsx

@ -6,9 +6,19 @@ import NavigationButton, {
type NavigationButtonProps, type NavigationButtonProps,
} from "@/components/ui/navigation-button"; } from "@/components/ui/navigation-button";
import { useQuestionAnswers } from "./question-answer-storage"; import { useQuestionAnswers } from "./question-answer-storage";
import { localizePath } from "@/translations/config";
import { useI18n } from "@/translations/provider";
export function QuestionExitNavigationButton(props: NavigationButtonProps) {
export type QuestionExitNavigationButtonProps = NavigationButtonProps & {
exitHref?: string;
};
export function QuestionExitNavigationButton({
exitHref,
...props
}: QuestionExitNavigationButtonProps) {
const router = useRouter(); const router = useRouter();
const { locale } = useI18n();
const { flushAnswers } = useQuestionAnswers(); const { flushAnswers } = useQuestionAnswers();
const [isLeaving, setIsLeaving] = useState(false); const [isLeaving, setIsLeaving] = useState(false);
@ -27,9 +37,15 @@ export function QuestionExitNavigationButton(props: NavigationButtonProps) {
setIsLeaving(true); setIsLeaving(true);
try { try {
await flushAnswers({ force: true });
await Promise.race([
flushAnswers({ force: true }),
new Promise((resolve) => setTimeout(resolve, 500)),
]);
} catch {
// ignore
} finally { } finally {
router.back();
const target = localizePath(exitHref || "/questions-list", locale);
router.push(target);
} }
}} }}
/> />

21
src/components/questions/question-section-flow.tsx

@ -11,6 +11,9 @@ import QuestionProgressTracker, {
} from "./question-progress-tracker"; } from "./question-progress-tracker";
import QuestionSnapList from "./question-snap-list"; import QuestionSnapList from "./question-snap-list";
import { localizePath } from "@/translations/config";
import { useI18n } from "@/translations/provider";
type QuestionSectionFlowProps = { type QuestionSectionFlowProps = {
children: ReactNode; children: ReactNode;
continueLabel: string; continueLabel: string;
@ -31,7 +34,8 @@ function SectionFlowContent({
optionalQuestionIndexes: readonly number[]; optionalQuestionIndexes: readonly number[];
}) { }) {
const router = useRouter(); const router = useRouter();
const { flushAnswers, isSaving } = useQuestionAnswers();
const { locale } = useI18n();
const { flushAnswers } = useQuestionAnswers();
const { isCompleted, markQuestionPassed } = useQuestionProgress(); const { isCompleted, markQuestionPassed } = useQuestionProgress();
const [isLeaving, setIsLeaving] = useState(false); const [isLeaving, setIsLeaving] = useState(false);
@ -55,13 +59,19 @@ function SectionFlowContent({
setIsLeaving(true); setIsLeaving(true);
try { try {
await flushAnswers({ force: true });
await Promise.race([
flushAnswers({ force: true }),
new Promise((resolve) => setTimeout(resolve, 800)),
]);
} catch {
// Ignore background sync errors during transition
} finally { } finally {
router.replace(exitHref);
const targetHref = localizePath(exitHref || "/questions-list", locale);
router.push(targetHref);
} }
}, [exitHref, flushAnswers, isCompleted, router]);
}, [exitHref, flushAnswers, isCompleted, locale, router]);
const isDisabled = !isCompleted || isSaving || isLeaving;
const isDisabled = !isCompleted || isLeaving;
return ( return (
<QuestionSnapList <QuestionSnapList
@ -83,7 +93,6 @@ function SectionFlowContent({
: "!bg-linear-to-r !from-[#F2465F] !to-[#E03950] !text-white !opacity-100 shadow-[0_12px_28px_rgba(242,70,95,0.38)] cursor-pointer hover:brightness-105 active:scale-[0.99]", : "!bg-linear-to-r !from-[#F2465F] !to-[#E03950] !text-white !opacity-100 shadow-[0_12px_28px_rgba(242,70,95,0.38)] cursor-pointer hover:brightness-105 active:scale-[0.99]",
].join(" ")} ].join(" ")}
disabled={isDisabled} disabled={isDisabled}
isLoading={isSaving || isLeaving}
onClick={() => void handleContinue()} onClick={() => void handleContinue()}
> >
{continueLabel} {continueLabel}

103
src/components/questions/question-snap-list.tsx

@ -10,6 +10,10 @@ import {
useState, useState,
} from "react"; } from "react";
import { GoArrowDown } from "react-icons/go";
import { useI18n } from "@/translations/provider";
import { useQuestionProgress } from "./question-progress-tracker";
const WHEEL_GESTURE_IDLE_MS = 320; const WHEEL_GESTURE_IDLE_MS = 320;
const TOUCH_MIN_DISTANCE = 8; const TOUCH_MIN_DISTANCE = 8;
const AUTO_FOCUS_SELECTOR = [ const AUTO_FOCUS_SELECTOR = [
@ -35,6 +39,8 @@ export function QuestionSnapList({
onQuestionExit, onQuestionExit,
onQuestionTransition, onQuestionTransition,
}: QuestionSnapListProps) { }: QuestionSnapListProps) {
const { dictionary: t } = useI18n();
const { isCompleted } = useQuestionProgress();
const questions = Children.toArray(children); const questions = Children.toArray(children);
const wheelLockedRef = useRef(false); const wheelLockedRef = useRef(false);
const wheelUnlockTimeoutRef = useRef<number | null>(null); const wheelUnlockTimeoutRef = useRef<number | null>(null);
@ -43,6 +49,22 @@ export function QuestionSnapList({
const previousActiveIndexRef = useRef<number | null>(null); const previousActiveIndexRef = useRef<number | null>(null);
const [activeIndex, setActiveIndex] = useState(0); const [activeIndex, setActiveIndex] = useState(0);
// Track whether section was already completed on mount
const wasCompletedOnMountRef = useRef<boolean | null>(null);
const [justCompleted, setJustCompleted] = useState(false);
useEffect(() => {
if (wasCompletedOnMountRef.current === null) {
// First time: capture the initial state
wasCompletedOnMountRef.current = isCompleted;
return;
}
// Only show button if it was NOT completed on mount and now becomes completed
if (!wasCompletedOnMountRef.current && isCompleted) {
setJustCompleted(true);
}
}, [isCompleted]);
const stepQuestion = useCallback( const stepQuestion = useCallback(
(direction: 1 | -1) => { (direction: 1 | -1) => {
const nextIndex = Math.max( const nextIndex = Math.max(
@ -74,6 +96,73 @@ export function QuestionSnapList({
}, WHEEL_GESTURE_IDLE_MS); }, WHEEL_GESTURE_IDLE_MS);
}, []); }, []);
const hasInitializedActiveIndexRef = useRef(false);
useEffect(() => {
if (hasInitializedActiveIndexRef.current || questions.length === 0) {
return;
}
const timerId = window.setTimeout(() => {
if (hasInitializedActiveIndexRef.current) {
return;
}
for (let index = 0; index < questions.length; index += 1) {
const questionElement = questionRefs.current[index];
if (!questionElement) {
continue;
}
const isDisabled =
questionElement.getAttribute("data-question-disabled") === "true" ||
questionElement.querySelector("[data-question-disabled='true']") !== null;
if (isDisabled) {
continue;
}
const explicitAnsweredState =
questionElement.getAttribute("data-question-answered") ??
questionElement
.querySelector("[data-question-answered]")
?.getAttribute("data-question-answered");
const isAnswered =
explicitAnsweredState === "true" ||
(explicitAnsweredState !== "false" &&
Array.from(
questionElement.querySelectorAll<
HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
>("input, select, textarea"),
).some((input) => {
if (input instanceof HTMLInputElement) {
if (input.type === "checkbox" || input.type === "radio") {
return input.checked;
}
if (input.type === "file") {
return input.files !== null && input.files.length > 0;
}
}
return input.value.trim().length > 0;
}));
if (!isAnswered) {
hasInitializedActiveIndexRef.current = true;
setActiveIndex(index);
return;
}
}
hasInitializedActiveIndexRef.current = true;
}, 50);
return () => {
window.clearTimeout(timerId);
};
}, [questions.length]);
useEffect(() => { useEffect(() => {
const previousActiveIndex = previousActiveIndexRef.current; const previousActiveIndex = previousActiveIndexRef.current;
@ -296,6 +385,20 @@ export function QuestionSnapList({
{firstQuestionHint} {firstQuestionHint}
</div> </div>
) : null} ) : null}
{justCompleted && activeIndex < questions.length - 1 ? (
<button
type="button"
onClick={() => {
onQuestionExit?.(activeIndex, questions.length - 1);
onQuestionTransition?.(activeIndex, questions.length - 1);
setActiveIndex(questions.length - 1);
}}
className="fixed right-[max(16px,calc(50%-170px))] bottom-6 z-30 inline-flex items-center gap-1.5 rounded-full bg-[#1B1B1B]/90 px-3.5 py-2 text-xs font-semibold text-white shadow-[0_8px_20px_rgba(0,0,0,0.22)] backdrop-blur-md transition-all duration-300 hover:bg-[#1B1B1B] active:scale-95 cursor-pointer animate-in fade-in slide-in-from-right-4"
>
<GoArrowDown className="size-3.5 motion-safe:animate-bounce" />
<span>{t.questions?.moveToEnd ?? "Move to the End"}</span>
</button>
) : null}
</section> </section>
); );
} }

2
src/components/ui/button.tsx

@ -120,9 +120,9 @@ export function Button({
widthClass, widthClass,
"items-center", "items-center",
"justify-center", "justify-center",
"h-[52px]",
"rounded-[11px]", "rounded-[11px]",
"px-4", "px-4",
countdownLocked ? "py-[9px]" : "py-[14px]",
"text-center", "text-center",
"transition-opacity", "transition-opacity",
isOutlined isOutlined

173
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(
<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;

88
src/components/ui/navigation-button.tsx

@ -2,9 +2,11 @@
import Image from "next/image"; import Image from "next/image";
import { useRouter } from "next/navigation"; 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 { GoArrowLeft } from "react-icons/go";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
import HelpModal from "@/components/ui/help-modal";
type NavigationButtonIcon = type NavigationButtonIcon =
| "back" | "back"
@ -22,6 +24,10 @@ export type NavigationButtonProps = Omit<
icon: NavigationButtonIcon; icon: NavigationButtonIcon;
iconLabel?: string; iconLabel?: string;
variant?: NavigationButtonVariant; variant?: NavigationButtonVariant;
helpTitle?: ReactNode;
helpDescription?: ReactNode;
helpButtonText?: ReactNode;
disableHelpModal?: boolean;
}; };
export function NavigationButton({ export function NavigationButton({
@ -30,10 +36,16 @@ export function NavigationButton({
variant = "default", variant = "default",
type = "button", type = "button",
className, className,
helpTitle,
helpDescription,
helpButtonText,
disableHelpModal = false,
...props ...props
}: NavigationButtonProps) { }: NavigationButtonProps) {
const router = useRouter(); const router = useRouter();
const { dictionary: t } = useI18n(); const { dictionary: t } = useI18n();
const [isHelpOpen, setIsHelpOpen] = useState(false);
const iconNode = (() => { const iconNode = (() => {
switch (icon) { switch (icon) {
case "back": case "back":
@ -93,38 +105,56 @@ export function NavigationButton({
} }
})(); })();
const isHelpTrigger = icon === "info" || helpTitle !== undefined || helpDescription !== undefined;
return ( return (
<button
{...props}
type={type}
aria-label={iconLabel ?? (icon === "back" ? t.common.back : icon)}
onClick={(event) => {
props.onClick?.(event);
<>
<button
{...props}
type={type}
aria-label={iconLabel ?? (icon === "back" ? t.common.back : icon)}
onClick={(event) => {
props.onClick?.(event);
if (!event.defaultPrevented && !props.onClick) {
if (icon === "back") {
if (typeof window !== "undefined" && (window as any).HabibApp) {
(window as any).HabibApp.postMessage(
JSON.stringify({ action: "close_service" })
);
} else {
router.back();
if (!event.defaultPrevented) {
if (isHelpTrigger && !disableHelpModal) {
setIsHelpOpen(true);
} else if (!props.onClick) {
if (icon === "back") {
if (typeof window !== "undefined" && (window as any).HabibApp) {
(window as any).HabibApp.postMessage(
JSON.stringify({ action: "close_service" })
);
} else {
router.back();
}
} else if (icon === "close") {
router.back();
}
} }
} else if (icon === "close") {
router.back();
} }
}
}}
className={[
"inline-flex items-center justify-center rounded-[15px] p-2",
variant === "transparent" ? "bg-white/20" : "bg-[#FFFFFF]",
className,
]
.filter(Boolean)
.join(" ")}
>
{iconNode}
</button>
}}
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}
</button>
{isHelpTrigger && !disableHelpModal ? (
<HelpModal
isOpen={isHelpOpen}
onClose={() => setIsHelpOpen(false)}
title={helpTitle}
description={helpDescription}
buttonText={helpButtonText}
/>
) : null}
</>
); );
} }

22
src/components/ui/token-switcher.tsx

@ -7,6 +7,9 @@ import { setClientCookie, getClientCookie } from "@/lib/cookies";
export const FEMALE_TOKEN = "545f61bb3e061ccb9b19f84715eb1b1ed4740331"; export const FEMALE_TOKEN = "545f61bb3e061ccb9b19f84715eb1b1ed4740331";
export const MALE_TOKEN = "f3a7543b44ef0a713d1ee0d4f7866b3825cf1308"; 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"; const TOKEN_COOKIE_NAME = "HABIB_TOKEN";
type TokenSwitcherProps = { type TokenSwitcherProps = {
@ -39,11 +42,12 @@ export function TokenSwitcher({
const handleSelectToken = (newToken: string) => { const handleSelectToken = (newToken: string) => {
if (typeof window !== "undefined") { 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 { try {
window.localStorage.clear(); window.localStorage.clear();
window.sessionStorage.clear();
} catch (e) { } catch (e) {
console.error("Failed to clear localStorage", e);
console.error("Failed to clear storage", e);
} }
setClientCookie(TOKEN_COOKIE_NAME, newToken); setClientCookie(TOKEN_COOKIE_NAME, newToken);
@ -115,8 +119,11 @@ export function TokenSwitcher({
<span className="text-2xl">👨</span> <span className="text-2xl">👨</span>
<div> <div>
<div className="text-sm font-bold">حساب آقا</div> <div className="text-sm font-bold">حساب آقا</div>
<div className="text-[11px] font-mono text-slate-400 dark:text-slate-500 dir-ltr text-left">
f3a7543b...308
<div className="text-xs text-slate-500 dark:text-slate-400 font-medium">
{MALE_EMAIL}
</div>
<div className="text-[11px] font-mono text-slate-400 dark:text-slate-500 dir-ltr text-left mt-0.5 select-all">
{MALE_TOKEN}
</div> </div>
</div> </div>
</div> </div>
@ -142,8 +149,11 @@ export function TokenSwitcher({
<span className="text-2xl">👩</span> <span className="text-2xl">👩</span>
<div> <div>
<div className="text-sm font-bold">حساب خانم</div> <div className="text-sm font-bold">حساب خانم</div>
<div className="text-[11px] font-mono text-slate-400 dark:text-slate-500 dir-ltr text-left">
545f61bb...331
<div className="text-xs text-slate-500 dark:text-slate-400 font-medium">
{FEMALE_EMAIL}
</div>
<div className="text-[11px] font-mono text-slate-400 dark:text-slate-500 dir-ltr text-left mt-0.5 select-all">
{FEMALE_TOKEN}
</div> </div>
</div> </div>
</div> </div>

6
src/translations/locales/en.json

@ -11,6 +11,9 @@
"required": "Required", "required": "Required",
"estimateTime": "Estimate time", "estimateTime": "Estimate time",
"rangeError": "The value entered seems incorrect. Please provide a realistic value.", "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", "finalMatchIntroduce": "Final Match Introduction",
"confirmFinalMatch": "Confirm Final Match", "confirmFinalMatch": "Confirm Final Match",
"confirmFinalMatchQuestion": "Are you sure you want to officially introduce these two candidates to each other?", "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." "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": { "match": {
"title": "New Match", "title": "New Match",

8
src/translations/locales/fa.json

@ -10,7 +10,10 @@
"back": "بازگشت", "back": "بازگشت",
"required": "ضروری", "required": "ضروری",
"estimateTime": "زمان تقریبی", "estimateTime": "زمان تقریبی",
"rangeError": "مقدار وارد شده صحیح به نظر نمی‌رسد. لطفاً یک عدد واقعی وارد کنید."
"rangeError": "مقدار وارد شده صحیح به نظر نمی‌رسد. لطفاً یک عدد واقعی وارد کنید.",
"help": "راهنما",
"gotIt": "متوجه شدم",
"helpDescription": "این بخش به شما کمک می‌کند تا مراحل ثبت‌نام و پاسخ به سوالات را به صورت دقیق و آگاهانه تکمیل نمایید. تمامی اطلاعات شما نزد ما محرمانه باقی می‌ماند."
}, },
"intro": { "intro": {
"imageAlt": "ازدواج آسمانی", "imageAlt": "ازدواج آسمانی",
@ -52,7 +55,8 @@
"با شناسایی قوی‌ترین نیازهای خود، می‌توانید انتظارات خود را بهتر بیان کنید و روابط سالم‌تری بسازید." "با شناسایی قوی‌ترین نیازهای خود، می‌توانید انتظارات خود را بهتر بیان کنید و روابط سالم‌تری بسازید."
] ]
}, },
"startMatchFailed": "ارسال درخواست مچ انجام نشد. اتصال خود را بررسی کنید و دوباره تلاش کنید."
"startMatchFailed": "ارسال درخواست مچ انجام نشد. اتصال خود را بررسی کنید و دوباره تلاش کنید.",
"moveToEnd": "Move to the End"
}, },
"match": { "match": {
"title": "گزینه جدید", "title": "گزینه جدید",

Loading…
Cancel
Save