Muhammad A. Ghorbani 14 hours ago
parent
commit
82936af95d
  1. 12
      src/app/globals.css
  2. 1
      src/app/layout.tsx
  3. 25
      src/app/new-match/new-match-client.tsx
  4. 6
      src/app/questions-list/[slug]/question-detail-client.tsx
  5. 52
      src/app/questions-list/questions-list-client.tsx
  6. 20
      src/app/request-accepted/request-accepted-client.tsx
  7. 4
      src/components/Componentes/currency-sheet.tsx
  8. 2
      src/components/Componentes/error-toast.tsx
  9. 2
      src/components/Componentes/fix-to-the-end.tsx
  10. 11
      src/components/Componentes/question-birthplace.tsx
  11. 82
      src/components/Componentes/question-checkbox.tsx
  12. 4
      src/components/Componentes/question-date-sheet.tsx
  13. 22
      src/components/Componentes/question-date.test.tsx
  14. 3
      src/components/Componentes/question-number.test.tsx
  15. 4
      src/components/Componentes/question-phone.tsx
  16. 6
      src/components/Componentes/question-radio.tsx
  17. 17
      src/components/Componentes/question-renderer.tsx
  18. 48
      src/components/Componentes/question-sheet.test.tsx
  19. 4
      src/components/Componentes/question-sheet.tsx
  20. 13
      src/components/Componentes/question-slider.tsx
  21. 2
      src/components/Componentes/question-textarea.tsx
  22. 49
      src/components/Componentes/question-viewport-coordinator.ts
  23. 4
      src/components/Componentes/slider-page.tsx
  24. 2
      src/components/Componentes/slider-slide-one.tsx
  25. 19
      src/components/Componentes/test-questions-flow.tsx
  26. 5
      src/hooks/marriage/use-cattell.ts
  27. 10
      src/hooks/marriage/use-glasser.ts
  28. 26
      src/hooks/marriage/use-section-data.ts
  29. 8
      src/lib/marriage-field-formatter.ts
  30. 52
      src/lib/ssr-fetch.ts

12
src/app/globals.css

