Browse Source

feat: implement question flow system with dynamic components and custom styling

front-test-2
ghorbani 4 weeks ago
parent
commit
a3bda4ecd3
  1. 27
      src/app/globals.css
  2. 32
      src/app/intro/page.tsx
  3. 21
      src/app/new-match/page.tsx
  4. 5
      src/app/new-match/profile/page.tsx
  5. 6
      src/app/questions-list/[slug]/question-detail-client.tsx
  6. 1
      src/app/questions-list/page.tsx
  7. 10
      src/app/request-accepted/page.tsx
  8. 96
      src/components/questions/question-date.tsx
  9. 1
      src/components/questions/question-section-flow.tsx
  10. 2
      src/components/questions/question-snap-list.tsx
  11. 7
      src/components/questions/test-loading-screen.tsx
  12. 57
      src/components/sliders/slider-page.tsx
  13. 4
      src/components/sliders/slider-slide-one.tsx
  14. 78
      src/components/sliders/slider-slide-two.tsx
  15. 58
      src/components/ui/button.tsx
  16. 4
      src/data/questions/en.json
  17. 2
      src/lib/get-submit-path.ts

27
src/app/globals.css

@ -160,3 +160,30 @@ body[data-page-background="custom"] .app-shell {
0 2px 7px rgb(0 0 0 / 22%), 0 2px 7px rgb(0 0 0 / 22%),
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;
}
40% {
transform: scale(1.25);
opacity: 1;
}
}
.animate-dots-slide-1 {
animation: button-dot-slide 0.9s cubic-bezier(0.45, 0.05, 0.55, 0.95) infinite;
animation-delay: 0s;
}
.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;
}
.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;
}

32
src/app/intro/page.tsx

