Browse Source

feat: implement dynamic question components, slider functionality, and registration flow pages

front-test-2
ghorbani 4 weeks ago
parent
commit
130f518c45
  1. 8
      src/app/finding-match/page.tsx
  2. 33
      src/app/globals.css
  3. 18
      src/app/intro/page.tsx
  4. 2
      src/app/questions-list/[slug]/question-detail-client.tsx
  5. 6
      src/app/questions-list/page.tsx
  6. 20
      src/app/request-accepted/page.tsx
  7. 10
      src/app/request-sent/page.tsx
  8. 2
      src/components/questions/question-button.tsx
  9. 10
      src/components/questions/question-card.tsx
  10. 2
      src/components/questions/question-checkbox.tsx
  11. 10
      src/components/questions/question-date.tsx
  12. 10
      src/components/questions/question-dropdown.tsx
  13. 197
      src/components/questions/question-file.tsx
  14. 6
      src/components/questions/question-phone.tsx
  15. 197
      src/components/questions/question-photo.tsx
  16. 4
      src/components/questions/question-progress-tracker.tsx
  17. 2
      src/components/questions/question-radio.tsx
  18. 4
      src/components/questions/question-slider.tsx
  19. 4
      src/components/questions/question-snap-list.tsx
  20. 10
      src/components/questions/question-text.tsx
  21. 8
      src/components/questions/question-title.tsx
  22. 6
      src/components/questions/required-steps-card.tsx
  23. 10
      src/components/sliders/slider-slide-four.tsx
  24. 24
      src/components/sliders/slider-slide-one.tsx
  25. 8
      src/components/sliders/slider-slide-three.tsx
  26. 10
      src/components/sliders/slider-slide-two.tsx
  27. 6
      src/components/ui/advisor-actions-card.tsx
  28. 6
      src/components/ui/button.tsx
  29. 10
      src/components/ui/info-progress-card.tsx
  30. 4
      src/components/ui/information-sheet.tsx
  31. 9
      src/components/ui/sticky-header.tsx
  32. 1
      src/hooks/marriage/use-upload-tmp-media.ts
  33. 94
      src/i18n/locales/en/questions.json
  34. 94
      src/i18n/locales/fa/questions.json

8
src/app/finding-match/page.tsx