@ -237,12 +237,12 @@ html:lang(ar) body,
}
}
body[data-page-background="none"] .app-shell {
background-image: none;
}
.app-shell:has(.page-background-none) {
background-image: none;
body[data-page-background="none"] .app-shell,
.app-shell:has(.page-background-none),
body:has(.page-background-none),
html:has(.page-background-none) {
background-image: none !important;
background-color: #F7F1F0 !important;
}
body[data-page-background="custom"] .app-shell {

1
src/app/layout.tsx

@ -62,6 +62,7 @@ export const viewport: Viewport = {
userScalable: false,
viewportFit: "cover",
themeColor: "#ffffff",
interactiveWidget: "overlays-content",
};
export default async function RootLayout({

25
src/app/new-match/new-match-client.tsx

@ -20,6 +20,7 @@ import AdvisorActionsCard from "@/components/Componentes/advisor-actions-card";
import MarriageAdvisorsOverlay, {
useMarriageAdvisorsOverlay,
} from "@/components/Componentes/marriage-advisors-overlay";
import ErrorToast from "@/components/Componentes/error-toast";
import MatchProfileOverlay, {
useMatchProfileOverlay,
} from "@/components/Componentes/match-profile-overlay";
@ -118,6 +119,7 @@ const fieldCandidateMatchers = {
"career",
],
hobbies: [
"what_are_your_main_hobbies_and_interests",
"your_hobbies_and_main_interests",
"hobbies_and_interests",
"hobbies",
@ -502,6 +504,8 @@ export default function NewMatchClient() {
const [paymentError, setPaymentError] = useState<string | null>(null);
const [isInsufficientCoins, setIsInsufficientCoins] = useState(false);
const [showPaymentSuccessToast, setShowPaymentSuccessToast] = useState(false);
const [appliedDiscount, setAppliedDiscount] =
useState<CheckDiscountResult | null>(null);
@ -539,6 +543,7 @@ export default function NewMatchClient() {
discountCode: appliedDiscount?.valid ? appliedDiscount.code : undefined,
});
setIsPaymentSheetOpen(false);
setShowPaymentSuccessToast(true);
openProfile();
} catch (err: any) {
console.error("Payment failed", err);
@ -629,12 +634,14 @@ export default function NewMatchClient() {
<>
<PageBackground />
<main
className="-mx-[17px] flex min-h-dvh flex-col px-[18px] text-center"
>
<div
style={{
paddingTop: `${Math.max(12, top + 4)}px`,
}}
className="-mx-[17px] flex min-h-dvh flex-col px-[18px] text-center"
className="sticky top-0 z-30 -mx-[18px] px-[18px] pb-2 bg-[#F7F1F0]/90 backdrop-blur-md"
>
<div className="shrink-0 mb-1">
<PageHeader profile={profile} />
</div>
@ -716,12 +723,14 @@ export default function NewMatchClient() {
<PageBackground />
<main
className="-mx-[17px] flex min-h-dvh flex-col px-[18px] text-center"
>
<div
style={{
paddingTop: `${Math.max(12, top + 4)}px`,
}}
className="-mx-[17px] flex min-h-dvh flex-col px-[18px] text-center"
className="sticky top-0 z-30 -mx-[18px] px-[18px] pb-2 bg-[#F7F1F0]/90 backdrop-blur-md"
>
<div className="shrink-0 mb-1">
<PageHeader profile={profile} />
</div>
@ -1166,6 +1175,14 @@ export default function NewMatchClient() {
<MarriageAdvisorsOverlay open={isAdvisorOpen} onClose={closeAdvisors} />
<MatchProfileOverlay open={isProfileOpen} onClose={closeProfile} />
{showPaymentSuccessToast && (
<ErrorToast
variant="success"
message={t["Payment successful"] || "Payment successful"}
onClose={() => setShowPaymentSuccessToast(false)}
/>
)}
</>
);
}

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

@ -270,12 +270,16 @@ export default function QuestionDetailClient({
const profileId = useCurrentProfileId();
const handleExit = useCallback(() => {
void queryClient.invalidateQueries({
queryKey: [...marriageQueryKeys.all, "form-overview"],
exact: false,
});
if (onClose) {
onClose();
return;
}
router.replace(questionsListHref);
}, [onClose, questionsListHref, router]);
}, [onClose, questionsListHref, router, queryClient]);
// Hardware back in the detail page = navigate back to questions list.
// QuestionAnswersProvider's pagehide/unmount safety net will flush

52
src/app/questions-list/questions-list-client.tsx

@ -18,8 +18,8 @@ import QuestionCard from "@/components/Componentes/question-card";
import RequiredStepsCard from "@/components/Componentes/required-steps-card";
import type { MarriageField } from "@/hooks/marriage/types";
import { marriageQueryKeys } from "@/hooks/marriage/query-keys";
import { getCattellQuestions } from "@/hooks/marriage/use-cattell";
import { getGlasserQuestions } from "@/hooks/marriage/use-glasser";
import { getCattellQuestions, useCattellResultQuery } from "@/hooks/marriage/use-cattell";
import { getGlasserQuestions, useGlasserResultQuery } from "@/hooks/marriage/use-glasser";
import {
getFormSection,
useFormOverviewQuery,
@ -97,6 +97,14 @@ export default function QuestionsListClient() {
"profile",
locale,
);
const { data: glasserResult } = useGlasserResultQuery(locale, {
enabled: Boolean(profile?.id),
retry: false,
});
const { data: cattellResult } = useCattellResultQuery(locale, {
enabled: Boolean(profile?.id),
retry: false,
});
const isSectionsLoading = false;
@ -205,11 +213,46 @@ export default function QuestionsListClient() {
const profileId = profile?.id;
for (const slug of ["personality_test", "glasser_5_needs_test"]) {
try {
// 1. Check backend test results
if (
(slug === "glasser_5_needs_test" && Boolean(glasserResult)) ||
(slug === "personality_test" && Boolean(cattellResult))
) {
next.set(slug, 100);
continue;
}
// 2. Check local completion flag
if (typeof window !== "undefined") {
const completionKeys = [
profileId ? `marriage:user:${profileId}:sections:${slug}:completed` : null,
`marriage:sections:${slug}:completed`,
`marriage:sections:${slug}:answers`,
].filter(Boolean) as string[];
let isLocalCompleted = false;
for (const key of completionKeys) {
const raw = window.localStorage.getItem(key);
if (raw) {
try {
const parsed = JSON.parse(raw);
if (parsed?.completed === true) {
next.set(slug, 100);
isLocalCompleted = true;
break;
}
} catch {}
}
}
if (isLocalCompleted) continue;
}
// 3. If not completed, read draft progress
let draft: any = null;
if (profileId) {
draft = readScopedAssessmentDraft(profileId, slug);
}
if (!draft) {
if (!draft && typeof window !== "undefined") {
const raw = window.localStorage.getItem(`marriage:tests:${slug}:draft`);
draft = raw ? JSON.parse(raw) : null;
}
@ -220,7 +263,7 @@ export default function QuestionsListClient() {
}
}
setLocalAssessmentProgress(next);
}, [overview, profile?.id]);
}, [overview, profile?.id, glasserResult, cattellResult]);
const sectionProgressBySlug = useMemo(() => {
const progressBySlug = new Map<string, number>();
@ -236,7 +279,6 @@ export default function QuestionsListClient() {
);
}
localAssessmentProgress.forEach((progress, slug) => {
if ((progressBySlug.get(slug) ?? 0) < 100)
progressBySlug.set(slug, progress);
});

20
src/app/request-accepted/request-accepted-client.tsx

@ -9,6 +9,7 @@ import AdvisorActionsCard from "@/components/Componentes/advisor-actions-card";
import MarriageAdvisorsOverlay, {
useMarriageAdvisorsOverlay,
} from "@/components/Componentes/marriage-advisors-overlay";
import ErrorToast from "@/components/Componentes/error-toast";
import MatchProfileOverlay, {
useMatchProfileOverlay,
} from "@/components/Componentes/match-profile-overlay";
@ -199,6 +200,7 @@ export default function RequestAcceptedClient() {
useState(false);
const [hasConfirmedFemaleContact, setHasConfirmedFemaleContact] =
useState(false);
const [showPaymentSuccessToast, setShowPaymentSuccessToast] = useState(false);
const profileHref = localizePath("/new-match/profile", locale);
const { data: profile, isLoading } = useMarriageProfileQuery({
refetchInterval: 3000,
@ -260,6 +262,7 @@ export default function RequestAcceptedClient() {
: caseStatus === "payment_done" || caseStatus === "contacted"
? t["Contact info released"]
: t["Request approved!"];
const primaryActionText = isFemaleProfile
? t["No Contact"] || t["No Contact Received"] || "No Contact"
: t["View profile"];
@ -272,6 +275,14 @@ export default function RequestAcceptedClient() {
contactInfoQuery.data?.contact_info,
);
const handlePrimaryAction = () => {
if (isFemaleProfile) {
setIsDismissReasonSheetOpen(true);
} else {
openProfile();
}
};
const handleSecondaryAction = async () => {
if (isFemaleProfile) {
setIsContactReceivedConfirmOpen(true);
@ -314,6 +325,7 @@ export default function RequestAcceptedClient() {
}
setIsSubscriptionSheetOpen(false);
setShowPaymentSuccessToast(true);
if (caseId) {
await contactInfoQuery.refetch();
@ -773,6 +785,14 @@ export default function RequestAcceptedClient() {
open={isProfileOpen}
onClose={closeProfile}
/>
{showPaymentSuccessToast && (
<ErrorToast
variant="success"
message={t["Payment successful"] || "Payment successful"}
onClose={() => setShowPaymentSuccessToast(false)}
/>
)}
</>
);
}

4
src/components/Componentes/currency-sheet.tsx

@ -148,8 +148,8 @@ export function CurrencySheet({
].join(" ")}
>
{/* Header */}
<div className="flex items-center justify-between px-5 pt-3 pb-3 border-b border-[#F2F4F7]">
<h3 className="text-[17px] font-bold text-[#181818] truncate pr-2">
<div className="flex items-center justify-between gap-3 px-5 pt-3 pb-3 border-b border-[#F2F4F7]">
<h3 className="flex-1 min-w-0 text-[17px] font-bold leading-snug text-[#181818] line-clamp-2 break-words text-start">
{sheetTitle}
</h3>
<button

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

@ -51,7 +51,7 @@ export default function ErrorToast({
return (
<div
className={[
"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",
"fixed top-3.5 left-1/2 z-[70] 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
? "translate-y-0 opacity-100 scale-100"

2
src/components/Componentes/fix-to-the-end.tsx

@ -12,7 +12,7 @@ export function FixToTheEnd({ children, className }: FixToTheEndProps) {
<div
style={{ paddingBottom: "calc(16px + var(--safe-bottom))" }}
className={[
"fixed inset-x-0 bottom-0 z-20 mx-auto w-full sm:max-w-[375px] bg-[#F5F5F5]/95 px-[17px] pt-3 backdrop-blur-md",
"question-fix-to-end fixed inset-x-0 bottom-0 z-20 mx-auto w-full sm:max-w-[375px] bg-[#F5F5F5]/95 px-[17px] pt-3 backdrop-blur-md",
className,
]
.filter(Boolean)

11
src/components/Componentes/question-birthplace.tsx

@ -155,7 +155,12 @@ export function QuestionBirthplace({
const { getAnswerValue, setAnswerValue, isLoading } = useQuestionAnswers();
const rawValue = getAnswerValue(question);
const isResidence = question.ui_config?.enable_geoip === true;
const isResidence =
question.ui_config?.enable_geoip === true ||
question.ui_config?.location_kind === "residence" ||
question.id?.includes("residence") ||
question.id?.includes("living_area") ||
question.id?.includes("location");
const storedRegion = isResidence ? getStoredUserGeoRegion() : null;
const initial = parseValue(rawValue);
@ -707,8 +712,8 @@ export function QuestionBirthplace({
</div>
{/* Header with Title and Close Button */}
<div className="flex items-center justify-between px-5 pt-2 pb-3 border-b border-[#F2F4F7]">
<h3 className="text-[17px] font-bold text-[#181818] truncate pr-2">
<div className="flex items-center justify-between gap-3 px-5 pt-2.5 pb-3 border-b border-[#F2F4F7]">
<h3 className="flex-1 min-w-0 text-[17px] font-bold leading-snug text-[#181818] line-clamp-2 break-words text-start">
{selectCountryPlaceholder}
</h3>
<button

82
src/components/Componentes/question-checkbox.tsx

@ -23,8 +23,81 @@ export function QuestionCheckbox({
? [String(rawValue)]
: [];
if (options.length === 0) {
return null;
const isSingleCheckbox = options.length === 0;
if (isSingleCheckbox) {
const isChecked = Boolean(
rawValue === true ||
rawValue === "true" ||
rawValue === "confirmed" ||
(Array.isArray(rawValue) && rawValue.length > 0),
);
const toggleSingle = () => {
setAnswerValue(question, isChecked ? null : ["confirmed"]);
};
const labelText =
question.description ||
question.title ||
"تأیید می‌کنم که نام، سن، تصویر و اطلاعات هویتی من با مدارک بارگذاری‌شده مطابقت دارد.";
return (
<div
className={[
"flex w-full flex-col gap-3.5 transition-opacity duration-200",
disabled ? "pointer-events-none opacity-30" : "",
].join(" ")}
>
<QuestionTitle question={question} />
<div className="flex flex-col gap-3.5 pt-1">
<label
htmlFor={`checkbox-${question.id}`}
className={[
"cursor-pointer rounded-[16px] font-semibold text-[15px] transition-all duration-200 active:scale-[0.99] flex items-center gap-3 w-full px-5 py-4 text-start leading-snug",
isChecked
? "bg-[#F0445B] text-white shadow-[0_8px_20px_rgba(240,68,91,0.25)]"
: "bg-[#FAFAFA] text-[#181818] hover:bg-white shadow-[0_2px_6px_rgba(0,0,0,0.03)] border border-[#F0EDED]",
].join(" ")}
>
<input
type="checkbox"
id={`checkbox-${question.id}`}
checked={isChecked}
disabled={disabled}
onChange={toggleSingle}
className="sr-only"
/>
<div
className={[
"size-[18px] shrink-0 rounded-[5px] border-[2px] transition-all flex items-center justify-center",
isChecked
? "border-white bg-white text-[#F0445B]"
: "border-[#D0D5DD] bg-white text-transparent",
].join(" ")}
>
{isChecked && (
<svg
width="10"
height="8"
viewBox="0 0 10 8"
fill="none"
className="stroke-current stroke-[2]"
>
<path
d="M1 4L3.5 6.5L9 1"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
</div>
<span className="flex-1">{labelText}</span>
</label>
</div>
</div>
);
}
const toggleOption = (optionId: string) => {
@ -40,9 +113,10 @@ export function QuestionCheckbox({
);
};
// Render horizontally only for 2-option binary choices
const isShortOptions =
options.length <= 4 &&
options.every((opt) => String(opt.label || opt.value || "").length <= 15);
options.length <= 2 &&
options.every((opt) => String(opt.label || opt.value || "").length <= 10);
return (
<div

4
src/components/Componentes/question-date-sheet.tsx

@ -294,8 +294,8 @@ export function QuestionDateSheet({
isClosing ? "flutter-sheet-surface-exit" : "flutter-sheet-surface",
].join(" ")}
>
<div className="flex items-center justify-between border-b border-[#F2F4F7] px-5 pb-3 pt-2">
<h3 className="truncate pr-2 text-[17px] font-bold text-[#181818]">
<div className="flex items-center justify-between gap-3 border-b border-[#F2F4F7] px-5 pb-3 pt-2.5">
<h3 className="flex-1 min-w-0 text-[17px] font-bold leading-snug text-[#181818] line-clamp-2 break-words text-start">
{question.title}
</h3>
<button

22
src/components/Componentes/question-date.test.tsx

@ -70,4 +70,26 @@ describe("QuestionDate", () => {
expect(screen.queryByText("Age")).toBeNull();
expect(screen.getByText("Select date")).toBeDefined();
});
it("renders long date question title in sheet header with line-clamp-2 and without truncate", () => {
const longTitle =
"تاریخ دقیق تولد شما بر اساس مدارک شناسایی رسمی و شناسنامه معتبر";
const longDateQ = {
...question,
title: longTitle,
};
render(<QuestionDate question={longDateQ} />);
fireEvent.click(screen.getByRole("button", { name: /select date/i }));
const dialog = screen.getByRole("dialog");
expect(dialog).toBeDefined();
const heading = dialog.querySelector("h3");
expect(heading).toBeDefined();
expect(heading?.textContent?.trim()).toBe(longTitle);
expect(heading).toHaveClass("line-clamp-2");
expect(heading).toHaveClass("flex-1");
expect(heading).not.toHaveClass("truncate");
});
});

3
src/components/Componentes/question-number.test.tsx

@ -40,10 +40,11 @@ describe("QuestionNumber Component", () => {
type: "number",
order: 1,
required: true,
baseRequired: true,
isVisible: true,
description: "",
tooltip: "",
extras: { placeHolder: "e.g. 3", range: [0, 20] },
extras: { placeHolder: "e.g. 3", range: [0, 20], options: [] },
options: [],
ui_config: {},
};

4
src/components/Componentes/question-phone.tsx

@ -816,8 +816,8 @@ export function QuestionPhone({
].join(" ")}
>
{/* Header with Title and Close Button */}
<div className="flex items-center justify-between px-5 pt-2 pb-3 border-b border-[#F2F4F7]">
<h3 className="text-[17px] font-bold text-[#181818] truncate pr-2">
<div className="flex items-center justify-between gap-3 px-5 pt-2.5 pb-3 border-b border-[#F2F4F7]">
<h3 className="flex-1 min-w-0 text-[17px] font-bold leading-snug text-[#181818] line-clamp-2 break-words text-start">
{selectCountryTitle}
</h3>
<button

6
src/components/Componentes/question-radio.tsx

@ -22,10 +22,10 @@ export function QuestionRadio({
return null;
}
// Render horizontally if all options are short (e.g. Single, Divorced, Widowed)
// Render horizontally only for 2-option binary choices (e.g. Yes/No, Male/Female)
const isShortOptions =
options.length <= 4 &&
options.every((opt) => String(opt.label || opt.value || "").length <= 15);
options.length <= 2 &&
options.every((opt) => String(opt.label || opt.value || "").length <= 10);
return (
<div

17
src/components/Componentes/question-renderer.tsx

@ -33,6 +33,8 @@ export function QuestionRenderer({
switch (question.type) {
case "birthplace":
case "location":
case "residence":
return (
<QuestionBirthplace
question={question}
@ -117,6 +119,7 @@ export function QuestionRenderer({
);
case "slider":
case "scale":
case "range":
return (
<QuestionSlider
question={question}
@ -124,6 +127,20 @@ export function QuestionRenderer({
/>
);
case "text":
if (
question.ui_config?.enable_geoip === true ||
question.ui_config?.location_kind === "residence" ||
question.id?.includes("approximate_location_of_current_living_area") ||
question.id?.includes("current_residence") ||
question.id?.includes("living_area")
) {
return (
<QuestionBirthplace
question={question}
disabled={disabled}
/>
);
}
return (
<QuestionText
question={question}

48
src/components/Componentes/question-sheet.test.tsx

@ -433,4 +433,52 @@ describe("QuestionSheet component", () => {
expect(screen.queryByText("Antigua and Barbuda")).toBeNull();
expect(screen.queryByText("افغانستان")).toBeNull();
});
it("renders long question title with line-clamp-2 and flex-1 classes in sheet header", () => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const longTitle =
"میزان پایبندی شما به مناسک و تکالیف شرعی و اعتقادات مذهبی در زندگی روزمره خانوادگی و اجتماعی";
const qLong = {
id: "religious_commitment_details",
title: longTitle,
type: "dropdown",
order: 1,
required: true,
isVisible: true,
description: "",
tooltip: "",
extras: { placeHolder: "انتخاب کنید" },
options: [
{ id: "high", value: "high", label: "بسیار زیاد", order: 1 },
{ id: "medium", value: "medium", label: "متوسط", order: 2 },
],
ui_config: {},
} as QuestionField;
render(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider slug="test" questions={[qLong]}>
<QuestionSheet question={qLong} />
</QuestionAnswersProvider>
</QueryClientProvider>,
);
const trigger = screen.getByRole("button", { name: "انتخاب کنید" });
fireEvent.click(trigger);
const dialog = screen.getByRole("dialog");
expect(dialog).toBeDefined();
const heading = dialog.querySelector("h3");
expect(heading).toBeDefined();
expect(heading?.textContent?.trim()).toBe(longTitle);
expect(heading).toHaveClass("line-clamp-2");
expect(heading).toHaveClass("flex-1");
expect(heading).not.toHaveClass("truncate");
});
});

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

@ -436,8 +436,8 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
isClosing ? "flutter-sheet-surface-exit" : "flutter-sheet-surface",
].join(" ")}
>
<div className="flex items-center justify-between px-5 pt-2 pb-3 border-b border-[#F2F4F7]">
<h3 className="text-[17px] font-bold text-[#181818] truncate pr-2">
<div className="flex items-center justify-between gap-3 px-5 pt-2.5 pb-3 border-b border-[#F2F4F7]">
<h3 className="flex-1 min-w-0 text-[17px] font-bold leading-snug text-[#181818] line-clamp-2 break-words text-start">
{question.title}
</h3>
<button

13
src/components/Componentes/question-slider.tsx

@ -86,7 +86,10 @@ export function QuestionSlider({
? Number(question.extras.placeHolder)
: Math.round((min + max) / 2);
const isDesiredAgeRange = false;
const isDesiredAgeRange =
question.type === "range" ||
(question.id || "").toLowerCase().includes("desired_age") ||
(question.id || "").toLowerCase().includes("age_range");
const thumbWidth = 18;
@ -124,7 +127,7 @@ export function QuestionSlider({
})();
// Multi-state logic for Age Range Slider
let fromVal = 25;
let fromVal = 20;
let toVal = 30;
if (isDesiredAgeRange) {
if (typeof storedValue === "string" && storedValue.includes("-")) {
@ -140,6 +143,12 @@ export function QuestionSlider({
} else if (typeof storedValue === "number") {
fromVal = storedValue;
toVal = Math.max(storedValue, max);
} else if (typeof question.extras?.placeHolder === "string") {
const match = question.extras.placeHolder.match(/(\d+)[^\d]+(\d+)/);
if (match) {
fromVal = Number(match[1]);
toVal = Number(match[2]);
}
}
fromVal = Math.max(min, Math.min(max, fromVal));
toVal = Math.max(min, Math.min(max, toVal));

2
src/components/Componentes/question-textarea.tsx

@ -88,7 +88,7 @@ export function QuestionTextarea({
placeholder={question.extras.placeHolder}
disabled={disabled}
rows={5}
className="h-[270px]"
className="h-[180px] sm:h-[220px] resize-none"
/>
{description ? (
<span className="block group-10 font-semibold text-[#747474]">

49
src/components/Componentes/question-viewport-coordinator.ts

@ -29,11 +29,16 @@ const NON_KEYBOARD_INPUT_TYPES = new Set([
"submit",
]);
const DEFAULT_TOP_GAP = 48;
const DEFAULT_BOTTOM_GAP = 16;
export type QuestionLiftGeometry = {
baseTop: number;
baseBottom: number;
visibleTop: number;
visibleBottom: number;
topGap?: number;
bottomGap?: number;
gap?: number;
};
@ -42,18 +47,28 @@ export function computeQuestionLift({
baseBottom,
visibleTop,
visibleBottom,
topGap,
bottomGap,
gap = QUESTION_GAP,
}: QuestionLiftGeometry) {
const actualTopGap = topGap ?? gap ?? DEFAULT_TOP_GAP;
const actualBottomGap = bottomGap ?? gap ?? DEFAULT_BOTTOM_GAP;
const contentHeight = baseBottom - baseTop;
const availableTop = visibleTop + gap;
const availableBottom = visibleBottom - gap;
const availableTop = visibleTop + actualTopGap;
const availableBottom = visibleBottom - actualBottomGap;
const availableCenter = (availableTop + availableBottom) / 2;
const contentCenter = baseTop + contentHeight / 2;
const centerShift = Math.max(0, contentCenter - availableCenter);
const overlapShift = Math.max(0, baseBottom - availableBottom);
const maxShift = Math.max(0, baseTop - availableTop);
return Math.min(Math.max(centerShift, overlapShift), maxShift);
// When content is taller than available space, don't shove top to the extreme limit;
// prioritize keeping badge and title readable with comfortable top clearance.
const idealShift = contentHeight > (availableBottom - availableTop)
? Math.min(overlapShift, maxShift)
: Math.max(centerShift, overlapShift);
return Math.min(idealShift, maxShift);
}
function isKeyboardInputTarget(
@ -94,7 +109,8 @@ function setLift(nextLift: number) {
const prevLift = currentLift;
currentLift = Math.max(0, Math.round(nextLift));
document.documentElement.style.setProperty(LIFT_VARIABLE, `${currentLift}px`);
const isOpen = currentLift > 0 && keyboardVisible && activeQuestionInput !== null;
const isOpen =
(currentLift > 0 || keyboardVisible) && activeQuestionInput !== null;
document.body.classList.toggle("question-keyboard-open", isOpen);
console.log(`[Coord] setLift: ${prevLift}px -> ${currentLift}px | visible: ${keyboardVisible} | class: ${isOpen}`);
}
@ -157,17 +173,30 @@ function updateLift() {
const safeTop = Number.parseFloat(
getComputedStyle(document.documentElement).getPropertyValue("--safe-top"),
);
const visibleTop = Math.max(
Number.isFinite(safeTop) ? safeTop : 0,
snapList?.getBoundingClientRect().top ?? 0,
);
const visibleBottom = Math.min(...visibleBottomCandidates);
const badgeEl = content.querySelector<HTMLElement>(
'.rounded-full.bg-\\[\\#F8D7DA\\]',
);
const badgeTop = badgeEl ? badgeEl.getBoundingClientRect().top : null;
const inputRect = activeQuestionInput?.getBoundingClientRect();
const computed = computeQuestionLift({
baseTop,
baseBottom,
visibleTop: Math.max(
Number.isFinite(safeTop) ? safeTop : 0,
snapList?.getBoundingClientRect().top ?? 0,
),
visibleBottom: Math.min(...visibleBottomCandidates),
visibleTop,
visibleBottom,
topGap: DEFAULT_TOP_GAP,
bottomGap: DEFAULT_BOTTOM_GAP,
});
console.log(`[Coord] computed lift: ${computed}px (baseTop: ${baseTop.toFixed(0)}, baseBottom: ${baseBottom.toFixed(0)}, kTop: ${keyboardTop?.toFixed(0)})`);
console.log(
`[Coord Geometry] lift: ${computed}px | content: [${baseTop.toFixed(0)}..${baseBottom.toFixed(0)}] h=${(baseBottom - baseTop).toFixed(0)} | vis: [${visibleTop.toFixed(0)}..${visibleBottom.toFixed(0)}] (kTop: ${keyboardTop?.toFixed(0)}) | badgeTop: ${badgeTop?.toFixed(0) ?? "none"} | input: [${inputRect?.top.toFixed(0) ?? "?"}..${inputRect?.bottom.toFixed(0) ?? "?"}]`,
);
setLift(computed);
}

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

@ -133,7 +133,7 @@ export default function SliderPage({ onClose }: SliderPageProps = {}) {
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 pt-[max(12px,calc(var(--safe-top)+4px))] pb-3">
<div className="relative flex items-center justify-between">
<NavigationButton
icon="back"
@ -185,7 +185,7 @@ export default function SliderPage({ onClose }: SliderPageProps = {}) {
</div>
</header>
<main className="flex min-h-0 flex-1 overflow-hidden pt-6">
<main className="flex min-h-0 flex-1 overflow-hidden pt-3">
<div
className="flex h-full w-full transition-transform duration-300 ease-out"
style={{ transform: `translateX(-${activeSlide * 100}%)` }}

2
src/components/Componentes/slider-slide-one.tsx

@ -60,7 +60,7 @@ export function SliderSlideOne({ index, onScrollToEnd }: SliderSlideOneProps) {
}}
>
<SliderHeader index={index} title="Terms & Conditions" />
<div className="bg-[#F14B46]/10 py-2.5 px-3.5 border border-[#F14B46] rounded-xl mt-8">
<div className="bg-[#F14B46]/10 py-2.5 px-3.5 border border-[#F14B46] rounded-xl mt-4">
<p className="text-[#F14B46] group-12 font-semibold text-center">
Welcome to Habib Marriage. Please review our terms of service before
proceeding. By registering and using this platform, you agree to

19
src/components/Componentes/test-questions-flow.tsx

@ -12,7 +12,6 @@ import NavigationButton from "./navigation-button";
import { PageBackground } from "./page-background";
import StickyHeader from "./sticky-header";
import TestExitSheet from "./test-exit-sheet";
import TestLoadingScreen from "./test-loading-screen";
export type QuestionOption = {
id: string;
@ -225,15 +224,6 @@ export default function TestQuestionsFlow({
}
};
if (isSubmitting) {
return (
<TestLoadingScreen
title="Analyzing and submitting responses"
subtitle="Please wait a moment while your answers are reviewed and registered."
/>
);
}
if (!currentQuestion) {
return null;
}
@ -306,7 +296,7 @@ export default function TestQuestionsFlow({
key={q.id}
aria-hidden={offset !== 0}
className={[
"absolute inset-0 flex flex-col overflow-y-auto pt-2 pb-4 will-change-transform transition-transform duration-200 ease-in-out",
"absolute inset-0 flex flex-col overflow-y-auto pt-3 pb-6 will-change-transform transition-transform duration-200 ease-in-out",
offset === 0 && !isSliding
? "pointer-events-auto"
: "pointer-events-none",
@ -316,14 +306,14 @@ export default function TestQuestionsFlow({
}}
>
{/* Question Title */}
<div className="w-full min-h-[100px] sm:min-h-[120px] flex items-center justify-center my-3 sm:my-4 shrink-0 px-2">
<h2 className="text-[17px] sm:text-[18px] font-bold text-[#1F2024] leading-[1.6] text-center w-full">
<div className="w-full min-h-[110px] sm:min-h-[130px] flex items-center justify-center mt-3 mb-6 sm:mt-5 sm:mb-8 shrink-0 px-3">
<h2 className="text-[18px] sm:text-[19px] font-bold text-[#1F2024] leading-[1.7] text-center w-full">
{q.text}
</h2>
</div>
{/* Answer Options Stack */}
<div className="w-full flex flex-col gap-3 pb-3">
<div className="w-full flex flex-col gap-3.5 pb-6">
{options.map((option) => {
const isSelected = qSelectedValue === option.value;
@ -426,6 +416,7 @@ export default function TestQuestionsFlow({
!areAllQuestionsAnswered ||
isSubmitting
}
isLoading={isSubmitting}
onClick={handleFinishSubmit}
>
{finishLabel}

5
src/hooks/marriage/use-cattell.ts

@ -86,9 +86,12 @@ export function useSubmitCattellAssessmentMutation(
queryKey: marriageQueryKeys.cattellResult(),
});
queryClient.setQueriesData<FormSchemaResponse>(
{ queryKey: ["marriage", "form-schema", "profile"] },
{ queryKey: [...marriageQueryKeys.all, "form-overview"] },
(schema) => markAssessmentCompleted(schema, "personality_test"),
);
await queryClient.invalidateQueries({
queryKey: [...marriageQueryKeys.all, "form-overview"],
});
await options?.onSuccess?.(data, variables, onMutateResult, context);
},
});

10
src/hooks/marriage/use-glasser.ts

@ -61,7 +61,7 @@ export function useSubmitGlasserAssessmentMutation(
queryKey: marriageQueryKeys.glasserResult(),
});
queryClient.setQueriesData<FormSchemaResponse>(
{ queryKey: ["marriage", "form-schema", "profile"] },
{ queryKey: [...marriageQueryKeys.all, "form-overview"] },
(schema) => {
if (!schema) return schema;
const slug = "glasser_5_needs_test";
@ -84,11 +84,19 @@ export function useSubmitGlasserAssessmentMutation(
total_steps: 1,
completion_percent: 100,
},
glasser: {
current_step: 1,
total_steps: 1,
completion_percent: 100,
},
},
},
};
},
);
await queryClient.invalidateQueries({
queryKey: [...marriageQueryKeys.all, "form-overview"],
});
await options?.onSuccess?.(data, variables, onMutateResult, context);
},
});

26
src/hooks/marriage/use-section-data.ts

@ -149,22 +149,28 @@ export function applyProfilePatchResultToCache(
const slug = String(queryKey[3] ?? "");
queryClient.setQueryData(queryKey, patchSection(current, slug));
});
queryClient.setQueryData(
marriageQueryKeys.formOverview("profile", locale),
(current: any) =>
current
? {
queryClient
.getQueriesData({
queryKey: [...marriageQueryKeys.all, "form-overview"],
})
.forEach(([queryKey, current]: [any, any]) => {
if (!current) return;
queryClient.setQueryData(queryKey, {
...current,
progress: data.progress,
sections: current.sections.map((section: any) => {
sections: current.sections?.map((section: any) => {
const progress =
data.affected_sections[section.id] ??
data.progress.sections_progress[section.id];
data.progress?.sections_progress?.[section.id];
return progress ? { ...section, progress } : section;
}),
}
: current,
);
});
});
void queryClient.invalidateQueries({
queryKey: [...marriageQueryKeys.all, "form-overview"],
exact: false,
});
}
export function useMarriageSectionDataQuery(

8
src/lib/marriage-field-formatter.ts

@ -122,7 +122,13 @@ export function formatFieldLabel(
return matchedCanonicalKey;
}
// 5. Fallback
// 5. If rawLabel contains Persian/Arabic characters and active dictionary is English, use englishTitle
const hasPersian = /[؀-ۿ]/.test(rawLabel);
if (hasPersian && dictionary) {
return englishTitle || rawLabel;
}
// 6. Fallback
return rawLabel || englishTitle;
}

52
src/lib/ssr-fetch.ts

@ -1,4 +1,6 @@
// Server-only module: imported only by Server Components (page.tsx wrappers).
import { cookies, headers } from "next/headers";
import { defaultLocale, isLocale } from "@/translations/config";
/**
* Helper to get the API base URL for server-side fetches.
@ -18,6 +20,41 @@ const DEFAULT_SSR_TIMEOUT_MS =
Number(process.env.SSR_FETCH_TIMEOUT_MS) ||
(process.env.NODE_ENV === "development" ? 15000 : 1500);
async function resolveServerLocale(
localeOverride?: string | null,
): Promise<string> {
if (localeOverride && isLocale(localeOverride)) {
return localeOverride;
}
try {
const cookieStore = await cookies();
const cookieLang =
cookieStore.get("HABIB_LANGUAGE")?.value ??
cookieStore.get("habib_language")?.value;
if (isLocale(cookieLang)) {
return cookieLang;
}
} catch {
// Ignore if called outside request context
}
try {
const headersList = await headers();
const headerLang = headersList
.get("accept-language")
?.split(",")[0]
?.split("-")[0];
if (isLocale(headerLang)) {
return headerLang;
}
} catch {
// Ignore if called outside request context
}
return defaultLocale;
}
/**
* Fetches the marriage profile server-side.
* Runs only on the server, typically inside a Next.js Server Component.
@ -25,11 +62,13 @@ const DEFAULT_SSR_TIMEOUT_MS =
*
* @param token - The user authentication token
* @param timeoutMs - Timeout in milliseconds (defaults to 15s in dev, 1.5s in prod)
* @param localeOverride - Optional locale override
* @returns Profile data or null on failure
*/
export async function fetchProfileSSR(
token?: string | null,
timeoutMs = DEFAULT_SSR_TIMEOUT_MS,
localeOverride?: string | null,
): Promise<any | null> {
if (!token || token === "NO_TOKEN" || token.trim() === "") {
console.log("🖥️ [SSR fetchProfileSSR] No valid token found in cookies.");
@ -40,10 +79,11 @@ export async function fetchProfileSSR(
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const lang = await resolveServerLocale(localeOverride);
const baseUrl = getApiBaseUrl();
const url = `${baseUrl}/api/marriage/profile/main/`;
const url = `${baseUrl}/api/marriage/profile/main/?lang=${lang}`;
console.log(
`🖥️ [SSR fetchProfileSSR] Fetching profile from ${url} with token: ${token.slice(0, 8)}...`,
`🖥️ [SSR fetchProfileSSR] Fetching profile from ${url} with token: ${token.slice(0, 8)}... (lang: ${lang})`,
);
const response = await fetch(url, {
@ -53,6 +93,8 @@ export async function fetchProfileSSR(
headers: {
Accept: "application/json",
Authorization: `Token ${token.trim()}`,
"Accept-Language": lang,
"X-User-Language": lang,
},
});
@ -88,13 +130,15 @@ export async function fetchProfileSSR(
*/
export async function fetchConfigSSR(
timeoutMs = DEFAULT_SSR_TIMEOUT_MS,
localeOverride?: string | null,
): Promise<any | null> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const lang = await resolveServerLocale(localeOverride);
const baseUrl = getApiBaseUrl();
const url = `${baseUrl}/api/marriage/config/`;
const url = `${baseUrl}/api/marriage/config/?lang=${lang}`;
const response = await fetch(url, {
method: "GET",
@ -102,6 +146,8 @@ export async function fetchConfigSSR(
signal: controller.signal,
headers: {
Accept: "application/json",
"Accept-Language": lang,
"X-User-Language": lang,
},
});

Loading…
Cancel
Save