You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1183 lines
41 KiB
1183 lines
41 KiB
"use client";
|
|
|
|
import Image from "next/image";
|
|
import Link from "next/link";
|
|
import { useRouter } from "next/navigation";
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import { useHabibWebReady } from "@/hooks/use-habib-web-ready";
|
|
import {
|
|
FaCalendarDays,
|
|
FaLocationDot,
|
|
FaGraduationCap,
|
|
FaBookOpen,
|
|
FaBriefcase,
|
|
FaStar,
|
|
FaLock,
|
|
} from "react-icons/fa6";
|
|
import { LuEye } from "react-icons/lu";
|
|
import { IoHeartOutline } from "react-icons/io5";
|
|
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";
|
|
import PageHeader from "@/components/Componentes/page-header";
|
|
import { PageBackground } from "@/components/Componentes/page-background";
|
|
import InformationSheet from "@/components/Componentes/information-sheet";
|
|
import SwipeButton from "@/components/Componentes/swipe-button";
|
|
import { LoadingSkeleton } from "@/components/Componentes/loading-skeleton";
|
|
import { LoadingThreeDot } from "@/components/Componentes/loading-three-dot";
|
|
import { DiscountWidget } from "@/components/Componentes/discount-widget";
|
|
import type { CheckDiscountResult } from "@/hooks/marriage/use-validate-discount";
|
|
import { useHabcoinPaymentMutation } from "@/hooks/marriage/use-habcoin-payment";
|
|
import { useHabcoinInventoryQuery } from "@/hooks/marriage/use-habcoin-inventory";
|
|
import { useRespondToMarriageCaseMutation } from "@/hooks/marriage/use-case-respond";
|
|
import type {
|
|
MarriageField,
|
|
MarriageFieldValue,
|
|
MarriageMatchSummary,
|
|
MarriagePhoneFieldValue,
|
|
} from "@/hooks/marriage/types";
|
|
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
|
|
import { useViewPaddings } from "@/hooks/use-view-paddings";
|
|
import { getSubmitPath } from "@/lib/get-submit-path";
|
|
import {
|
|
buyHabibCoinPackages,
|
|
isInFlutterWebView,
|
|
} from "@/lib/webview-actions";
|
|
import { localizePath } from "@/translations/config";
|
|
import { useI18n } from "@/translations/provider";
|
|
|
|
const advisorAvatars = [
|
|
{ id: "advisor-primary", src: "/assets/images/Avatar Image.png" },
|
|
{ id: "advisor-secondary", src: "/assets/images/Ellipse 370.png" },
|
|
{ id: "advisor-tertiary", src: "/assets/images/Avatar Image.png" },
|
|
];
|
|
|
|
const fieldCandidateMatchers = {
|
|
name: [
|
|
"name",
|
|
"full_name",
|
|
"fullname",
|
|
"first_name",
|
|
"last_name",
|
|
"display_name",
|
|
"q1_full_name",
|
|
],
|
|
age: ["age", "date_of_birth", "birth_date", "dob", "birth_year"],
|
|
currentCountry: [
|
|
"country_of_current_residence",
|
|
"current_country",
|
|
"residence_country",
|
|
"country",
|
|
"current_residence_country",
|
|
],
|
|
currentCity: [
|
|
"city_state_of_current_residence",
|
|
"city_of_current_residence",
|
|
"current_city",
|
|
"residence_city",
|
|
"city",
|
|
"city_state",
|
|
"state_city",
|
|
"state",
|
|
"province",
|
|
],
|
|
residence: [
|
|
"current_residence",
|
|
"residence",
|
|
"current_country_city",
|
|
"location",
|
|
"current_city",
|
|
"residence_city",
|
|
"city",
|
|
"country",
|
|
"current_country",
|
|
],
|
|
educationLevel: [
|
|
"highest_level_of_education",
|
|
"highest_education_level",
|
|
"education_level",
|
|
"education",
|
|
],
|
|
fieldOfStudy: [
|
|
"field_of_study",
|
|
"study_field",
|
|
"study",
|
|
],
|
|
jobTitle: [
|
|
"job_title",
|
|
"job_title_and_description",
|
|
"job_position",
|
|
"employment_status",
|
|
"job",
|
|
"occupation",
|
|
"profession",
|
|
"career",
|
|
],
|
|
hobbies: [
|
|
"your_hobbies_and_main_interests",
|
|
"hobbies_and_interests",
|
|
"hobbies",
|
|
"interests",
|
|
],
|
|
} as const;
|
|
|
|
type DisplayField = {
|
|
id: string;
|
|
label: string;
|
|
value: string;
|
|
};
|
|
|
|
function normalizeFieldName(value: string) {
|
|
return value
|
|
.toLowerCase()
|
|
.replace(/^q\d+[_-]?/, "")
|
|
.replace(/[^a-z0-9]/g, "");
|
|
}
|
|
|
|
function matchesCandidate(
|
|
field: MarriageField,
|
|
candidates: readonly string[],
|
|
): boolean {
|
|
const rawKey = (field.key || "").toLowerCase();
|
|
const keyParts = rawKey.split(".");
|
|
const suffix = keyParts[keyParts.length - 1];
|
|
const normalizedKey = rawKey.replace(/[^a-z0-9]/g, "");
|
|
const normalizedSuffix = suffix.replace(/[^a-z0-9]/g, "");
|
|
|
|
for (const c of candidates) {
|
|
const normC = c.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
if (
|
|
normalizedSuffix === normC ||
|
|
normalizedKey.endsWith(normC) ||
|
|
rawKey === c.toLowerCase() ||
|
|
suffix === c.toLowerCase()
|
|
) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function calculateAgeFromDob(dobString: string): number | null {
|
|
if (!dobString) return null;
|
|
const match = dobString.match(/^(\d{4})-(\d{1,2})-(\d{1,2})/);
|
|
if (!match) return null;
|
|
const year = parseInt(match[1], 10);
|
|
const month = parseInt(match[2], 10) - 1;
|
|
const day = parseInt(match[3], 10);
|
|
const birthDate = new Date(year, month, day);
|
|
if (isNaN(birthDate.getTime())) return null;
|
|
const today = new Date();
|
|
let age = today.getFullYear() - birthDate.getFullYear();
|
|
const m = today.getMonth() - birthDate.getMonth();
|
|
if (m < 0 || (m === 0 && today.getDate() < birthDate.getDate())) {
|
|
age--;
|
|
}
|
|
return age > 0 && age < 120 ? age : null;
|
|
}
|
|
|
|
import {
|
|
formatFieldLabel,
|
|
formatFieldValue,
|
|
formatOptionValue,
|
|
isMarriagePhoneFieldValue,
|
|
titleFromKey,
|
|
} from "@/lib/marriage-field-formatter";
|
|
|
|
function toDisplayField(
|
|
field: MarriageField,
|
|
dictionary?: Record<string, string>,
|
|
): DisplayField | null {
|
|
const value = formatOptionValue(field.value, dictionary);
|
|
|
|
if (!value) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
id: field.key || field.label || value,
|
|
label: formatFieldLabel(field, dictionary),
|
|
value,
|
|
};
|
|
}
|
|
|
|
function pickField(
|
|
fields: MarriageField[],
|
|
candidates: readonly string[],
|
|
usedIndexes: Set<number>,
|
|
dictionary?: Record<string, string>,
|
|
): DisplayField | null {
|
|
for (const [fieldIndex, field] of fields.entries()) {
|
|
if (usedIndexes.has(fieldIndex)) {
|
|
continue;
|
|
}
|
|
|
|
if (matchesCandidate(field, candidates)) {
|
|
const displayField = toDisplayField(field, dictionary);
|
|
if (displayField) {
|
|
usedIndexes.add(fieldIndex);
|
|
return displayField;
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function useMatchSummaryDisplay(
|
|
matchSummary: MarriageMatchSummary | null,
|
|
t?: Record<string, string>,
|
|
) {
|
|
return useMemo(() => {
|
|
const fields = matchSummary?.public_info ?? [];
|
|
const usedIndexes = new Set<number>();
|
|
|
|
// 1. Name: Show only first_name (do not show last_name)
|
|
let displayName: string | null = null;
|
|
const firstNameIdx = fields.findIndex(
|
|
(f) =>
|
|
f.key === "personal_identity.first_name" ||
|
|
f.key?.endsWith(".first_name") ||
|
|
f.key === "first_name",
|
|
);
|
|
const lastNameIdx = fields.findIndex(
|
|
(f) =>
|
|
f.key === "personal_identity.last_name" ||
|
|
f.key?.endsWith(".last_name") ||
|
|
f.key === "last_name",
|
|
);
|
|
|
|
if (lastNameIdx !== -1) {
|
|
usedIndexes.add(lastNameIdx);
|
|
}
|
|
|
|
if (firstNameIdx !== -1 && fields[firstNameIdx].value) {
|
|
usedIndexes.add(firstNameIdx);
|
|
displayName = formatFieldValue(fields[firstNameIdx].value);
|
|
} else {
|
|
const nameField = pickField(
|
|
fields,
|
|
fieldCandidateMatchers.name,
|
|
usedIndexes,
|
|
t,
|
|
);
|
|
if (nameField) {
|
|
const rawName = String(nameField.value).trim();
|
|
displayName = rawName.split(/\s+/)[0] || rawName;
|
|
}
|
|
}
|
|
|
|
if (!displayName && matchSummary?.id) {
|
|
displayName = `Profile #${matchSummary.id}`;
|
|
}
|
|
|
|
// 2. Age (extract and calculate from date_of_birth if available)
|
|
const dobIdx = fields.findIndex(
|
|
(f) =>
|
|
f.key === "personal_identity.date_of_birth" ||
|
|
f.key?.endsWith(".date_of_birth") ||
|
|
f.key?.toLowerCase().includes("date_of_birth") ||
|
|
f.key?.toLowerCase().includes("birth_date"),
|
|
);
|
|
let age: DisplayField | null = null;
|
|
if (dobIdx !== -1 && fields[dobIdx].value) {
|
|
usedIndexes.add(dobIdx);
|
|
const calculatedAge = calculateAgeFromDob(String(fields[dobIdx].value));
|
|
if (calculatedAge) {
|
|
age = {
|
|
id: fields[dobIdx].key,
|
|
label: t ? t["Age"] || "Age" : "Age",
|
|
value: `${calculatedAge}`,
|
|
};
|
|
}
|
|
}
|
|
if (!age) {
|
|
age = pickField(fields, fieldCandidateMatchers.age, usedIndexes, t);
|
|
}
|
|
|
|
// 3. Country of Current Residence
|
|
let currentCountry = pickField(
|
|
fields,
|
|
fieldCandidateMatchers.currentCountry,
|
|
usedIndexes,
|
|
t,
|
|
);
|
|
|
|
// 4. City / State of Current Residence
|
|
let currentCity = pickField(
|
|
fields,
|
|
fieldCandidateMatchers.currentCity,
|
|
usedIndexes,
|
|
t,
|
|
);
|
|
|
|
// If either country or city was not matched as a standalone field, check composite residence
|
|
if (!currentCountry || !currentCity) {
|
|
const residenceIdx = fields.findIndex(
|
|
(f, idx) =>
|
|
!usedIndexes.has(idx) &&
|
|
matchesCandidate(f, fieldCandidateMatchers.residence),
|
|
);
|
|
|
|
if (residenceIdx !== -1) {
|
|
const residenceField = fields[residenceIdx];
|
|
if (
|
|
typeof residenceField.value === "object" &&
|
|
residenceField.value !== null &&
|
|
!Array.isArray(residenceField.value)
|
|
) {
|
|
const valObj = residenceField.value as {
|
|
country?: string;
|
|
city?: string;
|
|
state?: string;
|
|
};
|
|
if (valObj.country && !currentCountry) {
|
|
currentCountry = {
|
|
id: `${residenceField.key}.country`,
|
|
label:
|
|
(t &&
|
|
(t["Current Country of Residence"] ||
|
|
t["Country of Residence"] ||
|
|
t["Country"])) ||
|
|
"Country of Residence",
|
|
value: formatOptionValue(valObj.country, t) || valObj.country,
|
|
};
|
|
}
|
|
const cityVal = [valObj.city, valObj.state].filter(Boolean).join(", ");
|
|
if (cityVal && !currentCity) {
|
|
currentCity = {
|
|
id: `${residenceField.key}.city`,
|
|
label:
|
|
(t &&
|
|
(t["Current City / State of Residence"] ||
|
|
t["City / State of Residence"] ||
|
|
t["City"])) ||
|
|
"City / State of Residence",
|
|
value: formatOptionValue(cityVal, t) || cityVal,
|
|
};
|
|
}
|
|
usedIndexes.add(residenceIdx);
|
|
} else if (!currentCountry && !currentCity) {
|
|
const disp = toDisplayField(residenceField, t);
|
|
if (disp) {
|
|
usedIndexes.add(residenceIdx);
|
|
currentCountry = disp;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 5. Highest level of education
|
|
const educationLevel = pickField(
|
|
fields,
|
|
fieldCandidateMatchers.educationLevel,
|
|
usedIndexes,
|
|
t,
|
|
);
|
|
|
|
// 6. Field of study
|
|
const fieldOfStudy = pickField(
|
|
fields,
|
|
fieldCandidateMatchers.fieldOfStudy,
|
|
usedIndexes,
|
|
t,
|
|
);
|
|
|
|
// 7. Job Title
|
|
const jobTitle = pickField(
|
|
fields,
|
|
fieldCandidateMatchers.jobTitle,
|
|
usedIndexes,
|
|
t,
|
|
);
|
|
|
|
// 8. Hobbies & Main Interests
|
|
const hobbies = pickField(
|
|
fields,
|
|
fieldCandidateMatchers.hobbies,
|
|
usedIndexes,
|
|
t,
|
|
);
|
|
|
|
return {
|
|
name: displayName,
|
|
age,
|
|
currentCountry,
|
|
currentCity,
|
|
educationLevel,
|
|
fieldOfStudy,
|
|
jobTitle,
|
|
hobbies,
|
|
};
|
|
}, [matchSummary, t]);
|
|
}
|
|
|
|
function ProfileInfoItem({
|
|
icon,
|
|
field,
|
|
}: {
|
|
icon: React.ReactNode;
|
|
field: DisplayField;
|
|
}) {
|
|
return (
|
|
<div
|
|
className="
|
|
flex
|
|
items-center
|
|
gap-3
|
|
rounded-[14px]
|
|
border
|
|
border-[#FCE2E6]
|
|
bg-white
|
|
px-3.5
|
|
py-2.5
|
|
min-h-[52px]
|
|
"
|
|
>
|
|
<div
|
|
className="
|
|
flex
|
|
h-[38px]
|
|
w-[38px]
|
|
shrink-0
|
|
items-center
|
|
justify-center
|
|
rounded-full
|
|
bg-[#FFF0F2]
|
|
text-[#F0445B]
|
|
text-[16px]
|
|
"
|
|
>
|
|
{icon}
|
|
</div>
|
|
|
|
<div className="flex min-w-0 flex-1 flex-col text-start">
|
|
<span
|
|
className="
|
|
text-[12px]
|
|
leading-tight
|
|
font-bold
|
|
text-[#1E293B]
|
|
truncate
|
|
"
|
|
>
|
|
{field.label}
|
|
</span>
|
|
|
|
<span
|
|
title={typeof field.value === "string" ? field.value : undefined}
|
|
className="
|
|
mt-0.5
|
|
text-[12px]
|
|
leading-[1.35]
|
|
font-semibold
|
|
text-[#F0445B]
|
|
line-clamp-2
|
|
overflow-hidden
|
|
text-ellipsis
|
|
break-words
|
|
"
|
|
>
|
|
{field.value}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function NewMatchClient() {
|
|
const router = useRouter();
|
|
const { dictionary: t, locale } = useI18n();
|
|
const { top, bottom } = useViewPaddings();
|
|
const { isAdvisorOpen, openAdvisors, closeAdvisors } =
|
|
useMarriageAdvisorsOverlay();
|
|
const { isProfileOpen, openProfile, closeProfile } = useMatchProfileOverlay();
|
|
const { data: profile, isError, isLoading } = useMarriageProfileQuery();
|
|
const [isPaymentSheetOpen, setIsPaymentSheetOpen] = useState(false);
|
|
const [isDeclineConfirmOpen, setIsDeclineConfirmOpen] = useState(false);
|
|
const [paymentError, setPaymentError] = useState<string | null>(null);
|
|
const [isInsufficientCoins, setIsInsufficientCoins] = useState(false);
|
|
|
|
const [showPaymentSuccessToast, setShowPaymentSuccessToast] = useState(false);
|
|
|
|
const [appliedDiscount, setAppliedDiscount] =
|
|
useState<CheckDiscountResult | null>(null);
|
|
|
|
const { data: inventory, isLoading: isInventoryLoading } =
|
|
useHabcoinInventoryQuery({
|
|
enabled: isPaymentSheetOpen,
|
|
});
|
|
|
|
const planPrice = Number(profile?.recommended_plan?.price) || 50;
|
|
const finalPrice = appliedDiscount?.valid
|
|
? appliedDiscount.discountedPrice
|
|
: planPrice;
|
|
const coinBalance = inventory?.coin_balance ?? 0;
|
|
const hasEnoughCoins =
|
|
!isInsufficientCoins &&
|
|
(inventory === undefined ? true : coinBalance >= finalPrice);
|
|
|
|
const paymentMutation = useHabcoinPaymentMutation();
|
|
const caseId = profile?.active_case?.case_id;
|
|
const respondMutation = useRespondToMarriageCaseMutation(caseId ?? "", {
|
|
onSuccess: () => {
|
|
router.replace(localizePath("/finding-match", locale));
|
|
},
|
|
});
|
|
|
|
const handlePayment = async () => {
|
|
const recommendedPlanId = profile?.recommended_plan?.id;
|
|
if (!recommendedPlanId) return;
|
|
|
|
try {
|
|
setPaymentError(null);
|
|
setIsInsufficientCoins(false);
|
|
await paymentMutation.mutateAsync({
|
|
objectId: recommendedPlanId,
|
|
discountCode: appliedDiscount?.valid ? appliedDiscount.code : undefined,
|
|
});
|
|
setIsPaymentSheetOpen(false);
|
|
setShowPaymentSuccessToast(true);
|
|
openProfile();
|
|
} catch (err: any) {
|
|
console.error("Payment failed", err);
|
|
const msg =
|
|
err?.response?.data?.error || err?.message || "Payment failed";
|
|
const modalT = (t as any).paymentModal || {};
|
|
if (msg === "Not enough coins") {
|
|
setIsInsufficientCoins(true);
|
|
setPaymentError(
|
|
modalT.insufficientCoins ||
|
|
"Insufficient coin balance. Please recharge your account.",
|
|
);
|
|
} else {
|
|
setPaymentError(msg);
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleDecline = async () => {
|
|
if (!caseId) return;
|
|
try {
|
|
await respondMutation.mutateAsync({ action: "reject" });
|
|
} catch (err) {
|
|
console.error("Decline failed", err);
|
|
}
|
|
};
|
|
|
|
const openDeclineConfirm = () => {
|
|
setIsPaymentSheetOpen(false);
|
|
setIsDeclineConfirmOpen(true);
|
|
};
|
|
|
|
const cancelDeclineConfirm = () => {
|
|
setIsDeclineConfirmOpen(false);
|
|
setIsPaymentSheetOpen(true);
|
|
};
|
|
|
|
useEffect(() => {
|
|
console.log("🔍 [NewMatchClient] Current React Query Profile State:", {
|
|
isLoading,
|
|
isError,
|
|
hasProfile: Boolean(profile),
|
|
profileId: profile?.id,
|
|
status: profile?.status,
|
|
active_case: profile?.active_case,
|
|
hasMatchSummary: Boolean(profile?.match_summary),
|
|
matchSummaryId: profile?.match_summary?.id,
|
|
publicInfoCount: profile?.match_summary?.public_info?.length,
|
|
fullProfileObject: profile,
|
|
});
|
|
|
|
if (!profile) {
|
|
return;
|
|
}
|
|
const targetPath = getSubmitPath(profile);
|
|
if (targetPath !== "/new-match") {
|
|
router.replace(localizePath(targetPath, locale));
|
|
}
|
|
}, [profile, locale, router, isLoading, isError]);
|
|
|
|
// Signal Flutter to lift its loading cover once the profile is available.
|
|
useHabibWebReady(!!profile && !isLoading);
|
|
|
|
const isRedirecting = useMemo(() => {
|
|
if (!profile) return false;
|
|
return getSubmitPath(profile) !== "/new-match";
|
|
}, [profile]);
|
|
|
|
const matchSummary = profile?.match_summary ?? null;
|
|
const matchDisplay = useMatchSummaryDisplay(matchSummary, t);
|
|
|
|
if (typeof window !== "undefined") {
|
|
if (!matchSummary && !isLoading && !isRedirecting) {
|
|
console.warn(
|
|
"⚠️ [NewMatchClient] match_summary is null on profile! 'No match summary is available yet.' will be displayed. Profile:",
|
|
profile,
|
|
);
|
|
} else if (matchSummary) {
|
|
console.log(
|
|
"✅ [NewMatchClient] Rendering match summary card with:",
|
|
matchDisplay,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (isLoading || isRedirecting) {
|
|
return (
|
|
<>
|
|
<PageBackground />
|
|
<main
|
|
style={{
|
|
paddingTop: `${Math.max(12, top + 4)}px`,
|
|
}}
|
|
className="-mx-[17px] flex min-h-dvh flex-col px-[18px] text-center"
|
|
>
|
|
<div className="shrink-0 mb-1">
|
|
<PageHeader profile={profile} />
|
|
</div>
|
|
|
|
<div
|
|
style={{
|
|
paddingBottom: `${profile?.can_edit_profile === false ? 84 + bottom : 20 + bottom}px`,
|
|
}}
|
|
className="min-h-0 flex-1 flex flex-col gap-3.5 py-1"
|
|
>
|
|
{/* 1. Header Section Skeleton */}
|
|
<section className="flex flex-col items-center shrink-0">
|
|
<LoadingSkeleton className="h-[56px] w-[56px] rounded-full" />
|
|
<LoadingSkeleton className="h-6 w-[200px] mt-2.5" />
|
|
<LoadingSkeleton className="h-3.5 w-[260px] mt-1.5" />
|
|
</section>
|
|
|
|
{/* 2. Match Card Skeleton */}
|
|
<section className="w-full rounded-[16px] border border-white/80 bg-white px-4 py-4 shadow-none flex flex-col items-center shrink-0">
|
|
{/* Name line */}
|
|
<LoadingSkeleton className="h-4 w-[130px]" />
|
|
|
|
{/* Subtitle / Details lines */}
|
|
<div className="mt-2.5 w-full flex flex-col items-center gap-2 min-h-[40px]">
|
|
<LoadingSkeleton className="h-3 w-[180px]" />
|
|
<LoadingSkeleton className="h-3 w-[150px]" />
|
|
</div>
|
|
|
|
{/* Button skeleton */}
|
|
<LoadingSkeleton className="mt-3.5 h-[40px] w-full rounded-[10px]" />
|
|
</section>
|
|
|
|
{/* 3. Advisor Card Skeleton */}
|
|
<section className="w-full shrink-0">
|
|
<div className="rounded-[14px] border border-white/80 bg-white/75 px-4 py-3.5 text-left shadow-none backdrop-blur-sm">
|
|
<LoadingSkeleton className="h-4 w-[120px]" />
|
|
<div className="mt-1.5 space-y-1">
|
|
<LoadingSkeleton className="h-2.5 w-[240px]" />
|
|
<LoadingSkeleton className="h-2.5 w-[160px]" />
|
|
</div>
|
|
|
|
<div className="mt-3.5 flex items-center justify-between gap-3">
|
|
<div className="flex items-center pl-1">
|
|
<LoadingSkeleton className="h-[28px] w-[28px] rounded-full border-2 border-white" />
|
|
<LoadingSkeleton className="-ml-1.5 h-[28px] w-[28px] rounded-full border-2 border-white" />
|
|
<LoadingSkeleton className="-ml-1.5 h-[28px] w-[28px] rounded-full border-2 border-white" />
|
|
</div>
|
|
|
|
<LoadingSkeleton className="h-[36px] w-[100px] rounded-[9px]" />
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</main>
|
|
</>
|
|
);
|
|
}
|
|
|
|
const isFemaleProfile = profile?.gender === "female";
|
|
const matchHeadingTitle = isFemaleProfile
|
|
? t["New Marriage Proposal"]
|
|
: t["YOU HAVE A NEW MATCH!"];
|
|
const matchHeadingDescription = isFemaleProfile
|
|
? t[
|
|
"A suitable match has been found for you. If approved, your profile will be evaluated to proceed with the introduction process."
|
|
]
|
|
: t[
|
|
"If you proceed, we will notify the other party, and upon their approval, you can view each other's contact information."
|
|
];
|
|
|
|
const isMale = profile?.gender === "male";
|
|
const hasActiveSub =
|
|
!!profile?.active_subscription &&
|
|
profile.active_subscription.is_active !== false &&
|
|
profile.active_subscription.is_valid !== false;
|
|
const isMatchAvailable = !!profile?.match_summary;
|
|
|
|
return (
|
|
<>
|
|
<PageBackground />
|
|
|
|
<main
|
|
style={{
|
|
paddingTop: `${Math.max(12, top + 4)}px`,
|
|
}}
|
|
className="-mx-[17px] flex min-h-dvh flex-col px-[18px] text-center"
|
|
>
|
|
<div className="shrink-0 mb-1">
|
|
<PageHeader profile={profile} />
|
|
</div>
|
|
|
|
<div
|
|
style={{
|
|
paddingBottom: `${profile?.can_edit_profile === false ? 90 + bottom : 28 + bottom}px`,
|
|
}}
|
|
className="min-h-0 flex-1 flex flex-col gap-4 py-2"
|
|
>
|
|
{/* 1. Header Section */}
|
|
<section className="flex flex-col items-center shrink-0">
|
|
<div
|
|
aria-hidden="true"
|
|
className="relative flex h-[56px] w-[56px] items-center justify-center rounded-full bg-[#FF4E67] shadow-none"
|
|
>
|
|
<Image
|
|
src={"/assets/images/Ellipse 1210.svg"}
|
|
width={66}
|
|
height={66}
|
|
alt="notification"
|
|
/>
|
|
</div>
|
|
<h2 className="text-[20px] font-bold mt-2.5 text-[#171717] leading-tight">
|
|
{matchHeadingTitle}
|
|
</h2>
|
|
<p className="mt-1.5 max-w-[320px] text-[12.5px] leading-[1.4] font-medium text-[#7C7C7C]">
|
|
{matchHeadingDescription}
|
|
</p>
|
|
</section>
|
|
|
|
{/* 2. Match Summary Card Section */}
|
|
<div className="w-full shrink-0">
|
|
{isLoading ? (
|
|
<section className="relative overflow-hidden rounded-[24px] bg-[#FAFBFD] border border-[#F2E5E8] shadow-[0_4px_24px_rgba(0,0,0,0.04)] text-start">
|
|
<div className="relative h-[74px] w-full bg-[linear-gradient(180deg,#FCE2E6_0%,#FFD6DC_100%)]" />
|
|
|
|
<div className="relative -mt-[38px] mx-auto w-[72px] h-[72px] rounded-full p-[3px] bg-white shadow-[0_4px_12px_rgba(0,0,0,0.08)] flex items-center justify-center z-10">
|
|
<LoadingSkeleton className="w-full h-full rounded-full" />
|
|
</div>
|
|
|
|
<div className="px-4 text-center mt-1.5 flex flex-col items-center">
|
|
<LoadingSkeleton className="h-4 w-[120px] rounded-md" />
|
|
<LoadingSkeleton className="h-1.5 w-[50px] mt-1.5 rounded-full" />
|
|
</div>
|
|
|
|
<div className="p-3.5 space-y-2">
|
|
{[1, 2, 3, 4, 5, 6, 7].map((i) => (
|
|
<div
|
|
key={i}
|
|
className="flex items-center gap-3 rounded-[14px] border border-[#FCE2E6] bg-white px-3.5 py-2.5 min-h-[52px]"
|
|
>
|
|
<LoadingSkeleton className="h-[38px] w-[38px] rounded-full shrink-0" />
|
|
<div className="flex-1 space-y-1.5">
|
|
<LoadingSkeleton className="h-3 w-[35%]" />
|
|
<LoadingSkeleton className="h-3 w-[60%]" />
|
|
</div>
|
|
</div>
|
|
))}
|
|
<LoadingSkeleton className="h-[46px] w-full rounded-[13px] mt-2.5" />
|
|
</div>
|
|
</section>
|
|
) : (
|
|
<section className="relative overflow-hidden rounded-[24px] bg-[#FAFBFD] border border-[#F2E5E8] shadow-[0_4px_20px_rgba(240,68,91,0.06)] text-start">
|
|
{isError ? (
|
|
<p className="py-8 text-center text-[13px] font-semibold text-[#8A8A8A]">
|
|
Unable to load match summary.
|
|
</p>
|
|
) : matchSummary ? (
|
|
<>
|
|
{/* Top Curved Banner */}
|
|
<div className="relative h-[74px] w-full overflow-hidden bg-[linear-gradient(180deg,#F0445B_0%,#F54B64_100%)]">
|
|
{/* Bottom Curve */}
|
|
<svg
|
|
className="absolute bottom-0 left-0 w-full h-3 text-[#FAFBFD]"
|
|
preserveAspectRatio="none"
|
|
viewBox="0 0 100 20"
|
|
fill="currentColor"
|
|
>
|
|
<path d="M0 20 C30 0 70 0 100 20 Z" />
|
|
</svg>
|
|
</div>
|
|
|
|
{/* Circular Overlapping Avatar */}
|
|
<div className="relative -mt-[38px] mx-auto w-[72px] h-[72px] rounded-full p-[3px] bg-white shadow-[0_4px_12px_rgba(0,0,0,0.08)] flex items-center justify-center z-10">
|
|
<div className="w-full h-full rounded-full overflow-hidden bg-[#F0445B] flex items-center justify-center">
|
|
<Image
|
|
src={
|
|
isMale
|
|
? "/assets/images/female_avatar.svg"
|
|
: "/assets/images/Avatar Image.png"
|
|
}
|
|
width={68}
|
|
height={68}
|
|
alt="candidate avatar"
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Candidate Name & Heart Divider */}
|
|
<div className="px-4 text-center mt-1">
|
|
<h2 className="text-[18px] font-extrabold text-[#111827] tracking-tight leading-snug break-words">
|
|
{matchDisplay.name}
|
|
</h2>
|
|
|
|
<div className="flex items-center justify-center gap-2 mt-1 mb-1.5">
|
|
<span className="h-[1px] w-8 bg-[#FFCCD3] rounded-full" />
|
|
<IoHeartOutline className="text-[#F0445B] text-[14px]" />
|
|
<span className="h-[1px] w-8 bg-[#FFCCD3] rounded-full" />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Info Items List */}
|
|
<div className="space-y-2 px-3.5 pt-0.5 pb-3.5">
|
|
{matchDisplay.age && (
|
|
<ProfileInfoItem
|
|
field={matchDisplay.age}
|
|
icon={<FaCalendarDays />}
|
|
/>
|
|
)}
|
|
|
|
{matchDisplay.currentCountry && (
|
|
<ProfileInfoItem
|
|
field={matchDisplay.currentCountry}
|
|
icon={<FaLocationDot />}
|
|
/>
|
|
)}
|
|
|
|
{matchDisplay.currentCity && (
|
|
<ProfileInfoItem
|
|
field={matchDisplay.currentCity}
|
|
icon={<FaLocationDot />}
|
|
/>
|
|
)}
|
|
|
|
{matchDisplay.educationLevel && (
|
|
<ProfileInfoItem
|
|
field={matchDisplay.educationLevel}
|
|
icon={<FaGraduationCap />}
|
|
/>
|
|
)}
|
|
|
|
{matchDisplay.fieldOfStudy && (
|
|
<ProfileInfoItem
|
|
field={matchDisplay.fieldOfStudy}
|
|
icon={<FaBookOpen />}
|
|
/>
|
|
)}
|
|
|
|
{matchDisplay.jobTitle && (
|
|
<ProfileInfoItem
|
|
field={matchDisplay.jobTitle}
|
|
icon={<FaBriefcase />}
|
|
/>
|
|
)}
|
|
|
|
{matchDisplay.hobbies && (
|
|
<ProfileInfoItem
|
|
field={matchDisplay.hobbies}
|
|
icon={<FaStar />}
|
|
/>
|
|
)}
|
|
|
|
{/* Button */}
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
if (isMale && !hasActiveSub) {
|
|
setIsPaymentSheetOpen(true);
|
|
} else {
|
|
openProfile();
|
|
}
|
|
}}
|
|
className="
|
|
mt-2.5
|
|
flex
|
|
h-[46px]
|
|
w-full
|
|
items-center
|
|
justify-center
|
|
gap-2
|
|
rounded-[13px]
|
|
bg-[linear-gradient(180deg,#F0445B_0%,#F4556E_100%)]
|
|
text-white
|
|
text-[14px]
|
|
font-bold
|
|
shadow-[0_4px_14px_rgba(240,68,91,0.25)]
|
|
active:scale-[0.98]
|
|
transition
|
|
cursor-pointer
|
|
border-none
|
|
"
|
|
>
|
|
<LuEye className="text-[18px] text-white" />
|
|
<span>{t["View more details"]}</span>
|
|
</button>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<p className="py-8 text-center text-[13px] font-semibold text-[#8A8A8A]">
|
|
No match summary is available yet.
|
|
</p>
|
|
)}
|
|
</section>
|
|
)}
|
|
</div>
|
|
|
|
{/* 3. Advisor Section */}
|
|
<div className="w-full shrink-0">
|
|
<AdvisorActionsCard
|
|
title={t["Get an advisor"]}
|
|
description={
|
|
t[
|
|
"Not sure what to do next? Our psychology section is here to guide you at every step."
|
|
]
|
|
}
|
|
avatars={advisorAvatars}
|
|
extraCount={7}
|
|
getAdvisorLabel={t["Get Advisor"]}
|
|
onGetAdvisor={openAdvisors}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
|
|
{profile?.can_edit_profile === false && (
|
|
<div
|
|
style={{ paddingBottom: `${16 + bottom}px` }}
|
|
className="fixed inset-x-0 bottom-0 z-20 mx-auto w-full sm:max-w-[375px] bg-[#F9F8F8] px-[17px] pt-3 pb-[16px]"
|
|
>
|
|
<div
|
|
className="inline-flex w-full items-center justify-center gap-2 rounded-[9px] border-none bg-[#D1D1D6] px-4 h-[52px] text-center text-[#747474] shadow-none"
|
|
role="status"
|
|
>
|
|
<FaLock aria-hidden="true" className="h-3.5 w-3.5 shrink-0" />
|
|
<span className="group-16 leading-none font-semibold">
|
|
{t["Profile is locked"]}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{isPaymentSheetOpen && isInventoryLoading && (
|
|
<InformationSheet
|
|
isLoading={true}
|
|
style={{
|
|
paddingBottom: bottom > 0 ? `${14 + bottom}px` : undefined,
|
|
}}
|
|
onClose={() => {
|
|
setIsPaymentSheetOpen(false);
|
|
setPaymentError(null);
|
|
setIsInsufficientCoins(false);
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{isPaymentSheetOpen && !isInventoryLoading && (
|
|
<InformationSheet
|
|
style={{
|
|
paddingBottom: bottom > 0 ? `${14 + bottom}px` : undefined,
|
|
}}
|
|
icon="coin"
|
|
title={
|
|
!hasEnoughCoins ? (
|
|
<span className="text-[#BD3F3F]">
|
|
{t["You do not have enough Habib Coins"] ||
|
|
"You do not have enough Habib Coins"}
|
|
</span>
|
|
) : (
|
|
t["Verification & Subscription Activation"] ||
|
|
"Verification & Subscription Activation"
|
|
)
|
|
}
|
|
description={
|
|
!hasEnoughCoins ? (
|
|
<p className="text-[14px] text-[#4D4D4D] leading-[1.3] font-normal text-center">
|
|
{t[
|
|
"Please add more Habib Coins to your balance (or use discount code), so that you can use them to access this content"
|
|
] ||
|
|
"Please add more Habib Coins to your balance (or use discount code), so that you can use them to access this content"}
|
|
</p>
|
|
) : (
|
|
<div className="space-y-3.5 text-center w-full">
|
|
<p className="text-[14px] text-[#4D4D4D] leading-[1.3] font-normal">
|
|
{t[
|
|
"This payment acts as a verification process for your account and activates a 3-month subscription to receive new case introductions. The cost of this subscription is 50 Habib Coins."
|
|
] ||
|
|
"This payment acts as a verification process for your account and activates a 3-month subscription to receive new case introductions. The cost of this subscription is 50 Habib Coins."}
|
|
</p>
|
|
|
|
<div className="w-full bg-white rounded-[11px] p-3 border border-gray-200/60 shadow-2xs">
|
|
<span className="text-[11px] text-[#8B8B8B] block mb-0.5 font-medium">
|
|
{t["Valid for 3 months"] || "Valid for 3 months"}
|
|
</span>
|
|
<span className="text-[16px] font-bold text-[#FF4E67]">
|
|
{finalPrice} {t["Habib Coins"] || "Habib Coins"}
|
|
</span>
|
|
</div>
|
|
|
|
<p className="text-[11px] text-[#8B8B8B] leading-normal">
|
|
{t[
|
|
"Please note that there is no guarantee for a specific number of cases, and the volume of incoming cases is solely subject to profile compatibility with other users."
|
|
] ||
|
|
"Please note that there is no guarantee for a specific number of cases, and the volume of incoming cases is solely subject to profile compatibility with other users."}
|
|
</p>
|
|
</div>
|
|
)
|
|
}
|
|
onClose={() => {
|
|
setIsPaymentSheetOpen(false);
|
|
setPaymentError(null);
|
|
setIsInsufficientCoins(false);
|
|
setAppliedDiscount(null);
|
|
}}
|
|
buttons={
|
|
<div className="space-y-3 w-full">
|
|
{profile?.recommended_plan?.id ? (
|
|
<DiscountWidget
|
|
objectId={profile.recommended_plan.id}
|
|
onDiscountApplied={(res) => setAppliedDiscount(res)}
|
|
onDiscountCleared={() => setAppliedDiscount(null)}
|
|
/>
|
|
) : null}
|
|
|
|
{paymentError && (
|
|
<div className="w-full bg-[#FEF2F2] text-[#B91C1C] text-xs p-3 rounded-[11px] text-center font-medium">
|
|
{paymentError}
|
|
</div>
|
|
)}
|
|
|
|
<div className="grid w-full grid-cols-[33fr_67fr] gap-3">
|
|
<button
|
|
type="button"
|
|
disabled={
|
|
paymentMutation.isPending || respondMutation.isPending
|
|
}
|
|
className="appearance-none border-0 bg-transparent p-0 text-left disabled:cursor-not-allowed disabled:opacity-70 min-w-0"
|
|
onClick={openDeclineConfirm}
|
|
>
|
|
<div className="inline-flex w-full items-center justify-center rounded-[11px] border border-[#747474] bg-transparent px-2 h-[52px] text-[16px] font-semibold text-[#747474] transition-opacity active:opacity-90 min-w-0">
|
|
{respondMutation.isPending ? (
|
|
<LoadingThreeDot />
|
|
) : (
|
|
<span className="truncate">
|
|
{t["Decline"] || "Decline"}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</button>
|
|
|
|
{!hasEnoughCoins ? (
|
|
<button
|
|
type="button"
|
|
className="appearance-none border-0 bg-transparent p-0 text-left disabled:cursor-not-allowed disabled:opacity-70 min-w-0"
|
|
onClick={() => buyHabibCoinPackages()}
|
|
>
|
|
<div className="inline-flex w-full items-center justify-center gap-2 rounded-[11px] bg-[#F0445B] px-4 h-[52px] text-[16px] font-semibold text-white shadow-[0_10px_18px_rgba(240,68,91,0.28)] transition-opacity active:opacity-90 min-w-0">
|
|
<span className="truncate">
|
|
{t["Buy Habib Coins"] || "Buy Habib Coins"}
|
|
</span>
|
|
</div>
|
|
</button>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
disabled={
|
|
paymentMutation.isPending || respondMutation.isPending
|
|
}
|
|
className="appearance-none border-0 bg-transparent p-0 text-left disabled:cursor-not-allowed disabled:opacity-70 min-w-0"
|
|
onClick={handlePayment}
|
|
>
|
|
<div className="inline-flex w-full items-center justify-center gap-2 rounded-[11px] bg-[#F0445B] px-4 h-[52px] text-[16px] font-semibold text-white shadow-[0_10px_18px_rgba(240,68,91,0.28)] transition-opacity active:opacity-90 min-w-0">
|
|
{paymentMutation.isPending ? (
|
|
<LoadingThreeDot />
|
|
) : (
|
|
<>
|
|
<span className="truncate min-w-0">
|
|
{t["Payment"] || "Payment"}
|
|
</span>
|
|
<span className="inline-flex items-center gap-1 rounded-[12px] bg-black/15 px-[9px] py-[4px] text-xs font-semibold leading-none text-white shrink-0 min-w-0 whitespace-nowrap">
|
|
<Image
|
|
src="/assets/images/Inner Plugdsain Iframe.svg"
|
|
alt=""
|
|
aria-hidden="true"
|
|
width={16}
|
|
height={16}
|
|
className="shrink-0"
|
|
/>
|
|
<span className="truncate">{finalPrice}</span>
|
|
</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
}
|
|
/>
|
|
)}
|
|
|
|
{isDeclineConfirmOpen && (
|
|
<InformationSheet
|
|
icon="warning"
|
|
title={
|
|
(t as any)["Are you sure?"] ||
|
|
(locale === "fa" ? "آیا مطمئن هستید؟" : "Are you sure?")
|
|
}
|
|
description={
|
|
<div className="flex flex-col gap-2 text-center mt-1 group-12 text-[#4D4D4D] leading-relaxed">
|
|
<p className="font-medium text-[14px]">
|
|
{t[
|
|
"Are you sure you've fully reviewed the profile and want to reject this profile?"
|
|
] ||
|
|
(locale === "fa"
|
|
? "آیا از رد این پیشنهاد مطمئن هستید؟ در صورت رد، این مورد دیگر در دسترس نخواهد بود."
|
|
: "Are you sure you want to decline this proposal? Once declined, this match will no longer be available.")}
|
|
</p>
|
|
</div>
|
|
}
|
|
buttons={({ close }) => (
|
|
<SwipeButton
|
|
disabled={respondMutation.isPending}
|
|
text={t["Decline"] || (locale === "fa" ? "رد پیشنهاد" : "Decline")}
|
|
cancelText={t["Cancel"] || (locale === "fa" ? "انصراف" : "Cancel")}
|
|
onCancel={() => {
|
|
close();
|
|
cancelDeclineConfirm();
|
|
}}
|
|
onSuccess={async () => {
|
|
await handleDecline();
|
|
close();
|
|
setIsDeclineConfirmOpen(false);
|
|
setIsPaymentSheetOpen(false);
|
|
}}
|
|
/>
|
|
)}
|
|
onClose={cancelDeclineConfirm}
|
|
/>
|
|
)}
|
|
|
|
<MarriageAdvisorsOverlay open={isAdvisorOpen} onClose={closeAdvisors} />
|
|
|
|
<MatchProfileOverlay open={isProfileOpen} onClose={closeProfile} />
|
|
|
|
{showPaymentSuccessToast && (
|
|
<ErrorToast
|
|
variant="success"
|
|
message={t["Payment successful"] || "Payment successful"}
|
|
onClose={() => setShowPaymentSuccessToast(false)}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|