@ -35,7 +35,7 @@ export default function FindingMatchPage() {
>
<header className="-mx-[6px] flex items-center justify-between">
<NavigationButton icon="back" />
<h1 className="font-faminela">{t.common.appName}</h1>
<h1 className="font-faminela group-16">{t.common.appName}</h1>
<NavigationButton icon="subscription" iconLabel="Subscribe" />
</header>
@ -51,11 +51,11 @@ export default function FindingMatchPage() {
/>
</div>
<h1 className="mt-5 text-[22px] font-bold leading-none tracking-[0.02em] text-[#171717] uppercase">
<h1 className="mt-5 group-16 font-bold leading-none tracking-[0.02em] text-[#171717] uppercase">
{copy.title}
</h1>
<p className="mt-3 max-w-[320px] text-[14px] leading-[1.35] font-semibold text-[#747474]">
<p className="mt-3 max-w-[320px] group-12 leading-[1.35] font-semibold text-[#747474]">
{copy.description}
</p>
</section>
@ -73,7 +73,7 @@ export default function FindingMatchPage() {
className="inline-flex mt-3.5 w-full cursor-pointer items-center justify-center rounded-[9px] border-none bg-[#2B2C31] bg-none px-4 py-[17px] text-center text-white shadow-none transition-opacity"
href={localizePath("/questions-list", locale)}
>
<span className="flex min-w-0 items-center justify-center gap-2 text-center text-[16px] leading-none font-semibold">
<span className="flex min-w-0 items-center justify-center gap-2 text-center group-16 leading-none font-semibold">
<FaPen aria-hidden="true" className="h-3.5 w-3.5 shrink-0" />
<span>{copy.editProfile}</span>
</span>

33
src/app/globals.css

@ -18,6 +18,39 @@
--font-faminela: var(--font-faminela-local);
}
/* Typography Configuration - Centralized Design System Scale */
@utility group-10 {
font-size: 10px;
}
@utility group-12 {
font-size: 12px;
}
@utility group-14 {
font-size: 14px;
}
@utility group-16 {
font-size: 16px;
}
.group-10 {
font-size: 10px;
}
.group-12 {
font-size: 12px;
}
.group-14 {
font-size: 14px;
}
.group-16 {
font-size: 16px;
}
html {
min-height: 100%;
}

18
src/app/intro/page.tsx

@ -144,7 +144,7 @@ export default function Intro() {
)}
<header className="flex items-center justify-between">
<NavigationButton icon="back" />
<h1 className="font-faminela">{t.common.appName}</h1>
<h1 className="font-faminela group-16">{t.common.appName}</h1>
<NavigationButton
icon="support"
iconLabel={t.common.support}
@ -159,10 +159,10 @@ export default function Intro() {
width={168}
height={155}
/>
<h2 className="text-[#475569] font-bold mt-5 text-center">
<h2 className="group-16 text-[#475569] font-bold mt-5 text-center">
{t.intro.title}
</h2>
<p className=" text-center mt-2 text-xs text-[#4D4D4D]">
<p className="text-center mt-2 group-12 text-[#4D4D4D]">
{t.intro.description}
</p>
</div>
@ -175,8 +175,8 @@ export default function Intro() {
height={37}
/>
<div className="flex flex-col items-start justify-start -mb-2">
<p className="text-[#36363C] text-lg font-bold leading-3">120</p>
<p className="text-[#36363C] text-[10px] font-semibold">
<p className="text-[#36363C] group-16 font-bold leading-3">120</p>
<p className="text-[#36363C] group-10 font-semibold">
{t.intro.userProfile}
</p>
</div>
@ -189,8 +189,8 @@ export default function Intro() {
height={37}
/>
<div className="flex flex-col items-start justify-start -mb-2">
<p className="text-[#36363C] text-lg font-bold leading-3">14</p>
<p className="text-[#36363C] text-[10px] font-semibold">
<p className="text-[#36363C] group-16 font-bold leading-3">14</p>
<p className="text-[#36363C] group-10 font-semibold">
{t.intro.matches}
</p>
</div>
@ -203,8 +203,8 @@ export default function Intro() {
height={37}
/>
<div className="flex flex-col items-start justify-start -mb-2">
<p className="text-[#36363C] text-lg font-bold leading-3">14</p>
<p className="text-[#36363C] text-[10px] font-semibold">
<p className="text-[#36363C] group-16 font-bold leading-3">14</p>
<p className="text-[#36363C] group-10 font-semibold">
{t.intro.marriage}
</p>
</div>

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

@ -452,7 +452,7 @@ export default function QuestionDetailClient({
icon="close"
iconLabel={closeLabel}
/>
<h1 className="min-w-0 flex-1 text-center font-semibold text-white">
<h1 className="min-w-0 flex-1 text-center font-semibold text-white truncate">
{item.title}
</h1>
<NavigationButton

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

@ -152,7 +152,7 @@ export default function QuestionsListPage() {
icon={null}
title={({ close }) => (
<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="group-14 leading-5 font-bold tracking-normal text-[#8B8B8B]">
{selectedSection.title}
</span>
<button
@ -166,7 +166,7 @@ export default function QuestionsListPage() {
</span>
)}
description={
<p className="px-0.5 text-sm leading-[1.45] text-[#2D2D2D]">
<p className="px-0.5 group-14 leading-[1.45] text-[#2D2D2D]">
{selectedSection.summary}
</p>
}
@ -190,7 +190,7 @@ export default function QuestionsListPage() {
icon="back"
iconLabel={t.questions.closeQuestionsList}
/>
<h1 className="text-[13px] font-semibold text-[#151515]">
<h1 className="group-16 font-semibold text-[#151515]">
{t.questions.profileRegistration}
</h1>
<NavigationButton

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

@ -110,11 +110,11 @@ function ContactInfoPhoneCard({ item }: { item: ContactInfoPhoneItem }) {
<div className="min-w-0 flex-1 text-left">
<p
dir="ltr"
className="text-lg leading-none font-semibold tracking-[0.01em] text-[#4D4D4D] tabular-nums"
className="group-16 leading-none font-semibold tracking-[0.01em] text-[#4D4D4D] tabular-nums"
>
{item.phoneNumber}
</p>
<p className="mt-2 text-[10px] leading-none font-semibold text-[#F0445B]">
<p className="mt-2 group-10 leading-none font-semibold text-[#F0445B]">
{item.label}
</p>
</div>
@ -237,7 +237,7 @@ export default function RequestAcceptedPage() {
))}
</div>
) : (
<div className="rounded-[12px] bg-[#ECECEC] px-4 py-3 text-sm font-semibold text-[#555]">
<div className="rounded-[12px] bg-[#ECECEC] px-4 py-3 group-12 font-semibold text-[#555]">
Contact information is not available yet.
</div>
)
@ -260,7 +260,7 @@ export default function RequestAcceptedPage() {
>
<header className="-mx-[6px] flex items-center justify-between">
<NavigationButton icon="back" />
<h1 className="font-faminela">Habib Marriage</h1>
<h1 className="font-faminela group-16">Habib Marriage</h1>
<NavigationButton icon="support" iconLabel="Support" />
</header>
@ -277,18 +277,18 @@ export default function RequestAcceptedPage() {
/>
</div>
<h1 className="mt-11 text-[22px] leading-none font-black tracking-[0.02em] text-[#171717] uppercase">
<h1 className="mt-11 group-16 leading-none font-black tracking-[0.02em] text-[#171717] uppercase">
{titleText}
</h1>
<p className="mt-4 max-w-[315px] text-[16px] leading-[1.45] font-semibold text-[#777777]">
<p className="mt-4 max-w-[315px] group-12 leading-[1.45] font-semibold text-[#777777]">
You can now view their family&apos;s contact details and arrange
further steps.
</p>
<div className="flex mt-9 w-full justify-center gap-4">
<Link href={profileHref} className="max-w-[212px]">
<div className="bg-[#F5F5F5] px-4 py-2 rounded-[15px] shadow text-sm text-center font-semibold text-[#36363C]">
<div className="bg-[#F5F5F5] px-4 py-2 rounded-[15px] shadow group-14 text-center font-semibold text-[#36363C]">
{primaryActionText}
</div>
</Link>
@ -318,14 +318,14 @@ export default function RequestAcceptedPage() {
}}
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 text-sm text-center font-semibold text-[#fff] shadow-md shadow-[#F2596E]/60">
<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>
</button>
</div>
<div className="border border-[#F0445B] bg-[#F0445B]/10 rounded-xl mt-4">
<p className="text-[#F0445B] text-xs font-semibold py-2.5 px-3.5">
<p className="text-[#F0445B] group-12 font-semibold py-2.5 px-3.5">
If they dont contact you within 2 days, please inform us.
</p>
</div>
@ -341,7 +341,7 @@ export default function RequestAcceptedPage() {
alt="lock"
/>
<h2 className="text-[17px] leading-none font-semibold text-[#747474]">
<h2 className="group-14 leading-none font-semibold text-[#747474]">
Profile is locked
</h2>
</div>

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

@ -27,7 +27,7 @@ export default function RequestSentPage() {
>
<header className="-mx-[6px] flex items-center justify-between">
<NavigationButton icon="back" />
<h1 className="font-faminela">Habib Marriage</h1>
<h1 className="font-faminela group-16">Habib Marriage</h1>
<NavigationButton icon="support" iconLabel="Support" />
</header>
@ -44,16 +44,16 @@ export default function RequestSentPage() {
/>
</div>
<h1 className="mt-11 text-[22px] leading-none font-black tracking-[0.02em] text-[#171717] uppercase">
<h1 className="mt-11 group-16 leading-none font-black tracking-[0.02em] text-[#171717] uppercase">
Request Sent
</h1>
<p className="mt-4 max-w-[315px] text-[16px] leading-[1.45] font-semibold text-[#777777]">
<p className="mt-4 max-w-[315px] group-12 leading-[1.45] font-semibold text-[#777777]">
We will propose marriage on your behalf. If they accept, their contact details will be shared with you.
</p>
<Link href={"/new-match/profile"} className="mt-9 w-full max-w-[212px]">
<div className="bg-[#F5F5F5] px-4 py-2 rounded-[15px] shadow text-sm text-center font-semibold text-[#36363C]">
<div className="bg-[#F5F5F5] px-4 py-2 rounded-[15px] shadow group-14 text-center font-semibold text-[#36363C]">
Match Profile
</div>
</Link >
@ -78,7 +78,7 @@ export default function RequestSentPage() {
alt="lock"
/>
<h2 className="text-[17px] leading-none font-semibold text-[#747474]">
<h2 className="group-14 leading-none font-semibold text-[#747474]">
Profile is locked
</h2>
</div>

2
src/components/questions/question-button.tsx

@ -42,7 +42,7 @@ export function QuestionButton({
type="button"
disabled={disabled}
onClick={() => setValue(true)}
className="min-h-[50px] rounded-[15px] bg-[#181818] px-5 py-3 text-[15px] font-semibold text-white"
className="min-h-[50px] rounded-[15px] bg-[#181818] px-5 py-3 group-14 font-semibold text-white"
>
{question.extras.options[0] ||
question.extras.placeHolder ||

10
src/components/questions/question-card.tsx

@ -82,16 +82,16 @@ export function QuestionCard({
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-baseline gap-1">
<h2 className="truncate text-[12px] leading-none font-bold text-[#1B1B1B]">
<h2 className="truncate group-14 leading-none font-bold text-[#1B1B1B]">
{item.title}
</h2>
{item.required ? (
<span className="shrink-0 text-[9px] leading-none font-semibold text-[#FF5B73]">
<span className="shrink-0 group-10 leading-none font-semibold text-[#FF5B73]">
({t.common.required})
</span>
) : null}
</div>
<p className="mt-1.5 text-[10px] leading-none font-medium text-[#7A7A7A]">
<p className="mt-1.5 group-10 leading-none font-medium text-[#7A7A7A]">
{t.common.estimateTime}: {item.estimate}
</p>
</div>
@ -151,7 +151,7 @@ export function QuestionCard({
/>
</svg>
)}
<span className="text-[10px] leading-none font-semibold text-[#1F1F1F]">
<span className="group-10 leading-none font-semibold text-[#1F1F1F]">
{hasProgress ? `${normalizedProgress}%` : "..."}
</span>
</div>
@ -159,7 +159,7 @@ export function QuestionCard({
</div>
{item.note ? (
<p className="mt-2 rounded-full bg-[#FFF1F4] px-3 py-1.5 text-center text-[9px] leading-none font-medium text-[#F35A74]">
<p className="mt-2 rounded-full bg-[#FFF1F4] px-3 py-1.5 text-center group-10 leading-none font-medium text-[#F35A74]">
{item.note}
</p>
) : null}

2
src/components/questions/question-checkbox.tsx

@ -70,7 +70,7 @@ export function QuestionCheckbox({
htmlFor={optionId}
className={[
"w-full cursor-pointer rounded-[12px] px-4 py-3.5",
"text-start text-[13px] leading-tight font-semibold transition-all duration-200",
"text-start group-14 leading-tight font-semibold transition-all duration-200",
isSelected
? "bg-[#F0445B] text-white shadow-[0_8px_20px_rgba(240,68,91,0.25)]"
: "bg-[#F5F5F5] text-[#181818] hover:bg-[#EBEBEB]",

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

@ -102,7 +102,7 @@ export function QuestionDate({
onChange={(e) => handleSelectChange(year, month, e.target.value)}
disabled={disabled}
aria-label="روز"
className="h-[54px] w-full cursor-pointer rounded-[15px] border border-[#E7D8D5] bg-white px-3 text-[14px] 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]"
>
<option value="">روز</option>
{DAYS.map((d) => (
@ -118,7 +118,7 @@ export function QuestionDate({
onChange={(e) => handleSelectChange(year, e.target.value, day)}
disabled={disabled}
aria-label="ماه"
className="h-[54px] w-full cursor-pointer rounded-[15px] border border-[#E7D8D5] bg-white px-3 text-[14px] 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]"
>
<option value="">ماه</option>
{MONTHS.map((m) => (
@ -134,7 +134,7 @@ export function QuestionDate({
onChange={(e) => handleSelectChange(e.target.value, month, day)}
disabled={disabled}
aria-label="سال"
className="h-[54px] w-full cursor-pointer rounded-[15px] border border-[#E7D8D5] bg-white px-3 text-[14px] 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]"
>
<option value="">سال</option>
{YEARS.map((y) => (
@ -147,7 +147,7 @@ export function QuestionDate({
{/* Display Calculated Age */}
<div className="mt-2.5 flex w-full flex-col gap-2">
<span className="block text-[12px] leading-tight font-semibold text-[#181818]">
<span className="block group-14 leading-tight font-semibold text-[#181818]">
Age
</span>
<input
@ -155,7 +155,7 @@ export function QuestionDate({
disabled
readOnly
value={calculatedAge !== null ? calculatedAge : ""}
className="h-[54px] w-full rounded-[15px] border border-[#E7D8D5] bg-[#F5F2F1] px-4 text-[14px] font-medium text-[#7C7472] outline-none cursor-not-allowed disabled:bg-[#F5F2F1] disabled:text-[#7C7472]"
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]"
/>
</div>
</div>

10
src/components/questions/question-dropdown.tsx

@ -94,7 +94,7 @@ export function QuestionDropdown({
type="button"
disabled={disabled}
onClick={() => setIsOpen(!isOpen)}
className="flex h-[55px] w-full items-center justify-between rounded-[11px] border border-[#BCBCBC] bg-white py-[10px] px-[10px] text-[14px] text-[#181818] outline-none transition-all focus:border-[#6F6F6F]"
className="flex h-[55px] w-full items-center justify-between rounded-[11px] border border-[#BCBCBC] bg-white py-[10px] px-[10px] group-12 text-[#181818] outline-none transition-all focus:border-[#6F6F6F]"
>
<span className={selectValue ? "text-[#181818]" : "text-[#8B8B8B]"}>
{selectValue || question.extras.placeHolder || "Select"}
@ -148,13 +148,13 @@ export function QuestionDropdown({
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search..."
className="flex-1 bg-transparent text-[13px] text-[#181818] outline-none placeholder:text-[#9D8F8C]"
className="flex-1 bg-transparent group-12 text-[#181818] outline-none placeholder:text-[#9D8F8C]"
/>
{searchQuery ? (
<button
type="button"
onClick={() => setSearchQuery("")}
className="text-[12px] text-[#747474] hover:text-[#181818]"
className="group-10 text-[#747474] hover:text-[#181818]"
>
</button>
@ -192,14 +192,14 @@ export function QuestionDropdown({
: "border-[#4D4D4D] bg-transparent",
].join(" ")}
/>
<span className="text-[14px] font-bold text-[#4D4D4D]">
<span className="group-12 font-bold text-[#4D4D4D]">
{option}
</span>
</button>
);
})
) : (
<span className="text-[12px] text-gray-400">No options found</span>
<span className="group-10 text-gray-400">No options found</span>
)}
</div>
</div>

197
src/components/questions/question-file.tsx

@ -4,6 +4,7 @@ import Image from "next/image";
import { useCallback, useEffect, useState } from "react";
import type { QuestionField } from "@/data/question-data";
import { useUploadTmpMediaMutation } from "@/hooks/marriage/use-upload-tmp-media";
import { getApiRequestUrl } from "@/lib/http";
import { isInFlutterWebView, uploadFile } from "@/lib/webview-actions";
import { useQuestionAnswers } from "./question-answer-storage";
import QuestionTitle from "./question-title";
@ -37,23 +38,60 @@ function resolveMediaType(
return "file";
}
function isImageFile(fileName?: string | null, fileUrl?: string | null): boolean {
if (fileUrl && (fileUrl.startsWith("data:image/") || fileUrl.startsWith("blob:"))) {
if (fileUrl.startsWith("data:application/pdf")) return false;
if (fileName && /\.(pdf|doc|docx|xls|xlsx|txt|zip|rar)$/i.test(fileName)) {
return false;
}
return true;
}
const nameToCheck = fileName || fileUrl || "";
return /\.(jpg|jpeg|png|webp|gif|svg|bmp|avif)$/i.test(nameToCheck);
}
export function QuestionFile({
question,
questionIndex,
disabled,
}: QuestionFileProps) {
const [selectedFileName, setSelectedFileName] = useState<string | null>(null);
const { getAnswerValue, setAnswerValue } = useQuestionAnswers();
const storedValue = getAnswerValue(question, questionIndex);
const initialFileName =
typeof storedValue === "string" && storedValue.trim().length > 0
? storedValue.split("/").pop() ?? storedValue
: null;
const initialFileUrl =
typeof storedValue === "string" && storedValue.trim().length > 0
? storedValue.startsWith("http") ||
storedValue.startsWith("blob:") ||
storedValue.startsWith("data:")
? storedValue
: getApiRequestUrl(storedValue)
: null;
const [selectedFileName, setSelectedFileName] = useState<string | null>(
initialFileName,
);
const [filePreviewUrl, setFilePreviewUrl] = useState<string | null>(
initialFileUrl,
);
const [isFlutterPicking, setIsFlutterPicking] = useState(false);
const acceptedFiles = question.extras.options
const acceptedFiles = (question.extras?.options ?? [])
.map((option) => option.replace(/^\./, ""))
.join(", ");
const { setAnswerValue } = useQuestionAnswers();
const uploadTmpMediaMutation = useUploadTmpMediaMutation({
onSuccess: (response) => {
if (response.path) {
setAnswerValue(question, questionIndex, response.path);
}
},
onError: () => {
setAnswerValue(question, questionIndex, null);
onError: (error) => {
console.error("File upload error:", error);
},
});
@ -72,28 +110,32 @@ export function QuestionFile({
break;
case "picked":
if (event.data?.files?.[0]) {
setSelectedFileName(event.data.files[0].name ?? null);
const fileName = event.data.files[0].name ?? null;
setSelectedFileName(fileName);
if (fileName) {
setAnswerValue(question, questionIndex, fileName);
}
}
break;
case "progress":
// Could show progress bar – for now just keep pending state
break;
case "completed": {
setIsFlutterPicking(false);
const file = event.data?.files?.[0];
if (file?.url) {
// Flutter uploaded the file – use the returned URL
setAnswerValue(question, questionIndex, file.url);
setSelectedFileName(file.name ?? "uploaded");
setFilePreviewUrl(file.url);
} else if (file?.base64) {
// base64 mode – convert to File and use existing mutation
fetch(file.base64)
const b64 = file.base64;
setSelectedFileName(file.name ?? "upload");
setFilePreviewUrl(b64);
fetch(b64)
.then((res) => res.blob())
.then((blob) => {
const f = new File([blob], file.name ?? "upload", {
type: blob.type,
});
setSelectedFileName(f.name);
uploadTmpMediaMutation.mutate(f);
});
}
@ -112,12 +154,11 @@ export function QuestionFile({
return () => {
unsubscribe?.();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [question, questionIndex]);
}, [question, questionIndex, setAnswerValue, uploadTmpMediaMutation]);
/** Handle file pick in Flutter WebView via upload_file action. */
const handleFlutterPick = useCallback(() => {
const extensions = question.extras.options.map((o) =>
const extensions = (question.extras?.options ?? []).map((o) =>
o.replace(/^\./, "").toLowerCase(),
);
const mediaType = resolveMediaType(extensions);
@ -140,18 +181,52 @@ export function QuestionFile({
if (!file) {
setSelectedFileName(null);
setFilePreviewUrl(null);
setAnswerValue(question, questionIndex, null);
return;
}
setSelectedFileName(file.name);
setAnswerValue(question, questionIndex, file.name);
if (file.type.startsWith("image/")) {
const objectUrl = URL.createObjectURL(file);
setFilePreviewUrl(objectUrl);
} else {
setFilePreviewUrl(null);
}
uploadTmpMediaMutation.mutate(file);
}
const handleRemoveFile = (e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
setSelectedFileName(null);
setFilePreviewUrl(null);
setAnswerValue(question, questionIndex, null);
};
const inWebView = isInFlutterWebView();
const isUploaded = Boolean(selectedFileName || storedValue);
const currentFileName =
selectedFileName ??
(typeof storedValue === "string" ? storedValue.split("/").pop() : null);
const currentFileUrl =
filePreviewUrl ??
(typeof storedValue === "string" && storedValue.trim().length > 0
? storedValue.startsWith("http") ||
storedValue.startsWith("blob:") ||
storedValue.startsWith("data:")
? storedValue
: getApiRequestUrl(storedValue)
: null);
const isImg = isImageFile(currentFileName, currentFileUrl);
return (
<div
data-question-answered={isUploaded ? "true" : "false"}
className={[
"flex w-full flex-col gap-2 transition-opacity duration-200",
disabled ? "pointer-events-none opacity-30" : "",
@ -159,48 +234,120 @@ export function QuestionFile({
>
<QuestionTitle question={question} />
<span
className="relative flex aspect-[727/330] min-h-[156px] w-full cursor-pointer flex-col items-center justify-center rounded-[29px] border-2 border-dashed border-[#8D8D8D] bg-[#F7F7F7] text-center transition-colors duration-200 focus-within:outline-2 focus-within:outline-offset-4 focus-within:outline-[#6F6F6F] hover:border-[#777777]"
className="relative flex aspect-[727/330] min-h-[156px] w-full cursor-pointer flex-col items-center justify-center rounded-[29px] border-2 border-dashed border-[#8D8D8D] bg-[#F7F7F7] text-center transition-colors duration-200 focus-within:outline-2 focus-within:outline-offset-4 focus-within:outline-[#6F6F6F] hover:border-[#777777] overflow-hidden p-4"
role={inWebView ? "button" : undefined}
tabIndex={inWebView ? 0 : undefined}
onClick={inWebView ? handleFlutterPick : undefined}
onClick={inWebView && !isUploaded ? handleFlutterPick : undefined}
onKeyDown={
inWebView
inWebView && !isUploaded
? (e) => {
if (e.key === "Enter" || e.key === " ") handleFlutterPick();
}
: undefined
}
>
{/* Fallback: browser file input (hidden in WebView) */}
{!inWebView && (
{/* Fallback: browser file input (hidden in WebView or when uploaded) */}
{!inWebView && !isUploaded && (
<input
type="file"
accept={question.extras.options.join(",")}
disabled={disabled || isPending}
accept={acceptedFiles ? acceptedFiles : "*/*"}
disabled={disabled}
onChange={(event) => handleBrowserFileChange(event.target.files)}
className="absolute inset-0 z-10 h-full w-full cursor-pointer opacity-0"
/>
)}
{isUploaded ? (
/* ────── UPLOADED STATE ────── */
<div className="relative flex h-full w-full flex-col items-center justify-center">
{isImg && currentFileUrl ? (
/* Image Preview (Left design in screenshot) */
<img
src={currentFileUrl}
alt={currentFileName ?? "Uploaded image"}
className="max-h-[120px] max-w-[85%] rounded-[12px] object-contain shadow-xs"
/>
) : (
/* Document / PDF Preview (Right design in screenshot) */
<div className="flex flex-col items-center justify-center gap-2">
<div className="relative flex h-14 w-11 items-center justify-center rounded-[6px] border border-[#D1D5DB] bg-white shadow-xs">
<svg
width="28"
height="32"
viewBox="0 0 32 36"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4 0C1.79086 0 0 1.79086 0 4V32C0 34.2091 1.79086 36 4 36H28C30.2091 36 32 34.2091 32 32V10L22 0H4Z"
fill="#E5E7EB"
/>
<path d="M22 0V10H32L22 0Z" fill="#9CA3AF" />
</svg>
<span className="absolute bottom-1 rounded-[3px] bg-[#F0445B] px-1 py-[1px] text-[8px] font-bold text-white leading-none">
PDF
</span>
</div>
<span className="max-w-[240px] truncate text-xs font-semibold text-[#111111]">
{currentFileName ?? "document"}
</span>
</div>
)}
{/* Trash Button in Bottom-Right */}
<button
type="button"
onClick={handleRemoveFile}
title="Remove file"
className="absolute bottom-0 right-0 z-20 flex h-8 w-8 items-center justify-center rounded-full bg-[#EAEAEA] text-[#36363C] transition-colors hover:bg-[#DDD] active:scale-95 cursor-pointer shadow-xs"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 6h18" />
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
</svg>
</button>
{isPending && (
<div className="absolute inset-0 z-30 flex items-center justify-center rounded-[29px] bg-black/40 backdrop-blur-[1px]">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-white border-t-transparent" />
</div>
)}
</div>
) : (
/* ────── DEFAULT EMPTY STATE ────── */
<>
<Image
src="/assets/images/Image.svg"
alt="Upload"
width={24}
height={24}
/>
<span className="mt-3 block text-sm leading-none font-normal text-[#111111]">
<span className="mt-3 block group-12 leading-none font-normal text-[#111111]">
{isPending
? "uploading..."
: (selectedFileName ?? "upload certificates")}
</span>
{uploadTmpMediaMutation.isError ? (
<span className="mt-2 block text-[10px] leading-none font-bold text-[#D44747]">
<span className="mt-2 block group-10 leading-none font-bold text-[#D44747]">
Upload failed. Please try again.
</span>
) : acceptedFiles ? (
<span className="mt-2 block text-[10px] leading-none font-bold text-[#8B8B8B]">
<span className="mt-2 block group-10 leading-none font-bold text-[#8B8B8B]">
{acceptedFiles}
</span>
) : null}
</>
)}
</span>
</div>
);
@ -208,3 +355,5 @@ export function QuestionFile({
export default QuestionFile;

6
src/components/questions/question-phone.tsx

@ -273,7 +273,7 @@ export function QuestionPhone({
setCodeValue(nextCodeValue);
updateStoredValue(nextCodeValue, phoneValue);
}}
className="w-[33px] border-0 bg-transparent p-0 text-left text-[14px] leading-none text-[#181818] tabular-nums outline-none placeholder:text-[#9D8F8C]"
className="w-[33px] border-0 bg-transparent p-0 text-left group-12 leading-none text-[#181818] tabular-nums outline-none placeholder:text-[#9D8F8C]"
/>
<span aria-hidden="true" className="h-5 w-px bg-[#181818]/35" />
</div>
@ -291,12 +291,12 @@ export function QuestionPhone({
updateStoredValue(codeValue, nextPhoneValue);
}}
dir="ltr"
className="h-full w-full border-0 bg-transparent p-0 text-left text-[14px] leading-none text-[#181818] tabular-nums outline-none placeholder:text-[#9D8F8C]"
className="h-full w-full border-0 bg-transparent p-0 text-left group-12 leading-none text-[#181818] tabular-nums outline-none placeholder:text-[#9D8F8C]"
/>
</span>
</div>
{showInvalidState ? (
<span className="block text-[10px] font-semibold text-[#F2465F]">
<span className="block group-10 font-semibold text-[#F2465F]">
Enter a valid phone number with country code.
</span>
) : null}

197
src/components/questions/question-photo.tsx

@ -1,8 +1,11 @@
"use client";
import Image from "next/image";
import type { ReactNode } from "react";
import { useCallback, useEffect, useId, useState, type ReactNode } from "react";
import type { QuestionField } from "@/data/question-data";
import { useUploadTmpMediaMutation } from "@/hooks/marriage/use-upload-tmp-media";
import { getApiRequestUrl } from "@/lib/http";
import { isInFlutterWebView, uploadFile } from "@/lib/webview-actions";
import { useQuestionAnswers } from "./question-answer-storage";
type QuestionPhotoProps = {
@ -12,42 +15,175 @@ type QuestionPhotoProps = {
disabled?: boolean;
};
function getFileInputValue(files: FileList | null) {
const fileNames = Array.from(files ?? []).map((file) => file.name);
return fileNames.length > 0 ? fileNames.join(", ") : null;
}
export function QuestionPhoto({
question,
questionIndex,
description,
disabled,
}: QuestionPhotoProps) {
const acceptedFiles = question.extras.options.join(",");
const inputId = useId();
const [localPreviewUrl, setLocalPreviewUrl] = useState<string | null>(null);
const [isFlutterPicking, setIsFlutterPicking] = useState(false);
const acceptedFiles =
question.extras?.options && question.extras.options.length > 0
? question.extras.options.join(",")
: "image/*";
const descriptionContent = description ?? question.description;
const { setAnswerValue } = useQuestionAnswers();
const { getAnswerValue, setAnswerValue } = useQuestionAnswers();
const storedValue = getAnswerValue(question, questionIndex);
const uploadTmpMediaMutation = useUploadTmpMediaMutation({
onSuccess: (response) => {
if (response.path) {
setAnswerValue(question, questionIndex, response.path);
}
},
onError: (error) => {
console.error("Photo upload error:", error);
},
});
const isPending = uploadTmpMediaMutation.isPending || isFlutterPicking;
// Listen for upload_file responses from Flutter WebView if active
useEffect(() => {
if (!isInFlutterWebView()) return;
const unsubscribe = window.addFlutterResponseListener?.((event) => {
if (event.action !== "upload_file") return;
switch (event.status) {
case "picking":
setIsFlutterPicking(true);
break;
case "picked":
if (event.data?.files?.[0]?.base64) {
const b64 = event.data.files[0].base64;
setLocalPreviewUrl(b64);
setAnswerValue(question, questionIndex, b64);
}
break;
case "completed": {
setIsFlutterPicking(false);
const file = event.data?.files?.[0];
if (file?.url) {
setAnswerValue(question, questionIndex, file.url);
setLocalPreviewUrl(file.url);
} else if (file?.base64) {
const b64 = file.base64;
setLocalPreviewUrl(b64);
setAnswerValue(question, questionIndex, b64);
fetch(b64)
.then((res) => res.blob())
.then((blob) => {
const f = new File([blob], file.name ?? "photo.jpg", {
type: blob.type || "image/jpeg",
});
uploadTmpMediaMutation.mutate(f);
});
}
break;
}
case "cancelled":
case "failed":
setIsFlutterPicking(false);
break;
}
});
return () => {
unsubscribe?.();
};
}, [question, questionIndex, setAnswerValue, uploadTmpMediaMutation]);
const handleFlutterPick = useCallback(() => {
const extensions = (question.extras?.options ?? []).map((o) =>
o.replace(/^\./, "").toLowerCase(),
);
uploadFile({
mediaType: "image",
source: "gallery",
returnAs: "upload",
uploadUrl: `${process.env.NEXT_PUBLIC_API_BASE_URL ?? ""}/upload-tmp-media/`,
fieldName: "file",
allowedExtensions: extensions.length > 0 ? extensions : ["jpg", "jpeg", "png", "webp"],
maxBytes: 10_485_760, // 10 MB
title: typeof question.title === "string" ? question.title : "Upload Photo",
});
}, [question]);
const handleBrowserFileChange = (files: FileList | null) => {
const file = files?.[0];
if (!file) return;
// Create synchronous object URL for instant preview & immediate state update
const objectUrl = URL.createObjectURL(file);
setLocalPreviewUrl(objectUrl);
setAnswerValue(question, questionIndex, objectUrl);
// Trigger background upload
uploadTmpMediaMutation.mutate(file);
};
const handleLabelClick = (e: React.MouseEvent) => {
if (isInFlutterWebView()) {
e.preventDefault();
handleFlutterPick();
}
};
// Determine photo URL to display
let displayUrl: string | null = localPreviewUrl;
if (!displayUrl && typeof storedValue === "string" && storedValue.trim().length > 0) {
const val = storedValue.trim();
if (
val.startsWith("http://") ||
val.startsWith("https://") ||
val.startsWith("blob:") ||
val.startsWith("data:")
) {
displayUrl = val;
} else {
displayUrl = getApiRequestUrl(val);
}
}
const hasAnswer = Boolean(displayUrl);
return (
<div
data-question-answered={hasAnswer ? "true" : "false"}
className={[
"flex w-full flex-col items-center text-center transition-opacity duration-200",
disabled ? "pointer-events-none opacity-30" : "",
].join(" ")}
>
<input
id={inputId}
type="file"
accept={acceptedFiles}
onChange={(event) => setAnswerValue(question, questionIndex, getFileInputValue(event.target.files))}
onChange={(event) => handleBrowserFileChange(event.target.files)}
disabled={disabled}
className="sr-only"
id={`photo-input-${questionIndex}`}
/>
<label
htmlFor={`photo-input-${questionIndex}`}
htmlFor={inputId}
onClick={handleLabelClick}
className="flex w-full cursor-pointer flex-col items-center"
>
<span className="flex w-full flex-col items-center">
<div className="relative flex h-[92px] w-[86px] items-center justify-center">
{displayUrl ? (
<img
src={displayUrl}
alt={typeof question.title === "string" ? question.title : ""}
className="h-[86px] w-[86px] rounded-full object-cover border border-[#D7DBE2]"
/>
) : (
<Image
src="/assets/images/Frame 2095586679.svg"
alt=""
@ -56,14 +192,44 @@ export function QuestionPhoto({
height={92}
className="h-[92px] w-[86px]"
/>
<span className="mt-4 block text-xs leading-none font-semibold text-[#36363C]">
)}
{/* Red camera badge when image is uploaded */}
{displayUrl && (
<span className="absolute bottom-[2px] right-0 flex h-7 w-7 items-center justify-center rounded-full bg-[#F0445B]">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-white"
>
<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z" />
<circle cx="12" cy="13" r="3" />
</svg>
</span>
)}
{/* Loading spinner during upload */}
{isPending && (
<div className="absolute inset-0 flex items-center justify-center rounded-full bg-black/40 backdrop-blur-[1px]">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-white border-t-transparent" />
</div>
)}
</div>
<span className="mt-4 block group-14 leading-none font-semibold text-[#36363C]">
{question.title}
{question.required ? (
<span className="ml-1 text-[#F33D52]">*</span>
) : null}
</span>
{descriptionContent ? (
<span className="mt-4 block max-w-[350px] text-xs leading-[1.35] font-semibold text-[#D44747]">
<span className="mt-4 block max-w-[350px] group-10 leading-[1.35] font-semibold text-[#D44747]">
{descriptionContent}
</span>
) : null}
@ -74,3 +240,6 @@ export function QuestionPhoto({
}
export default QuestionPhoto;

4
src/components/questions/question-progress-tracker.tsx

@ -93,7 +93,7 @@ export function QuestionProgressTracker({
>(() => new Set());
const safeTotal = Math.max(total, 0);
const progress = safeTotal > 0 ? (answered / safeTotal) * 100 : 0;
const isCompleted = safeTotal > 0 && answered >= safeTotal;
const isCompleted = safeTotal === 0 || answered >= safeTotal;
const markQuestionPassed = useCallback((questionIndex: number) => {
setPassedQuestionIndexes((currentIndexes) => {
@ -180,7 +180,7 @@ export function QuestionProgressTracker({
role="progressbar"
className="w-full bg-[#F7F1F0]"
>
<div className="mb-[7px] flex items-center justify-between text-xs leading-none font-normal text-[#747474]">
<div className="mb-[7px] flex items-center justify-between group-10 leading-none font-normal text-[#747474]">
<span>fields to complete</span>
<span>
{answered} /{safeTotal}

2
src/components/questions/question-radio.tsx

@ -45,7 +45,7 @@ export function QuestionRadio({
htmlFor={optionId}
className={[
"w-full cursor-pointer rounded-[12px] px-4 py-3.5",
"text-start text-[13px] leading-tight font-semibold transition-all duration-200",
"text-start group-14 leading-tight font-semibold transition-all duration-200",
isSelected
? "bg-[#F0445B] text-white shadow-[0_8px_20px_rgba(240,68,91,0.25)]"
: "bg-[#F5F5F5] text-[#181818] hover:bg-[#EBEBEB]",

4
src/components/questions/question-slider.tsx

@ -77,7 +77,7 @@ export function QuestionSlider({
<div className="pt-9">
<div ref={sliderWrapperRef} className="relative">
<div
className="-translate-x-1/2 absolute -top-6.5 z-10 flex items-center justify-center rounded-[7px] bg-[#F43F5E] px-2.5 font-semibold text-white shadow-sm after:absolute after:-bottom-[4px] after:left-1/2 after:h-[9px] after:w-2.5 after:-translate-x-1/2 after:rotate-45 after:rounded-[2px] after:bg-[#F43F5E] after:content-['']"
className="-translate-x-1/2 absolute -top-6.5 z-10 flex items-center justify-center rounded-[7px] bg-[#F43F5E] px-2.5 group-12 font-semibold text-white shadow-sm after:absolute after:-bottom-[4px] after:left-1/2 after:h-[9px] after:w-2.5 after:-translate-x-1/2 after:rotate-45 after:rounded-[2px] after:bg-[#F43F5E] after:content-['']"
style={{ left: `${bubblePosition}px` }}
>
{value}
@ -97,7 +97,7 @@ export function QuestionSlider({
}}
/>
</div>
<div className="mt-2 grid grid-flow-col auto-cols-fr text-center text-[8.5px] text-[#B7B7B7]">
<div className="mt-2 grid grid-flow-col auto-cols-fr text-center group-10 text-[#B7B7B7]">
{steps.map((step) => (
<span key={step}>{step}</span>
))}

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

@ -242,9 +242,9 @@ export function QuestionSnapList({
let wrapperStyles = "w-full";
if (isActive) {
containerStyles = "top-1/2 left-0 -translate-y-1/2 z-10 opacity-100 scale-100 pointer-events-auto";
containerStyles = "top-[35%] left-0 -translate-y-1/2 z-10 opacity-100 scale-100 pointer-events-auto";
} else if (isPrev) {
containerStyles = "top-4 left-0 z-0 opacity-20 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";
} else if (isNext) {
containerStyles = "bottom-4 left-0 z-0 opacity-20 pointer-events-none scale-[0.98]";

10
src/components/questions/question-text.tsx

@ -59,18 +59,18 @@ export default function QuestionText({
onChange={(e) => setAnswerValue(question, questionIndex, e.target.value)}
placeholder={question.extras.placeHolder}
disabled={disabled}
className="h-full w-full border-0 bg-transparent px-[18px] text-[14px] leading-none text-[#181818] outline-none placeholder:text-[#9D8F8C]"
className="h-full w-full border-0 bg-transparent px-[18px] group-12 leading-none text-[#181818] outline-none placeholder:text-[#9D8F8C]"
/>
</div>
{showInvalidState ? (
<span className="block text-[10px] font-semibold text-[#F2465F]">
<span className="block group-10 font-semibold text-[#F2465F]">
{/[\u0600-\u06FF]/.test(question.title)
? "یک آدرس ایمیل معتبر وارد کنید."
: "Enter a valid email address."}
</span>
) : null}
{description ? (
<span className="block text-[10px] font-semibold text-[#747474]">
<span className="block group-10 font-semibold text-[#747474]">
{description}
</span>
) : null}
@ -94,10 +94,10 @@ export default function QuestionText({
onChange={(e) => setAnswerValue(question, questionIndex, e.target.value)}
placeholder={question.extras.placeHolder}
disabled={disabled}
className="h-[54px] w-full rounded-[15px] border border-[#E7D8D5] bg-white px-4 text-[14px] font-medium text-[#181818] outline-none transition-all placeholder:text-[#9D8F8C] focus:border-[#6F6F6F] disabled:bg-[#F5F2F1] disabled:text-[#7C7472]"
className="h-[54px] w-full rounded-[15px] border border-[#E7D8D5] bg-white px-4 group-12 font-medium text-[#181818] outline-none transition-all placeholder:text-[#9D8F8C] focus:border-[#6F6F6F] disabled:bg-[#F5F2F1] disabled:text-[#7C7472]"
/>
{description ? (
<span className="block text-[10px] font-semibold text-[#747474]">
<span className="block group-10 font-semibold text-[#747474]">
{description}
</span>
) : null}

8
src/components/questions/question-title.tsx

@ -10,7 +10,7 @@ export function QuestionTitle({ question, className }: QuestionTitleProps) {
return (
<span className="block">
{question.private ? (
<span className="mb-3 mx-auto flex w-fit items-center gap-1.5 rounded-[10px] bg-[#F6D7D8] px-2.5 py-1.5 text-[10px] leading-tight font-semibold text-[#DF4146]">
<span className="mb-3 mx-auto flex w-fit items-center gap-1.5 rounded-[10px] bg-[#F6D7D8] px-2.5 py-1.5 group-10 leading-tight font-semibold text-[#DF4146]">
<IoEyeOff
aria-hidden="true"
className="shrink-0 text-[18px] text-[#DF4146]"
@ -21,7 +21,7 @@ export function QuestionTitle({ question, className }: QuestionTitleProps) {
<span
className={[
"block text-[12px] leading-tight font-semibold text-[#181818]",
"block group-14 leading-tight font-semibold text-[#181818]",
className,
]
.filter(Boolean)
@ -29,12 +29,12 @@ export function QuestionTitle({ question, className }: QuestionTitleProps) {
>
{question.title}
{question.description ? (
<span className="ml-1 text-[10px] font-semibold text-[#747474]">
<span className="ml-1 group-10 font-semibold text-[#747474]">
({question.description})
</span>
) : null}
{question.required ? (
<span className="ml-1 text-[14px] text-[#F33D52]">*</span>
<span className="ml-1 group-14 text-[#F33D52]">*</span>
) : null}
</span>
</span>

6
src/components/questions/required-steps-card.tsx

@ -104,12 +104,12 @@ export default function RequiredStepsCard() {
<IoAlert aria-hidden="true" className="text-[16px]" />
)}
</span>
<h2 className="leading-none font-bold tracking-[-0.02em]">
<h2 className="group-16 leading-none font-bold tracking-[-0.02em]">
{t.questions.requiredSteps}
</h2>
</div>
<p className="mt-2.5 max-w-[220px] text-[10px] leading-[1.18] font-semibold text-white/70">
<p className="mt-2.5 max-w-[220px] group-12 leading-[1.25] font-medium text-white/80">
{isCompleted
? t.questions.requiredStepsDescriptionCompleted
: t.questions.requiredStepsDescription}
@ -127,7 +127,7 @@ export default function RequiredStepsCard() {
}}
>
<div className="absolute inset-[6px] rounded-full bg-[#40506A]" />
<span className="relative leading-none font-bold tracking-[-0.02em]">
<span className="relative group-14 leading-none font-bold tracking-[-0.02em]">
{completed}/{total}
</span>
</div>

10
src/components/sliders/slider-slide-four.tsx

@ -27,13 +27,13 @@ export function SliderSlideFour({
>
<div className="min-h-0 flex-1 overflow-y-auto pb-28">
<div className="text-center">
<p className="text-[#747474] text-xs font-semibold">Submit Process</p>
<p className="text-[#111111] font-bold">
{index + 1}/4 terms & conditions
<p className="text-[#747474] group-10 font-semibold">Submit Process</p>
<p className="text-[#111111] group-16 font-bold">
{index + 1}/4 Registration Type
</p>
</div>
<div className="bg-[#F14B46]/10 py-2.5 px-3.5 border border-[#F14B46] rounded-xl mt-8">
<p className="text-[#F14B46] text-xs font-semibold text-center">
<p className="text-[#F14B46] group-12 font-semibold text-center">
Read the terms and conditions carefully so that you don't run into
any problems in the next process - reading this section is
mandatory.
@ -82,7 +82,7 @@ export function SliderSlideFour({
/>
</svg>
</span>
<span className="text-[15px] font-semibold sm:text-[16px]">
<span className="group-14 font-semibold">
{option.label}
</span>
</button>

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

@ -19,13 +19,13 @@ export function SliderSlideOne({ index }: SliderSlideProps) {
}}
>
<div className="text-center">
<p className="text-[#747474] text-xs font-semibold">Submit Process</p>
<p className="text-[#111111] font-bold">
<p className="text-[#747474] group-10 font-semibold">Submit Process</p>
<p className="text-[#111111] group-16 font-bold">
{index + 1}/4 terms & conditions
</p>
</div>
<div className="bg-[#F14B46]/10 py-2.5 px-3.5 border border-[#F14B46] rounded-xl mt-8">
<p className="text-[#F14B46] text-xs font-semibold text-center">
<p className="text-[#F14B46] group-12 font-semibold text-center">
Dear user, while welcoming you to the Habib Marij application,
please read the following rules and regulations carefully before
using the services of this platform. Your registration and use of
@ -35,11 +35,11 @@ export function SliderSlideOne({ index }: SliderSlideProps) {
<div className="bg-white rounded-2xl mt-4 overflow-y-auto shadow-xs">
<div className="space-y-6 px-5 py-5">
<div className="space-y-3.5">
<h3 className="text-[#F14B46] text-[16px] font-bold tracking-tight mb-3">
<h3 className="text-[#F14B46] group-14 font-bold tracking-tight mb-3">
1. Eligibility and Membership Requirements
</h3>
<p className="text-[13px] leading-[1.55]">
<p className="group-12 leading-[1.55]">
<span className="font-bold text-[#262626]">Legal Age:</span>{" "}
<span className="font-normal text-[#525252]">
Users must meet the minimum legal age for independent
@ -47,7 +47,7 @@ export function SliderSlideOne({ index }: SliderSlideProps) {
</span>
</p>
<p className="text-[13px] leading-[1.55]">
<p className="group-12 leading-[1.55]">
<span className="font-bold text-[#262626]">Identity Verification:</span>{" "}
<span className="font-normal text-[#525252]">
Mandatory submission of valid government-issued ID upon
@ -55,7 +55,7 @@ export function SliderSlideOne({ index }: SliderSlideProps) {
</span>
</p>
<p className="text-[13px] leading-[1.55]">
<p className="group-12 leading-[1.55]">
<span className="font-bold text-[#262626]">Single Status Commitment:</span>{" "}
<span className="font-normal text-[#525252]">
Users must provide proof of being single, or documents
@ -63,7 +63,7 @@ export function SliderSlideOne({ index }: SliderSlideProps) {
</span>
</p>
<p className="text-[13px] leading-[1.55]">
<p className="group-12 leading-[1.55]">
<span className="font-bold text-[#262626]">Intent:</span>{" "}
<span className="font-normal text-[#525252]">
Commitment to monogamy and intention for permanent marriage
@ -71,7 +71,7 @@ export function SliderSlideOne({ index }: SliderSlideProps) {
</span>
</p>
<p className="text-[13px] leading-[1.55]">
<p className="group-12 leading-[1.55]">
<span className="font-bold text-[#262626]">General Health:</span>{" "}
<span className="font-normal text-[#525252]">
Self-declaration regarding mental health, absence of
@ -81,11 +81,11 @@ export function SliderSlideOne({ index }: SliderSlideProps) {
</div>
<div className="space-y-3.5 pt-1">
<h3 className="text-[#F14B46] text-[16px] font-bold tracking-tight mb-3">
<h3 className="text-[#F14B46] group-14 font-bold tracking-tight mb-3">
2. Privacy and Data Management
</h3>
<p className="text-[13px] leading-[1.55]">
<p className="group-12 leading-[1.55]">
<span className="font-bold text-[#262626]">Content Security:</span>{" "}
<span className="font-normal text-[#525252]">
Technical prevention of screenshots from profiles and chat
@ -93,7 +93,7 @@ export function SliderSlideOne({ index }: SliderSlideProps) {
</span>
</p>
<p className="text-[13px] leading-[1.55]">
<p className="group-12 leading-[1.55]">
<span className="font-bold text-[#262626]">Progressive Disclosure:</span>{" "}
<span className="font-normal text-[#525252]">
Sensitive information (face, contact details) revealed

8
src/components/sliders/slider-slide-three.tsx

@ -47,9 +47,9 @@ export function SliderSlideThree({
className="flex h-full min-h-0 w-full shrink-0 flex-col justify-between"
>
<div className="text-center">
<p className="text-[#747474] text-xs font-semibold">Submit Process</p>
<p className="text-[#111111] font-bold">
{index + 1}/4 terms & conditions
<p className="text-[#747474] group-10 font-semibold">Submit Process</p>
<p className="text-[#111111] group-16 font-bold">
{index + 1}/4 Select Gender
</p>
</div>
@ -90,7 +90,7 @@ export function SliderSlideThree({
</span>
<p
className={[
"mt-2 text-center text-sm font-semibold transition-colors duration-300 ease-out",
"mt-2 text-center group-14 font-semibold transition-colors duration-300 ease-out",
isSelected ? "text-[#FF6175]" : "text-[#111111]",
].join(" ")}
>

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

@ -8,9 +8,9 @@ export function SliderSlideTwo({ index }: SliderSlideProps) {
className="flex h-full min-h-0 w-full shrink-0 flex-col"
>
<div className="text-center">
<p className="text-[#747474] text-xs font-semibold">Submit Process</p>
<p className="text-[#111111] font-bold">
{index + 1}/4 terms & conditions
<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">
@ -40,11 +40,11 @@ export function SliderSlideTwo({ index }: SliderSlideProps) {
height={20}
className="rounded-full"
/>
<p className="text-[#2F3A48] text-sm font-semibold">
<p className="text-[#2F3A48] group-14 font-semibold">
Dr. Hasti Masoudi
</p>
</div>
<p className="text-xs text-[#4D4D4D] text-center mt-2.5">
<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>

6
src/components/ui/advisor-actions-card.tsx

@ -30,10 +30,10 @@ export function AdvisorActionsCard({
return (
<section className={["space-y-3", className].filter(Boolean).join(" ")}>
<div className="rounded-[13px] border border-white/80 bg-white/72 px-3 py-3.5 text-left shadow-[0_18px_45px_rgba(15,23,42,0.06)] backdrop-blur-sm">
<h2 className="text-[16px] leading-none font-bold text-[#1C1C1C]">
<h2 className="group-16 leading-none font-bold text-[#1C1C1C]">
{title}
</h2>
<p className="mt-2 max-w-[280px] text-[11px] leading-[1.45] font-semibold text-[#8A8A8A]">
<p className="mt-2 max-w-[280px] group-10 leading-[1.45] font-semibold text-[#8A8A8A]">
{description}
</p>
@ -53,7 +53,7 @@ export function AdvisorActionsCard({
/>
</span>
))}
<span className="-ml-1.5 flex h-[30px] w-[30px] items-center justify-center rounded-full border-2 border-white bg-[#EDEEF1] text-[12px] font-semibold text-[#1C1C1C]">
<span className="-ml-1.5 flex h-[30px] w-[30px] items-center justify-center rounded-full border-2 border-white bg-[#EDEEF1] group-10 font-semibold text-[#1C1C1C]">
+{extraCount}
</span>
</div>

6
src/components/ui/button.tsx

@ -152,13 +152,13 @@ export function Button({
<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 text-[16px] font-semibold leading-none">
<span className="text-center group-16 font-semibold leading-none">
{children}
</span>
{description && !isOutlined ? (
<span
id={countdownId}
className="mt-1 text-center text-[10px] font-semibold leading-none text-white"
className="mt-1 text-center group-10 font-semibold leading-none text-white"
>
{description}
</span>
@ -219,7 +219,7 @@ function CountdownProgress({ value, progress }: CountdownProgressProps) {
strokeDashoffset={dashOffset}
/>
</svg>
<span className="absolute inset-0 flex items-center justify-center text-[12px] font-semibold leading-none text-white">
<span className="absolute inset-0 flex items-center justify-center group-12 font-semibold leading-none text-white">
{value}
</span>
</span>

10
src/components/ui/info-progress-card.tsx

@ -41,9 +41,9 @@ export function InfoProgressCard({
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="truncate text-[16px] leading-none font-bold tracking-[-0.03em] text-[#171717]">
<h2 className="truncate group-14 leading-none font-bold tracking-[-0.03em] text-[#171717]">
{title}{" "}
<span className="text-[10px] font-bold text-[#FF6175]">
<span className="group-10 font-bold text-[#FF6175]">
{requiredLabel && "(Required)"}
</span>
</h2>
@ -67,7 +67,7 @@ export function InfoProgressCard({
<div
id={tooltipId}
role="tooltip"
className="pointer-events-none absolute top-full right-0 z-10 mt-2 w-48 rounded-[10px] bg-[#171717] px-3 py-2 text-[11px] leading-4 font-medium text-white opacity-0 shadow-lg transition-opacity duration-150 group-hover:opacity-100 group-focus-within:opacity-100"
className="pointer-events-none absolute top-full right-0 z-10 mt-2 w-48 rounded-[10px] bg-[#171717] px-3 py-2 group-10 leading-4 font-medium text-white opacity-0 shadow-lg transition-opacity duration-150 group-hover:opacity-100 group-focus-within:opacity-100"
>
{tooltip}
</div>
@ -76,7 +76,7 @@ export function InfoProgressCard({
</div>
<div className="mt-2 flex items-center justify-between">
<p className="text-[12px] font-semibold text-[#747474]">
<p className="group-10 font-semibold text-[#747474]">
Estimate time: {estimate}
</p>
<div className="flex items-center gap-1">
@ -117,7 +117,7 @@ export function InfoProgressCard({
className="mt-0.5"
/>
)}
<span className="text-[12px] font-semibold text-[#111111]">
<span className="group-10 font-semibold text-[#111111]">
{normalizedProgress}%
</span>
</div>

4
src/components/ui/information-sheet.tsx

@ -259,14 +259,14 @@ export function InformationSheet({
<h2
className={[
resolvedIcon ? "mt-2.5" : "mt-0",
"w-full leading-[1.2] font-bold tracking-[-0.03em] text-[#171717]",
"w-full group-16 leading-[1.2] font-bold tracking-[-0.03em] text-[#171717]",
].join(" ")}
>
{resolvedTitle}
</h2>
{description ? (
<div className="mt-3 text-sm text-[#4D4D4D]">{description}</div>
<div className="mt-3 group-12 text-[#4D4D4D]">{description}</div>
) : null}
{resolvedButtons ? (

9
src/components/ui/sticky-header.tsx

@ -3,7 +3,9 @@
import type { ReactNode } from "react";
import { useViewPaddings } from "@/hooks/use-view-paddings";
const DESIGN_TOP_PADDING = 63;
// پدینگ بالای طراحی هدر (معادل pt-7 = 28px). روی دستگاه، safe-area + 4px جایگزین
// می‌شود (مثل صفحات بومی najm)، و طراحی اصلی به‌عنوان کف برای مرورگر حفظ می‌شود.
const DESIGN_TOP_PADDING = 28;
const SAFE_AREA_GAP = 4;
type StickyHeaderProps = {
@ -29,10 +31,7 @@ export default function StickyHeader({
}
className={[
sticky ? "sticky z-30" : "relative z-30",
"rounded-b-[32px] bg-[linear-gradient(135deg,#E03950_0%,#FE6F82_100%)] px-[29px] pb-[29px] text-[14px]",
"[&_button]:size-[74px] [&_button]:shrink-0 [&_button]:rounded-[24px] [&_button]:p-0",
"[&_button_img]:size-8 [&_button_svg]:size-8",
"[&_h1]:min-w-0 [&_h1]:truncate [&_h1]:text-[14px] [&_h1]:font-semibold [&_h1]:leading-5",
"rounded-b-[15px] bg-[linear-gradient(135deg,#E03950_0%,#FE6F82_100%)] px-[17px] pb-5",
className,
]
.filter(Boolean)

1
src/hooks/marriage/use-upload-tmp-media.ts

@ -18,7 +18,6 @@ export async function uploadTmpMedia(file: File) {
{
headers: {
Accept: "application/json",
"Content-Type": "multipart/form-data",
"X-CSRFToken": CSRF_TOKEN,
},
},

94
src/i18n/locales/en/questions.json

@ -1406,92 +1406,26 @@
]
},
{
"title": "Katel Test",
"title": "Personality Test",
"icon": "layout-grid",
"slug": "katel_test",
"slug": "personality_test",
"required": false,
"estimateTime": "30 minutes",
"tooltip": "Katel test section.",
"estimateTime": "15 minutes",
"tooltip": "Personality Test",
"progress": 0,
"description": "Captures Katel test responses.",
"questions": [
{
"title": "Katel Test Step 1",
"type": "radio",
"required": false,
"description": "Select the option that applies.",
"tooltip": "This is the first Katel test step.",
"extras": {
"placeHolder": "Select one option",
"range": [
0,
0
],
"options": [
"Option A",
"Option B",
"Option C"
]
}
},
{
"title": "Katel Test Step 2",
"type": "radio",
"required": false,
"description": "Select the option that applies.",
"tooltip": "This is the second Katel test step.",
"extras": {
"placeHolder": "Select one option",
"range": [
0,
0
],
"options": [
"Option A",
"Option B",
"Option C"
]
}
"description": "Personality Test",
"questions": []
},
{
"title": "Katel Test Step 3",
"type": "radio",
"required": false,
"description": "Select the option that applies.",
"tooltip": "This is the third Katel test step.",
"extras": {
"placeHolder": "Select one option",
"range": [
0,
0
],
"options": [
"Option A",
"Option B",
"Option C"
]
}
},
{
"title": "Katel Test Step 4",
"type": "radio",
"title": "Glasser 5 Needs Test",
"icon": "layout-grid",
"slug": "glasser_5_needs_test",
"required": false,
"description": "Select the option that applies.",
"tooltip": "This is the fourth Katel test step.",
"extras": {
"placeHolder": "Select one option",
"range": [
0,
0
],
"options": [
"Option A",
"Option B",
"Option C"
]
}
}
]
"estimateTime": "15 minutes",
"tooltip": "Glasser 5 Needs Test",
"progress": 0,
"description": "Glasser 5 Needs Test",
"questions": []
},
{
"title": "Future Spouse Criteria and Red Lines",

94
src/i18n/locales/fa/questions.json

@ -1406,92 +1406,26 @@
]
},
{
"title": "آزمون کتل",
"title": "تست شخصیت شناسی",
"icon": "layout-grid",
"slug": "katel_test",
"slug": "personality_test",
"required": false,
"estimateTime": "30 minutes",
"tooltip": "Katel test section.",
"estimateTime": "15 دقیقه",
"tooltip": "تست شخصیت شناسی",
"progress": 0,
"description": "Captures Katel test responses.",
"questions": [
{
"title": "مرحله ۱ آزمون کتل",
"type": "radio",
"required": false,
"description": "Select the option that applies.",
"tooltip": "This is the first Katel test step.",
"extras": {
"placeHolder": "Select one option",
"range": [
0,
0
],
"options": [
"Option A",
"Option B",
"Option C"
]
}
},
{
"title": "مرحله ۲ آزمون کتل",
"type": "radio",
"required": false,
"description": "Select the option that applies.",
"tooltip": "This is the second Katel test step.",
"extras": {
"placeHolder": "Select one option",
"range": [
0,
0
],
"options": [
"Option A",
"Option B",
"Option C"
]
}
"description": "تست شخصیت شناسی",
"questions": []
},
{
"title": "مرحله ۳ آزمون کتل",
"type": "radio",
"required": false,
"description": "Select the option that applies.",
"tooltip": "This is the third Katel test step.",
"extras": {
"placeHolder": "Select one option",
"range": [
0,
0
],
"options": [
"Option A",
"Option B",
"Option C"
]
}
},
{
"title": "مرحله ۴ آزمون کتل",
"type": "radio",
"title": "تست ۵ نیاز گلاسر",
"icon": "layout-grid",
"slug": "glasser_5_needs_test",
"required": false,
"description": "Select the option that applies.",
"tooltip": "This is the fourth Katel test step.",
"extras": {
"placeHolder": "Select one option",
"range": [
0,
0
],
"options": [
"Option A",
"Option B",
"Option C"
]
}
}
]
"estimateTime": "15 دقیقه",
"tooltip": "تست ۵ نیاز گلاسر",
"progress": 0,
"description": "تست ۵ نیاز گلاسر",
"questions": []
},
{
"title": "بخش ۸: معیارها و خطوط قرمز همسر آینده",

Loading…
Cancel
Save