@ -35,11 +35,9 @@ export default function Intro() {
return; return;
} }
const shouldRedirect =
authBridge.isAuthenticated() &&
window.sessionStorage.getItem(REDIRECT_SESSION_KEY) === "true";
const isAuthenticated = authBridge.isAuthenticated();
if (!shouldRedirect) {
if (!isAuthenticated) {
if (!isCancelled) { if (!isCancelled) {
setIsCheckingRedirect(false); setIsCheckingRedirect(false);
} }
@ -52,16 +50,24 @@ export default function Intro() {
setIsCheckingRedirect(true); setIsCheckingRedirect(true);
} }
const profileResponse = profile ?? (await refetch()).data;
const nextPath = localizePath(getSubmitPath(profileResponse), locale);
window.sessionStorage.removeItem(REDIRECT_SESSION_KEY);
try {
const profileResponse = profile ?? (await refetch()).data;
const nextPath = localizePath(getSubmitPath(profileResponse), locale);
if (typeof window !== "undefined") {
window.sessionStorage.removeItem(REDIRECT_SESSION_KEY);
}
if (!isCancelled) {
router.replace(nextPath);
return;
if (!isCancelled) {
router.replace(nextPath);
return;
}
} catch {
if (!isCancelled) {
setIsCheckingRedirect(false);
}
} finally {
isRedirectingRef.current = false;
} }
isRedirectingRef.current = false;
}; };
void redirectIfNeeded(); void redirectIfNeeded();
@ -208,7 +214,7 @@ export default function Intro() {
style={{ paddingBottom: "calc(20px + var(--safe-bottom))" }} style={{ paddingBottom: "calc(20px + var(--safe-bottom))" }}
className="pointer-events-auto px-[17px] pt-3" className="pointer-events-auto px-[17px] pt-3"
> >
<Button onClick={handleSubmit} disabled={isSubmitting}>
<Button onClick={handleSubmit} disabled={isSubmitting} isLoading={isSubmitting}>
{t.common.submit} {t.common.submit}
</Button> </Button>
</div> </div>

21
src/app/new-match/page.tsx

@ -1,6 +1,7 @@
"use client"; "use client";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link";
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { localizePath } from "@/translations/config"; import { localizePath } from "@/translations/config";
@ -8,6 +9,7 @@ import { getSubmitPath } from "@/lib/get-submit-path";
import { FaLock } from "react-icons/fa6"; import { FaLock } from "react-icons/fa6";
import AdvisorActionsCard from "@/components/ui/advisor-actions-card"; import AdvisorActionsCard from "@/components/ui/advisor-actions-card";
import NavigationButton from "@/components/ui/navigation-button"; import NavigationButton from "@/components/ui/navigation-button";
import { DotsLoader } from "@/components/ui/button";
import { PageBackground } from "@/components/utils/page-background"; import { PageBackground } from "@/components/utils/page-background";
import type { import type {
MarriageField, MarriageField,
@ -250,9 +252,9 @@ export default function NewMatchPage() {
<div className="flex h-full flex-col justify-between"> <div className="flex h-full flex-col justify-between">
<section className="mt-[36px] rounded-[15px] bg-[linear-gradient(180deg,#F0445B_0%,#F4556E_100%)] px-[17px] pt-[18px] pb-[17px] text-white shadow-[0_18px_38px_rgba(240,68,91,0.25)]"> <section className="mt-[36px] rounded-[15px] bg-[linear-gradient(180deg,#F0445B_0%,#F4556E_100%)] px-[17px] pt-[18px] pb-[17px] text-white shadow-[0_18px_38px_rgba(240,68,91,0.25)]">
{isLoading ? ( {isLoading ? (
<p className="py-8 text-[13px] font-semibold">
Loading match summary...
</p>
<div className="flex items-center justify-center py-8">
<DotsLoader />
</div>
) : isError ? ( ) : isError ? (
<p className="py-8 text-[13px] font-semibold"> <p className="py-8 text-[13px] font-semibold">
Unable to load match summary. Unable to load match summary.
@ -264,7 +266,7 @@ export default function NewMatchPage() {
<span>{matchDisplay.name}</span> <span>{matchDisplay.name}</span>
</h2> </h2>
<div className="mt-[3px] min-h-[68px]">
<div className="mt-[3px] min-h-[48px]">
{matchDisplay.occupation ? ( {matchDisplay.occupation ? (
<FieldLine field={matchDisplay.occupation} /> <FieldLine field={matchDisplay.occupation} />
) : null} ) : null}
@ -288,17 +290,14 @@ export default function NewMatchPage() {
{matchDisplay.cityPreference ? ( {matchDisplay.cityPreference ? (
<FieldLine field={matchDisplay.cityPreference} /> <FieldLine field={matchDisplay.cityPreference} />
) : null} ) : null}
{matchDisplay.extraFields.map((field) => (
<FieldLine key={field.id} field={field} />
))}
</div> </div>
<a
href="/new-match/profile"
className="mt-[15px] inline-flex w-full items-center justify-center rounded-[10px] border-none bg-white py-[12px] text-[16px] font-semibold text-[#F0445B] no-underline shadow-none"
<Link
href={localizePath("/new-match/profile", locale)}
className="mt-[15px] inline-flex w-full items-center justify-center rounded-[10px] border-none bg-white py-[12px] text-[16px] font-semibold text-[#F0445B] no-underline shadow-none hover:bg-white/95 transition-colors"
> >
View Profile View Profile
</a>
</Link>
</> </>
) : ( ) : (
<p className="py-8 text-[13px] font-semibold"> <p className="py-8 text-[13px] font-semibold">

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

@ -3,7 +3,7 @@
import Image from "next/image"; import Image from "next/image";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import Button from "@/components/ui/button";
import Button, { DotsLoader } from "@/components/ui/button";
import DismissReasonSheet from "@/components/ui/dismiss-reason-sheet"; import DismissReasonSheet from "@/components/ui/dismiss-reason-sheet";
import FemaleConsentSheet from "@/components/ui/female-consent-sheet"; import FemaleConsentSheet from "@/components/ui/female-consent-sheet";
import InformationSheet from "@/components/ui/information-sheet"; import InformationSheet from "@/components/ui/information-sheet";
@ -361,7 +361,8 @@ export default function NewMatchProfilePage() {
</Button> </Button>
<Button <Button
className="py-[18px]" className="py-[18px]"
disabled={!isAcceptProfileEnabled}
disabled={!isAcceptProfileEnabled || respondMutation.isPending}
isLoading={respondMutation.isPending}
onClick={async () => { onClick={async () => {
close(); close();
if (!isAcceptProfileEnabled) { if (!isAcceptProfileEnabled) {

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

@ -7,7 +7,7 @@ import {
QuestionAnswersProvider, QuestionAnswersProvider,
useQuestionAnswers, useQuestionAnswers,
} from "@/components/questions/question-answer-storage"; } from "@/components/questions/question-answer-storage";
import QuestionButton from "@/components/questions/question-button";
import QuestionButton, { DotsLoader } from "@/components/questions/question-button";
import { QuestionCheckbox } from "@/components/questions/question-checkbox"; import { QuestionCheckbox } from "@/components/questions/question-checkbox";
import QuestionDate from "@/components/questions/question-date"; import QuestionDate from "@/components/questions/question-date";
import QuestionDropdown from "@/components/questions/question-dropdown"; import QuestionDropdown from "@/components/questions/question-dropdown";
@ -508,7 +508,7 @@ export default function QuestionDetailClient({
<> <>
<PageBackground disabled /> <PageBackground disabled />
<main className="-mx-[17px] flex h-svh flex-col items-center justify-center bg-[#F7F1F0]"> <main className="-mx-[17px] flex h-svh flex-col items-center justify-center bg-[#F7F1F0]">
<div className="size-10 rounded-full border-[3px] border-[#DCDCE0] border-t-[#F2465F] border-r-[#F2465F] animate-spin" />
<DotsLoader className="text-[#F2465F] scale-150" />
</main> </main>
</> </>
); );
@ -529,7 +529,7 @@ export default function QuestionDetailClient({
<> <>
<PageBackground disabled /> <PageBackground disabled />
<main className="-mx-[17px] flex h-svh flex-col items-center justify-center bg-[#F7F1F0]"> <main className="-mx-[17px] flex h-svh flex-col items-center justify-center bg-[#F7F1F0]">
<div className="size-10 rounded-full border-[3px] border-[#DCDCE0] border-t-[#F2465F] border-r-[#F2465F] animate-spin" />
<DotsLoader className="text-[#F2465F] scale-150" />
</main> </main>
</> </>
); );

1
src/app/questions-list/page.tsx

@ -247,6 +247,7 @@ export default function QuestionsListPage() {
<Button <Button
aria-label={t.questions.findMatches} aria-label={t.questions.findMatches}
disabled={isStartMatchDisabled} disabled={isStartMatchDisabled}
isLoading={startMatchMutation.isPending}
onClick={() => { onClick={() => {
if (hasIncompleteOptionalSections) { if (hasIncompleteOptionalSections) {
setIsOptionalInfoSheetOpen(true); setIsOptionalInfoSheetOpen(true);

10
src/app/request-accepted/page.tsx

@ -10,6 +10,7 @@ import CallResultSheet from "@/components/ui/call-result-sheet";
import FemaleConsentSheet from "@/components/ui/female-consent-sheet"; import FemaleConsentSheet from "@/components/ui/female-consent-sheet";
import NavigationButton from "@/components/ui/navigation-button"; import NavigationButton from "@/components/ui/navigation-button";
import SubscriptionRequiredSheet from "@/components/ui/subscription-required-sheet"; import SubscriptionRequiredSheet from "@/components/ui/subscription-required-sheet";
import { DotsLoader } from "@/components/ui/button";
import { PageBackground } from "@/components/utils/page-background"; import { PageBackground } from "@/components/utils/page-background";
import type { MarriageField, MarriagePhoneFieldValue } from "@/hooks/marriage/types"; import type { MarriageField, MarriagePhoneFieldValue } from "@/hooks/marriage/types";
import { useMarriageContactInfoQuery } from "@/hooks/marriage/use-contact-info"; import { useMarriageContactInfoQuery } from "@/hooks/marriage/use-contact-info";
@ -321,8 +322,8 @@ export default function RequestAcceptedPage() {
disabled={contactStatusMutation.isPending} disabled={contactStatusMutation.isPending}
className="max-w-[212px] cursor-pointer appearance-none border-0 bg-transparent p-0 text-left" className="max-w-[212px] cursor-pointer appearance-none border-0 bg-transparent p-0 text-left"
> >
<div className="bg-[#F5F5F5] px-4 py-2 rounded-[15px] shadow group-14 text-center font-semibold text-[#36363C] hover:bg-[#EBEBEB] transition-colors">
{primaryActionText}
<div className="bg-[#F5F5F5] px-4 py-2 rounded-[15px] shadow group-14 text-center font-semibold text-[#36363C] hover:bg-[#EBEBEB] transition-colors flex items-center justify-center min-h-[38px]">
{contactStatusMutation.isPending ? <DotsLoader /> : primaryActionText}
</div> </div>
</button> </button>
) : ( ) : (
@ -338,10 +339,11 @@ export default function RequestAcceptedPage() {
onClick={() => { onClick={() => {
void handleSecondaryAction(); void handleSecondaryAction();
}} }}
disabled={paymentMutation.isPending}
className="max-w-[212px] appearance-none border-0 bg-transparent p-0 text-left" className="max-w-[212px] appearance-none border-0 bg-transparent p-0 text-left"
> >
<div className="bg-linear-180 from-[#FE6F82] to-[#E03950] px-4 py-2 rounded-[15px] shadow group-14 text-center font-semibold text-[#fff] shadow-md shadow-[#F2596E]/60">
{secondaryActionText}
<div className="bg-linear-180 from-[#FE6F82] to-[#E03950] px-4 py-2 rounded-[15px] shadow group-14 text-center font-semibold text-[#fff] shadow-md shadow-[#F2596E]/60 flex items-center justify-center min-h-[38px]">
{paymentMutation.isPending ? <DotsLoader /> : secondaryActionText}
</div> </div>
</button> </button>
</div> </div>

96
src/components/questions/question-date.tsx

@ -1,9 +1,10 @@
"use client"; "use client";
import { useMemo } from "react";
import { useEffect, useMemo, useState } from "react";
import type { QuestionField } from "@/data/question-data"; import type { QuestionField } from "@/data/question-data";
import { useQuestionAnswers } from "./question-answer-storage"; import { useQuestionAnswers } from "./question-answer-storage";
import QuestionTitle from "./question-title"; import QuestionTitle from "./question-title";
import { useI18n } from "@/translations/provider";
type QuestionDateProps = { type QuestionDateProps = {
question: QuestionField; question: QuestionField;
@ -32,28 +33,48 @@ const DAYS = Array.from({ length: 31 }, (_, i) => {
return { value: val, label: `${num}` }; return { value: val, label: `${num}` };
}); });
const MIN_AGE = 18;
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
const YEARS = Array.from({ length: 90 }, (_, i) => (currentYear - i).toString());
const maxBirthYear = currentYear - MIN_AGE;
const YEARS = Array.from({ length: 80 }, (_, i) => (maxBirthYear - i).toString());
export function QuestionDate({ export function QuestionDate({
question, question,
questionIndex, questionIndex,
disabled, disabled,
}: QuestionDateProps) { }: QuestionDateProps) {
const { locale } = useI18n();
const { getAnswerValue, setAnswerValue } = useQuestionAnswers(); const { getAnswerValue, setAnswerValue } = useQuestionAnswers();
const value = getAnswerValue(question, questionIndex); const value = getAnswerValue(question, questionIndex);
const dateValue = typeof value === "string" ? value : ""; const dateValue = typeof value === "string" ? value : "";
const parts = dateValue ? dateValue.split("-") : [];
const year = parts[0] || "";
const month = parts[1] || "";
const day = parts[2] || "";
const [selectedYear, setSelectedYear] = useState(() => {
const parts = dateValue ? dateValue.split("-") : [];
return parts[0] || "";
});
const [selectedMonth, setSelectedMonth] = useState(() => {
const parts = dateValue ? dateValue.split("-") : [];
return parts[1] || "";
});
const [selectedDay, setSelectedDay] = useState(() => {
const parts = dateValue ? dateValue.split("-") : [];
return parts[2] || "";
});
useEffect(() => {
const parts = dateValue ? dateValue.split("-") : [];
if (parts.length === 3) {
setSelectedYear(parts[0] || "");
setSelectedMonth(parts[1] || "");
setSelectedDay(parts[2] || "");
}
}, [dateValue]);
const calculatedAge = useMemo(() => { const calculatedAge = useMemo(() => {
if (!year || !month || !day) return null;
const y = Number.parseInt(year, 10);
const m = Number.parseInt(month, 10);
const d = Number.parseInt(day, 10);
if (!selectedYear || !selectedMonth || !selectedDay) return null;
const y = Number.parseInt(selectedYear, 10);
const m = Number.parseInt(selectedMonth, 10);
const d = Number.parseInt(selectedDay, 10);
if (!y || !m || !d || Number.isNaN(y) || Number.isNaN(m) || Number.isNaN(d)) { if (!y || !m || !d || Number.isNaN(y) || Number.isNaN(m) || Number.isNaN(d)) {
return null; return null;
@ -70,19 +91,34 @@ export function QuestionDate({
} }
return age >= 0 ? age : null; return age >= 0 ? age : null;
}, [year, month, day]);
}, [selectedYear, selectedMonth, selectedDay]);
const isUnder18 = calculatedAge !== null && calculatedAge < MIN_AGE;
const handleSelectChange = (newYear: string, newMonth: string, newDay: string) => {
if (!newYear && !newMonth && !newDay) {
const updateDate = (y: string, m: string, d: string) => {
setSelectedYear(y);
setSelectedMonth(m);
setSelectedDay(d);
if (y && m && d) {
const formattedMonth = m.padStart(2, "0");
const formattedDay = d.padStart(2, "0");
setAnswerValue(question, questionIndex, `${y}-${formattedMonth}-${formattedDay}`);
} else {
setAnswerValue(question, questionIndex, ""); setAnswerValue(question, questionIndex, "");
return;
} }
};
const y = newYear || year || "2000";
const m = (newMonth || month || "01").padStart(2, "0");
const d = (newDay || day || "01").padStart(2, "0");
const handleDayChange = (newDay: string) => {
updateDate(selectedYear, selectedMonth, newDay);
};
const handleMonthChange = (newMonth: string) => {
updateDate(selectedYear, newMonth, selectedDay);
};
setAnswerValue(question, questionIndex, `${y}-${m}-${d}`);
const handleYearChange = (newYear: string) => {
updateDate(newYear, selectedMonth, selectedDay);
}; };
return ( return (
@ -98,8 +134,8 @@ export function QuestionDate({
<div className="grid grid-cols-3 gap-2"> <div className="grid grid-cols-3 gap-2">
{/* Day / روز */} {/* Day / روز */}
<select <select
value={day}
onChange={(e) => handleSelectChange(year, month, e.target.value)}
value={selectedDay}
onChange={(e) => handleDayChange(e.target.value)}
disabled={disabled} disabled={disabled}
aria-label="Day" aria-label="Day"
className="h-[54px] w-full cursor-pointer rounded-[15px] border border-[#E7D8D5] bg-white px-3 group-12 text-[#181818] outline-none transition-all focus:border-[#6F6F6F]" className="h-[54px] w-full cursor-pointer rounded-[15px] border border-[#E7D8D5] bg-white px-3 group-12 text-[#181818] outline-none transition-all focus:border-[#6F6F6F]"
@ -114,8 +150,8 @@ export function QuestionDate({
{/* Month */} {/* Month */}
<select <select
value={month}
onChange={(e) => handleSelectChange(year, e.target.value, day)}
value={selectedMonth}
onChange={(e) => handleMonthChange(e.target.value)}
disabled={disabled} disabled={disabled}
aria-label="Month" aria-label="Month"
className="h-[54px] w-full cursor-pointer rounded-[15px] border border-[#E7D8D5] bg-white px-3 group-12 text-[#181818] outline-none transition-all focus:border-[#6F6F6F]" className="h-[54px] w-full cursor-pointer rounded-[15px] border border-[#E7D8D5] bg-white px-3 group-12 text-[#181818] outline-none transition-all focus:border-[#6F6F6F]"
@ -130,8 +166,8 @@ export function QuestionDate({
{/* Year */} {/* Year */}
<select <select
value={year}
onChange={(e) => handleSelectChange(e.target.value, month, day)}
value={selectedYear}
onChange={(e) => handleYearChange(e.target.value)}
disabled={disabled} disabled={disabled}
aria-label="Year" aria-label="Year"
className="h-[54px] w-full cursor-pointer rounded-[15px] border border-[#E7D8D5] bg-white px-3 group-12 text-[#181818] outline-none transition-all focus:border-[#6F6F6F]" className="h-[54px] w-full cursor-pointer rounded-[15px] border border-[#E7D8D5] bg-white px-3 group-12 text-[#181818] outline-none transition-all focus:border-[#6F6F6F]"
@ -155,8 +191,18 @@ export function QuestionDate({
disabled disabled
readOnly readOnly
value={calculatedAge !== null ? calculatedAge : ""} value={calculatedAge !== null ? calculatedAge : ""}
className="h-[54px] w-full rounded-[15px] border border-[#E7D8D5] bg-[#F5F2F1] px-4 group-12 font-medium text-[#7C7472] outline-none cursor-not-allowed disabled:bg-[#F5F2F1] disabled:text-[#7C7472]"
className={[
"h-[54px] w-full rounded-[15px] border px-4 group-12 font-medium outline-none cursor-not-allowed disabled:bg-[#F5F2F1] disabled:text-[#7C7472]",
isUnder18 ? "border-[#F2465F] text-[#F2465F]" : "border-[#E7D8D5] text-[#7C7472]",
].join(" ")}
/> />
{isUnder18 ? (
<span className="block text-[11px] font-semibold text-[#F2465F]">
{locale === "fa"
? "حداقل سن برای ثبت‌نام ۱۸ سال می‌باشد."
: "Minimum age required for registration is 18 years."}
</span>
) : null}
</div> </div>
</div> </div>
); );

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

@ -83,6 +83,7 @@ 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}

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

@ -247,7 +247,7 @@ export function QuestionSnapList({
containerStyles = "top-4 left-0 z-0 opacity-0 pointer-events-none scale-[0.98]"; containerStyles = "top-4 left-0 z-0 opacity-0 pointer-events-none scale-[0.98]";
wrapperStyles = "w-full max-h-[110px] overflow-hidden pointer-events-none"; wrapperStyles = "w-full max-h-[110px] overflow-hidden pointer-events-none";
} else if (isNext) { } else if (isNext) {
containerStyles = "bottom-4 left-0 z-0 opacity-20 pointer-events-none scale-[0.98]";
containerStyles = "bottom-4 left-0 z-0 opacity-0 pointer-events-none scale-[0.98]";
wrapperStyles = "w-full max-h-[110px] overflow-hidden pointer-events-none"; wrapperStyles = "w-full max-h-[110px] overflow-hidden pointer-events-none";
} else { } else {
containerStyles = "top-1/2 left-0 -translate-y-1/2 z-0 opacity-0 pointer-events-none scale-[0.95]"; containerStyles = "top-1/2 left-0 -translate-y-1/2 z-0 opacity-0 pointer-events-none scale-[0.95]";

7
src/components/questions/test-loading-screen.tsx

@ -1,5 +1,6 @@
"use client"; "use client";
import { DotsLoader } from "@/components/ui/button";
import { PageBackground } from "@/components/utils/page-background"; import { PageBackground } from "@/components/utils/page-background";
export function AnalyzingIllustration({ className = "w-44 h-44" }: { className?: string }) { export function AnalyzingIllustration({ className = "w-44 h-44" }: { className?: string }) {
@ -139,9 +140,9 @@ export default function TestLoadingScreen({
</p> </p>
</div> </div>
{/* Bottom Circular Loading Spinner */}
<div className="shrink-0 mb-4 flex justify-center items-center">
<div className="size-11 rounded-full border-[3.5px] border-[#DCDCE0] border-t-[#F2465F] border-r-[#F2465F] animate-spin transition-all" />
{/* Bottom 3-Dots Loading Animation */}
<div className="shrink-0 mb-4 flex justify-center items-center py-2">
<DotsLoader className="text-[#F2465F] scale-150" />
</div> </div>
</main> </main>
</> </>

57
src/components/sliders/slider-page.tsx

@ -45,7 +45,6 @@ export default function SliderPage() {
const [selectedGender, setSelectedGender] = useState<GenderAnswer>("woman"); const [selectedGender, setSelectedGender] = useState<GenderAnswer>("woman");
const [selectedRegistration, setSelectedRegistration] = const [selectedRegistration, setSelectedRegistration] =
useState<RegistrationAnswer>("other"); useState<RegistrationAnswer>("other");
const [hasLoadedSavedAnswers, setHasLoadedSavedAnswers] = useState(false);
const [touchStartX, setTouchStartX] = useState<number | null>(null); const [touchStartX, setTouchStartX] = useState<number | null>(null);
const updateProfileBasicMutation = useUpdateMarriageProfileBasicMutation(); const updateProfileBasicMutation = useUpdateMarriageProfileBasicMutation();
const hasFinalNotice = selectedGender === "woman"; const hasFinalNotice = selectedGender === "woman";
@ -59,46 +58,12 @@ export default function SliderPage() {
const navDotCount = displaySlideCount; const navDotCount = displaySlideCount;
useEffect(() => { useEffect(() => {
const savedAnswers = window.localStorage.getItem(
SLIDER_ANSWERS_STORAGE_KEY,
);
if (!savedAnswers) {
setHasLoadedSavedAnswers(true);
return;
}
try {
const parsed = JSON.parse(savedAnswers) as Partial<SavedSliderAnswers>;
if (isGenderAnswer(parsed.gender)) {
setSelectedGender(parsed.gender);
}
if (isRegistrationAnswer(parsed.registration)) {
setSelectedRegistration(parsed.registration);
}
} catch {
// Clear any previous slider answers on mount so abandoned onboarding starts clean
if (typeof window !== "undefined") {
window.localStorage.removeItem(SLIDER_ANSWERS_STORAGE_KEY); window.localStorage.removeItem(SLIDER_ANSWERS_STORAGE_KEY);
} }
setHasLoadedSavedAnswers(true);
}, []); }, []);
useEffect(() => {
if (!hasLoadedSavedAnswers) {
return;
}
window.localStorage.setItem(
SLIDER_ANSWERS_STORAGE_KEY,
JSON.stringify({
gender: selectedGender,
registration: selectedRegistration,
}),
);
}, [hasLoadedSavedAnswers, selectedGender, selectedRegistration]);
useEffect(() => { useEffect(() => {
if (!hasFinalNotice && activeSlide >= FINAL_SLIDE_COUNT - 1) { if (!hasFinalNotice && activeSlide >= FINAL_SLIDE_COUNT - 1) {
setActiveSlide(BASE_SLIDE_COUNT - 1); setActiveSlide(BASE_SLIDE_COUNT - 1);
@ -126,7 +91,11 @@ export default function SliderPage() {
} catch (error) { } catch (error) {
console.warn("Failed to update profile basic details:", error); console.warn("Failed to update profile basic details:", error);
} finally { } finally {
router.push(localizePath("/questions-list", locale));
if (typeof window !== "undefined") {
window.localStorage.removeItem(SLIDER_ANSWERS_STORAGE_KEY);
}
// Action Item 3: Immediately direct user into Card 1 (personal_info) after onboarding
router.push(localizePath("/questions-list/personal_info", locale));
} }
}; };
@ -214,8 +183,8 @@ export default function SliderPage() {
<div className="pointer-events-none fixed bottom-0 left-1/2 z-20 w-full sm:max-w-[375px] -translate-x-1/2 bg-[#F5F5F5]"> <div className="pointer-events-none fixed bottom-0 left-1/2 z-20 w-full sm:max-w-[375px] -translate-x-1/2 bg-[#F5F5F5]">
<div <div
style={{ paddingBottom: `calc(1.5rem + var(--safe-bottom))` }}
className="mx-auto w-full sm:max-w-[375px] px-[17px] pt-2.5"
style={{ paddingBottom: `calc(1rem + var(--safe-bottom))` }}
className="mx-auto w-full sm:max-w-[375px] px-[17px] pt-4"
> >
<div className="pointer-events-auto"> <div className="pointer-events-auto">
{activeSlide === 0 ? ( {activeSlide === 0 ? (
@ -252,7 +221,7 @@ type SliderStepActionsProps = {
function SliderStepActions({ onBack, onNext }: SliderStepActionsProps) { function SliderStepActions({ onBack, onNext }: SliderStepActionsProps) {
return ( return (
<div className="flex gap-3 [&>button]:flex-1">
<div className="flex items-center gap-3 [&>button]:flex-1">
<Button variant="outlined" arrowDirection="left" onClick={onBack}> <Button variant="outlined" arrowDirection="left" onClick={onBack}>
Back Back
</Button> </Button>
@ -275,7 +244,7 @@ function SliderFinalActions({
onFinish, onFinish,
}: SliderFinalActionsProps) { }: SliderFinalActionsProps) {
return ( return (
<div className="flex gap-3 [&>button]:flex-1">
<div className="flex items-center gap-3 [&>button]:flex-1">
<Button <Button
variant="outlined" variant="outlined"
arrowDirection="left" arrowDirection="left"
@ -284,8 +253,8 @@ function SliderFinalActions({
> >
Back Back
</Button> </Button>
<Button disabled={isFinishing} onClick={onFinish}>
{isFinishing ? "Saving..." : "Finish"}
<Button disabled={isFinishing} isLoading={isFinishing} onClick={onFinish}>
Finish
</Button> </Button>
</div> </div>
); );

4
src/components/sliders/slider-slide-one.tsx

@ -1,7 +1,7 @@
import type { SliderSlideProps } from "@/components/sliders/slider-slide"; import type { SliderSlideProps } from "@/components/sliders/slider-slide";
import Button from "../ui/button"; import Button from "../ui/button";
const ACTION_AREA_HEIGHT = 78;
const ACTION_AREA_HEIGHT = 80;
const CONTENT_MASK = `linear-gradient(to bottom, black 0, black calc(100% - ${ACTION_AREA_HEIGHT}px), transparent calc(100% - ${ACTION_AREA_HEIGHT}px), transparent 100%)`; const CONTENT_MASK = `linear-gradient(to bottom, black 0, black calc(100% - ${ACTION_AREA_HEIGHT}px), transparent calc(100% - ${ACTION_AREA_HEIGHT}px), transparent 100%)`;
export function SliderSlideOne({ index }: SliderSlideProps) { export function SliderSlideOne({ index }: SliderSlideProps) {
@ -116,7 +116,7 @@ export function SliderSlideOneActions({
onAccept, onAccept,
}: SliderSlideOneActionsProps) { }: SliderSlideOneActionsProps) {
return ( return (
<div className="flex gap-3 [&>a]:flex-1">
<div className="flex items-center gap-3 [&>a]:flex-1 [&>button]:flex-1">
<Button variant="outlined" arrowDirection="left" href="/intro"> <Button variant="outlined" arrowDirection="left" href="/intro">
Back Back
</Button> </Button>

78
src/components/sliders/slider-slide-two.tsx

@ -7,47 +7,49 @@ export function SliderSlideTwo({ index }: SliderSlideProps) {
aria-label={`Slide ${index + 1}`} aria-label={`Slide ${index + 1}`}
className="flex h-full min-h-0 w-full shrink-0 flex-col" className="flex h-full min-h-0 w-full shrink-0 flex-col"
> >
<div className="text-center">
<p className="text-[#747474] group-10 font-semibold">Submit Process</p>
<p className="text-[#111111] group-16 font-bold">
{index + 1}/4 Watch Video
</p>
</div>
<div className="mt-8 flex min-h-0 justify-center">
<div className="relative w-full max-w-[344px]">
<Image
src={"/assets/images/Frame 20953586523.png"}
alt="video"
width={344}
height={488}
className="h-auto w-full"
/>
<Image
src={"/assets/images/Frame 1116607280.svg"}
alt="play"
width={68}
height={68}
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2"
/>
<div className="min-h-0 flex-1 overflow-y-auto pb-[86px]">
<div className="text-center">
<p className="text-[#747474] group-10 font-semibold">Submit Process</p>
<p className="text-[#111111] group-16 font-bold">
{index + 1}/4 Watch Video
</p>
</div> </div>
</div>
<div className="mt-5">
<div className="flex items-center justify-center gap-1">
<Image
src={"/assets/images/Avatar Image.png"}
alt="video"
width={20}
height={20}
className="rounded-full"
/>
<p className="text-[#2F3A48] group-14 font-semibold">
Dr. Hasti Masoudi
<div className="mt-8 flex min-h-0 justify-center">
<div className="relative w-full max-w-[344px]">
<Image
src={"/assets/images/Frame 20953586523.png"}
alt="video"
width={344}
height={488}
className="h-auto w-full"
/>
<Image
src={"/assets/images/Frame 1116607280.svg"}
alt="play"
width={68}
height={68}
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2"
/>
</div>
</div>
<div className="mt-5">
<div className="flex items-center justify-center gap-1">
<Image
src={"/assets/images/Avatar Image.png"}
alt="video"
width={20}
height={20}
className="rounded-full"
/>
<p className="text-[#2F3A48] group-14 font-semibold">
Dr. Hasti Masoudi
</p>
</div>
<p className="group-12 text-[#4D4D4D] text-center mt-2.5">
We have come together with the goal of creating a secure and
confidential path for "permanent marriage" among Muslims
</p> </p>
</div> </div>
<p className="group-12 text-[#4D4D4D] text-center mt-2.5">
We have come together with the goal of creating a secure and
confidential path for "permanent marriage" among Muslims
</p>
</div> </div>
</section> </section>
); );

58
src/components/ui/button.tsx

@ -25,8 +25,19 @@ export type ButtonProps = Omit<
arrowDirection?: ArrowDirection; arrowDirection?: ArrowDirection;
countdownSeconds?: number; countdownSeconds?: number;
href?: string; href?: string;
isLoading?: boolean;
}; };
export function DotsLoader({ className = "" }: { className?: string }) {
return (
<span className={`inline-flex items-center justify-center gap-1.5 py-0.5 ${className}`}>
<span className="size-2 rounded-full bg-current animate-dots-slide-1" />
<span className="size-2 rounded-full bg-current animate-dots-slide-2" />
<span className="size-2 rounded-full bg-current animate-dots-slide-3" />
</span>
);
}
const FILLED_STROKE = "#FFFFFF"; const FILLED_STROKE = "#FFFFFF";
const EMPTY_STROKE = "rgba(255, 255, 255, 0.5)"; const EMPTY_STROKE = "rgba(255, 255, 255, 0.5)";
const RADIUS = 18; const RADIUS = 18;
@ -39,6 +50,7 @@ export function Button({
arrowDirection, arrowDirection,
countdownSeconds = 0, countdownSeconds = 0,
disabled, disabled,
isLoading = false,
href, href,
className, className,
type = "button", type = "button",
@ -99,7 +111,7 @@ export function Button({
}, [initialCountdown, isCountdown]); }, [initialCountdown, isCountdown]);
const countdownLocked = isCountdown && remainingSeconds > 0; const countdownLocked = isCountdown && remainingSeconds > 0;
const isDisabled = disabled || countdownLocked;
const isDisabled = disabled || countdownLocked || isLoading;
const progress = isCountdown ? animatedProgress : 1; const progress = isCountdown ? animatedProgress : 1;
const widthClass = variant === "secondary" ? "w-1/2" : "w-full"; const widthClass = variant === "secondary" ? "w-1/2" : "w-full";
@ -149,29 +161,33 @@ export function Button({
aria-describedby={description && !isOutlined ? countdownId : undefined} aria-describedby={description && !isOutlined ? countdownId : undefined}
className={baseClassName} className={baseClassName}
> >
<span className="flex w-full items-center justify-center gap-2">
{renderArrow("left")}
<span className="flex min-w-0 flex-col items-center justify-center">
<span className="text-center group-16 font-semibold leading-none">
{children}
{isLoading ? (
<DotsLoader />
) : (
<span className="flex w-full items-center justify-center gap-2">
{renderArrow("left")}
<span className="flex min-w-0 flex-col items-center justify-center">
<span className="text-center group-16 font-semibold leading-none">
{children}
</span>
{description && !isOutlined ? (
<span
id={countdownId}
className="mt-1 text-center group-10 font-semibold leading-none text-white"
>
{description}
</span>
) : null}
</span> </span>
{description && !isOutlined ? (
<span
id={countdownId}
className="mt-1 text-center group-10 font-semibold leading-none text-white"
>
{description}
{countdownLocked ? (
<span className="shrink-0">
<CountdownProgress value={remainingSeconds} progress={progress} />
</span> </span>
) : null}
) : (
renderArrow("right")
)}
</span> </span>
{countdownLocked ? (
<span className="shrink-0">
<CountdownProgress value={remainingSeconds} progress={progress} />
</span>
) : (
renderArrow("right")
)}
</span>
)}
</button> </button>
); );

4
src/data/questions/en.json

@ -10,12 +10,12 @@
"description": "Collects personal details to start the marriage application flow.", "description": "Collects personal details to start the marriage application flow.",
"questions": [ "questions": [
{ {
"title": "Full Name",
"title": "First Name",
"type": "text", "type": "text",
"required": true, "required": true,
"tooltip": "Use your passport or national ID spelling.", "tooltip": "Use your passport or national ID spelling.",
"extras": { "extras": {
"placeHolder": "e.g. Sara Ahmadi",
"placeHolder": "e.g. Sara",
"range": [ "range": [
0, 0,
0 0

2
src/lib/get-submit-path.ts

@ -52,7 +52,7 @@ export function getSubmitPath(profile: MarriageProfileResponse | undefined) {
return "/terms"; return "/terms";
} }
if (profile.status === "pending_info" && !isMatchSubmitted) {
if (profile.status === "pending_info") {
return "/questions-list"; return "/questions-list";
} }

Loading…
Cancel
Save