Browse Source

fix(marriage): optimize multi-select sheet local state, silent retry with ErrorToast on Continue

master
mortezaei 3 days ago
parent
commit
c0a380596e
  1. 60
      src/components/Componentes/error-toast.tsx
  2. 47
      src/components/Componentes/question-section-flow.tsx
  3. 65
      src/components/Componentes/question-sheet.tsx
  4. 4
      src/components/Componentes/slider-page.test.tsx
  5. 29
      src/components/Componentes/slider-page.tsx

60
src/components/Componentes/error-toast.tsx

@ -1,16 +1,18 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { IoAlertCircle, IoClose, IoCheckmarkCircle } from "react-icons/io5";
import { IoClose } from "react-icons/io5";
type ErrorToastProps = { type ErrorToastProps = {
title?: string;
message: string; message: string;
onClose: () => void; onClose: () => void;
duration?: number; duration?: number;
variant?: "error" | "success";
variant?: "error" | "success" | "warning" | "info";
}; };
export default function ErrorToast({ export default function ErrorToast({
title,
message, message,
onClose, onClose,
duration = 4000, duration = 4000,
@ -32,42 +34,56 @@ export default function ErrorToast({
setTimeout(onClose, 300); setTimeout(onClose, 300);
}; };
const isSuccess = variant === "success";
const getBorderColor = () => {
switch (variant) {
case "success":
return "border-t-[#10B981]";
case "warning":
return "border-t-[#F59E0B]";
case "info":
return "border-t-[#3B82F6]";
case "error":
default:
return "border-t-[#F0445B]";
}
};
return ( return (
<div <div
className={[ className={[
"fixed top-4 left-1/2 z-50 flex w-[calc(100%-34px)] max-w-[341px] -translate-x-1/2 items-center gap-3 rounded-xl border p-4 shadow-lg backdrop-blur-md transition-all duration-300",
"fixed top-3.5 left-1/2 z-50 flex w-[calc(100%-32px)] max-w-[360px] -translate-x-1/2 items-start justify-between gap-3 rounded-[16px] bg-[#141414]/95 p-3.5 px-4 text-white shadow-[0_10px_30px_rgba(0,0,0,0.35)] backdrop-blur-md border border-white/5 border-t-[2.5px] transition-all duration-300 ease-out",
getBorderColor(),
isVisible isVisible
? "translate-y-0 opacity-100 scale-100" ? "translate-y-0 opacity-100 scale-100"
: "-translate-y-4 opacity-0 scale-95", : "-translate-y-4 opacity-0 scale-95",
isSuccess
? "bg-green-50/95 dark:bg-green-950/95 text-green-800 dark:text-green-200 border-green-100/80 dark:border-green-900/50"
: "bg-red-50/95 dark:bg-red-950/95 text-red-800 dark:text-red-200 border-red-100/80 dark:border-red-900/50",
].join(" ")} ].join(" ")}
role="alert" role="alert"
> >
{isSuccess ? (
<IoCheckmarkCircle className="size-5 shrink-0 text-green-500" />
) : (
<IoAlertCircle className="size-5 shrink-0 text-red-500" />
)}
<span className="flex-1 text-sm font-semibold leading-normal">
{message}
</span>
<div className="flex flex-1 flex-col text-start min-w-0">
{title && (
<span className="text-[15px] font-bold text-white leading-tight truncate mb-1">
{title}
</span>
)}
<span
className={[
"text-[#E0E0E0] leading-snug break-words",
title ? "text-[13px] text-white/80" : "text-[14px] font-medium text-white",
].join(" ")}
>
{message}
</span>
</div>
<button <button
type="button" type="button"
onClick={handleClose} onClick={handleClose}
className={[
"flex size-6 items-center justify-center rounded-lg transition-colors",
isSuccess
? "text-green-500 hover:bg-green-100/50 dark:hover:bg-green-900/50"
: "text-red-500 hover:bg-red-100/50 dark:hover:bg-red-900/50",
].join(" ")}
className="flex size-7 shrink-0 items-center justify-center rounded-lg text-white/70 hover:text-white transition-colors cursor-pointer self-start -me-1 -mt-0.5"
aria-label="Close toast" aria-label="Close toast"
> >
<IoClose className="size-4" />
<IoClose className="size-5" />
</button> </button>
</div> </div>
); );
} }

47
src/components/Componentes/question-section-flow.tsx

@ -12,6 +12,7 @@ import QuestionProgressTracker, {
} from "./question-progress-tracker"; } from "./question-progress-tracker";
import QuestionSnapList from "./question-snap-list"; import QuestionSnapList from "./question-snap-list";
import type { QuestionField } from "@/lib/schema-adapter"; import type { QuestionField } from "@/lib/schema-adapter";
import ErrorToast from "./error-toast";
import NoticeBox from "./notice-box"; import NoticeBox from "./notice-box";
import AnswerPaceSheet from "@/app/questions-list/[slug]/answer-pace-sheet"; import AnswerPaceSheet from "@/app/questions-list/[slug]/answer-pace-sheet";
import { FixToTheEnd } from "./fix-to-the-end"; import { FixToTheEnd } from "./fix-to-the-end";
@ -50,6 +51,7 @@ function SectionFlowContent({
const { markQuestionPassed, isCompleted } = useQuestionProgress(); const { markQuestionPassed, isCompleted } = useQuestionProgress();
const [activeQuestionIndex, setActiveQuestionIndex] = useState(0); const [activeQuestionIndex, setActiveQuestionIndex] = useState(0);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const handleQuestionExit = useCallback(() => { const handleQuestionExit = useCallback(() => {
void flushAnswers({ force: true }); void flushAnswers({ force: true });
@ -60,19 +62,46 @@ function SectionFlowContent({
return; return;
} }
setIsSubmitting(true); setIsSubmitting(true);
setErrorMessage(null);
try {
const MAX_RETRIES = 3;
let success = false;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
await flushAnswers({ force: true });
success = true;
break;
} catch (err) {
console.warn(
`[CONTINUE] flushAnswers attempt ${attempt}/${MAX_RETRIES} failed:`,
err,
);
if (attempt < MAX_RETRIES) {
// Silent delay between retries while maintaining loading spinner
await new Promise((resolve) => setTimeout(resolve, 800));
}
}
}
if (success) {
markFirstEntryCompleted(); markFirstEntryCompleted();
await flushAnswers({ force: true });
} catch {
// ignore
} finally {
if (onExit) { if (onExit) {
onExit(); onExit();
} else { } else {
const target = localizePath(exitHref || "/questions-list", locale); const target = localizePath(exitHref || "/questions-list", locale);
router.replace(target); router.replace(target);
} }
} else {
setIsSubmitting(false);
const isPersian = locale === "fa" || locale === "fa-ir";
const isArabic = locale === "ar";
const msg = isPersian
? "خطا در اتصال به اینترنت. پاسخ‌ها با سرور همگام نشدند؛ لطفاً اتصال خود را بررسی و دوباره روی ادامه بزنید."
: isArabic
? "خطأ في الاتصال بالإنترنت. تعذر مزامنة الإجابات مع الخادم؛ يرجى التحقق من الاتصال والمحاولة مرة أخرى."
: "Internet connection error. Answers could not be synced with server. Please check your connection and tap Continue again.";
setErrorMessage(msg);
} }
}, [exitHref, onExit, flushAnswers, locale, router, isSubmitting]); }, [exitHref, onExit, flushAnswers, locale, router, isSubmitting]);
@ -137,6 +166,14 @@ function SectionFlowContent({
{process.env.NODE_ENV === "development" ? ( {process.env.NODE_ENV === "development" ? (
<DevTapInstrumentation /> <DevTapInstrumentation />
) : null} ) : null}
{errorMessage && (
<ErrorToast
message={errorMessage}
onClose={() => setErrorMessage(null)}
duration={5000}
variant="error"
/>
)}
<FixToTheEnd> <FixToTheEnd>
<Button <Button
disabled={!isCompleted || isSubmitting} disabled={!isCompleted || isSubmitting}

65
src/components/Componentes/question-sheet.tsx

@ -42,22 +42,45 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const listRef = useRef<HTMLDivElement>(null); const listRef = useRef<HTMLDivElement>(null);
const sheetRef = useRef<HTMLElement>(null); const sheetRef = useRef<HTMLElement>(null);
const [localSelectedList, setLocalSelectedList] = useState<string[]>(selectedList);
const localSelectedListRef = useRef<string[]>(selectedList);
useEffect(() => {
localSelectedListRef.current = localSelectedList;
}, [localSelectedList]);
useEffect(() => {
if (!isOpen) {
setLocalSelectedList(selectedList);
localSelectedListRef.current = selectedList;
}
}, [isOpen, selectedList]);
const closeSheet = useCallback(() => { const closeSheet = useCallback(() => {
setIsClosing(true); setIsClosing(true);
if (isMulti) {
const currentList = localSelectedListRef.current;
setAnswerValue(
question,
currentList.length > 0 ? currentList : null,
);
}
window.setTimeout(() => { window.setTimeout(() => {
setIsOpen(false); setIsOpen(false);
setIsClosing(false); setIsClosing(false);
setSearchQuery(""); setSearchQuery("");
}, EXIT_ANIMATION_MS); }, EXIT_ANIMATION_MS);
}, []);
}, [isMulti, question, setAnswerValue]);
const openSheet = useCallback(() => { const openSheet = useCallback(() => {
if (disabled) return; if (disabled) return;
setLocalSelectedList(selectedList);
localSelectedListRef.current = selectedList;
setIsOpen(true); setIsOpen(true);
setIsClosing(false); setIsClosing(false);
}, [disabled]);
}, [disabled, selectedList]);
useSheetScrollLock(isOpen, { onBack: closeSheet });
useSheetScrollLock(isOpen && !isClosing, { onBack: closeSheet });
// Handle escape key // Handle escape key
useEffect(() => { useEffect(() => {
@ -275,13 +298,25 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
: Boolean(singleValue); : Boolean(singleValue);
const toggleMultiOption = (optionId: string) => { const toggleMultiOption = (optionId: string) => {
let nextValue: string[];
if (selectedList.includes(optionId)) {
nextValue = selectedList.filter((v) => v !== optionId);
} else {
nextValue = [...selectedList, optionId];
}
setAnswerValue(question, nextValue.length > 0 ? nextValue : null);
setLocalSelectedList((prev) => {
let nextValue: string[];
if (prev.includes(optionId)) {
nextValue = prev.filter((v) => v !== optionId);
} else {
nextValue = [...prev, optionId];
}
localSelectedListRef.current = nextValue;
return nextValue;
});
};
const handleConfirmMulti = () => {
const currentList = localSelectedListRef.current;
setAnswerValue(
question,
currentList.length > 0 ? currentList : null,
);
closeSheet();
}; };
const handleSelectSingle = (optionId: string) => { const handleSelectSingle = (optionId: string) => {
@ -476,7 +511,7 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
{filteredOptions.length > 0 ? ( {filteredOptions.length > 0 ? (
filteredOptions.map((option) => { filteredOptions.map((option) => {
const isSelected = isMulti const isSelected = isMulti
? selectedList.includes(option.id)
? localSelectedList.includes(option.id)
: singleValue === option.id; : singleValue === option.id;
return ( return (
@ -581,11 +616,11 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
<Button <Button
type="button" type="button"
variant="default" variant="default"
onClick={closeSheet}
className="w-full h-[50px] rounded-[14px] text-[15px] font-bold shadow-[0_8px_20px_rgba(240,68,91,0.25)]"
onClick={handleConfirmMulti}
className="w-full h-[50px] rounded-[14px] text-[15px] font-bold shadow-[0_8px_20px_rgba(240,68,91,0.25)] cursor-pointer"
> >
{selectedList.length > 0
? `${confirmText} (${selectedList.length})`
{localSelectedList.length > 0
? `${confirmText} (${localSelectedList.length})`
: confirmText} : confirmText}
</Button> </Button>
</div> </div>

4
src/components/Componentes/slider-page.test.tsx

@ -41,7 +41,7 @@ vi.mock('@/translations/provider', () => ({
useI18n: () => ({ useI18n: () => ({
locale: 'en', locale: 'en',
dictionary: { dictionary: {
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again.",
"Something went wrong. Please check your internet connection and try again.": "Something went wrong. Please check your internet connection and try again.",
"Accept & Continue": "Accept & Continue", "Accept & Continue": "Accept & Continue",
}, },
}), }),
@ -134,7 +134,7 @@ describe('SliderPage', () => {
fireEvent.click(finishBtn); fireEvent.click(finishBtn);
await waitFor(() => { await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('Failed to update profile basic details. Please try again.');
expect(screen.getByRole('alert')).toHaveTextContent('Something went wrong. Please check your internet connection and try again.');
}); });
expect(mockReplace).not.toHaveBeenCalled(); expect(mockReplace).not.toHaveBeenCalled();

29
src/components/Componentes/slider-page.tsx

@ -11,6 +11,7 @@ import {
import { localizePath } from "@/translations/config"; import { localizePath } from "@/translations/config";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
import Button from "./button"; import Button from "./button";
import ErrorToast from "./error-toast";
import NavigationButton from "./navigation-button"; import NavigationButton from "./navigation-button";
import type { GenderAnswer, RegistrationAnswer } from "./slider-slide"; import type { GenderAnswer, RegistrationAnswer } from "./slider-slide";
import { SliderSlideFive } from "./slider-slide-five"; import { SliderSlideFive } from "./slider-slide-five";
@ -116,13 +117,22 @@ export default function SliderPage({ onClose }: SliderPageProps = {}) {
router.replace(localizedTarget); router.replace(localizedTarget);
} catch (error) { } catch (error) {
console.error("Failed to complete onboarding:", error); console.error("Failed to complete onboarding:", error);
setSubmitError(t["Failed to update profile basic details. Please try again."] || "Failed to update profile basic details. Please try again.");
setSubmitError(
t["Something went wrong. Please check your internet connection and try again."] ||
"Something went wrong. Please check your internet connection and try again."
);
setIsSubmitting(false); setIsSubmitting(false);
} }
}; };
return ( return (
<div className="relative flex h-[100dvh] max-h-[100dvh] flex-col overflow-hidden"> <div className="relative flex h-[100dvh] max-h-[100dvh] flex-col overflow-hidden">
{submitError && (
<ErrorToast
message={submitError}
onClose={() => setSubmitError(null)}
/>
)}
<header className="relative -mx-[17px] rounded-b-[32px] px-[17px] pt-[max(12px,calc(var(--safe-top)+4px))] pb-3"> <header className="relative -mx-[17px] rounded-b-[32px] px-[17px] pt-[max(12px,calc(var(--safe-top)+4px))] pb-3">
<div className="relative flex items-center justify-between"> <div className="relative flex items-center justify-between">
<NavigationButton <NavigationButton
@ -211,18 +221,11 @@ export default function SliderPage({ onClose }: SliderPageProps = {}) {
disabled={!hasReadRules} disabled={!hasReadRules}
/> />
) : activeSlide === maxSlideIndex ? ( ) : activeSlide === maxSlideIndex ? (
<div className="flex flex-col gap-3">
{submitError && (
<div role="alert" aria-live="assertive" className="text-sm text-red-500 bg-red-50 p-3 rounded-xl border border-red-200">
{submitError}
</div>
)}
<SliderFinalActions
onBack={goToPreviousSlide}
isFinishing={isSubmitting}
onFinish={completeSlider}
/>
</div>
<SliderFinalActions
onBack={goToPreviousSlide}
isFinishing={isSubmitting}
onFinish={completeSlider}
/>
) : ( ) : (
<SliderStepActions <SliderStepActions
onBack={goToPreviousSlide} onBack={goToPreviousSlide}

Loading…
Cancel
Save