18 Commits

Author SHA1 Message Date
Ali Alavi 8f8bf008ac chore(staging): auto-merge origin/master into staging 11 hours ago
Ali Alavi ac89d4ac29 feat(marriage): merge staging features (banned page, request-accepted enhancements, 24-lang support, question-file, outcome-reporting) 11 hours ago
Ali Alavi 305b9bb826 Merge branch 'staging' of https://git.nwhco.ir/sina_sajjadi/marriage into staging 3 days ago
Ali Alavi 38bd0cf2ef Merge branch 'staging' of https://git.nwhco.ir/sina_sajjadi/marriage into staging 4 days ago
Ali Alavi 7c367011f3 Merge branch 'staging' of https://git.nwhco.ir/sina_sajjadi/marriage into staging 4 days ago
Ali Alavi da90075f37 Merge branch 'staging' of https://git.nwhco.ir/sina_sajjadi/marriage into staging 4 days ago
Ali Alavi 0cc904fb79 Merge branch 'staging' of https://git.nwhco.ir/sina_sajjadi/marriage into staging 4 days ago
Ali Alavi f8737c7da5 Merge branch 'staging' of https://git.nwhco.ir/sina_sajjadi/marriage into staging 4 days ago
Ali Alavi 83291d7229 Merge branch 'staging' of https://git.nwhco.ir/sina_sajjadi/marriage into staging 4 days ago
Ali Alavi dbeabbfe67 Merge branch 'staging' of https://git.nwhco.ir/sina_sajjadi/marriage into staging 4 days ago
Ali Alavi d26bbb75ca Merge branch 'staging' of https://git.nwhco.ir/sina_sajjadi/marriage into staging 4 days ago
Ali Alavi e4e2053896 Merge branch 'staging' of https://git.nwhco.ir/sina_sajjadi/marriage into staging 4 days ago
Ali Alavi 56e7cceca4 Merge branch 'staging' of https://git.nwhco.ir/sina_sajjadi/marriage into staging 4 days ago
Ali Alavi d3b911bd8a Merge branch 'staging' of https://git.nwhco.ir/sina_sajjadi/marriage into staging 6 days ago
Ali Alavi 9588060378 Merge branch 'staging' of https://git.nwhco.ir/sina_sajjadi/marriage into staging 6 days ago
Ali Alavi c1ece99960 fix(staging): use RFC-compliant najm-backend alias to prevent Django 400 Bad Request 1 week ago
Ali Alavi 00363ad6a7 fix(build): migrate production build to webpack, fix page props typing, and resolve React hydration freeze 1 week ago
Ali Alavi ee432181e9 fix(build): add missing advisory translation key to en.json and fix auto_deploy priority order 1 week ago
  1. 32
      auto_deploy_staging.sh
  2. 6
      docker-compose.staging.yml
  3. 17
      next.config.ts
  4. 2
      package.json
  5. 374
      src/app/marriage-advisors/page.tsx
  6. 753
      src/app/new-match/profile/page.tsx
  7. 373
      src/components/Componentes/marriage-advisors-client.tsx
  8. 4
      src/components/Componentes/marriage-advisors-overlay.tsx
  9. 4
      src/components/Componentes/match-profile-overlay.tsx
  10. 752
      src/components/Componentes/new-match-profile-client.tsx
  11. 206
      src/components/Componentes/question-photo.test.tsx
  12. 58
      src/components/Componentes/question-photo.tsx
  13. 1
      src/translations/locales/en.json

32
auto_deploy_staging.sh

@ -16,22 +16,18 @@ fi
# Fetch latest master and staging from origin # Fetch latest master and staging from origin
git fetch origin master staging --quiet git fetch origin master staging --quiet
# Check 1: Commits ahead on origin/master
MASTER_COMMITS=$(git rev-list --count HEAD..origin/master 2>/dev/null || echo 0)
# Check 2: Commits ahead on origin/staging
# Check 1: Commits ahead on origin/staging (pull staging updates first)
STAGING_COMMITS=$(git rev-list --count HEAD..origin/staging 2>/dev/null || echo 0) STAGING_COMMITS=$(git rev-list --count HEAD..origin/staging 2>/dev/null || echo 0)
DEPLOYED_COMMIT_FILE="$PROJECT_DIR/.last_deployed_commit"
LAST_DEPLOYED=$(cat "$DEPLOYED_COMMIT_FILE" 2>/dev/null || echo "")
CURRENT_HEAD=$(git rev-parse HEAD)
NEED_BUILD=0
if [ "$CURRENT_HEAD" != "$LAST_DEPLOYED" ]; then
if [ "$STAGING_COMMITS" -gt 0 ]; then
echo "$LOG_TAG 🚀 Detected $STAGING_COMMITS new commit(s) on origin/staging!"
git log --oneline HEAD..origin/staging
echo "$LOG_TAG Fast-forwarding / pulling latest origin/staging..."
git merge origin/staging --ff-only || git pull origin staging --no-edit
NEED_BUILD=1 NEED_BUILD=1
fi fi
# Check 2: Commits ahead on origin/master
MASTER_COMMITS=$(git rev-list --count HEAD..origin/master 2>/dev/null || echo 0)
if [ "$MASTER_COMMITS" -gt 0 ]; then if [ "$MASTER_COMMITS" -gt 0 ]; then
echo "$LOG_TAG 🚀 Detected $MASTER_COMMITS new commit(s) on origin/master!" echo "$LOG_TAG 🚀 Detected $MASTER_COMMITS new commit(s) on origin/master!"
git log --oneline HEAD..origin/master git log --oneline HEAD..origin/master
@ -42,11 +38,13 @@ if [ "$MASTER_COMMITS" -gt 0 ]; then
NEED_BUILD=1 NEED_BUILD=1
fi fi
if [ "$STAGING_COMMITS" -gt 0 ]; then
echo "$LOG_TAG 🚀 Detected $STAGING_COMMITS new commit(s) on origin/staging!"
git log --oneline HEAD..origin/staging
echo "$LOG_TAG Pulling latest origin/staging..."
git merge origin/staging --no-edit || git pull origin staging --no-edit
DEPLOYED_COMMIT_FILE="$PROJECT_DIR/.last_deployed_commit"
LAST_DEPLOYED=$(cat "$DEPLOYED_COMMIT_FILE" 2>/dev/null || echo "")
CURRENT_HEAD=$(git rev-parse HEAD)
NEED_BUILD=0
if [ "$CURRENT_HEAD" != "$LAST_DEPLOYED" ]; then
NEED_BUILD=1 NEED_BUILD=1
fi fi

6
docker-compose.staging.yml

@ -16,7 +16,7 @@ services:
environment: environment:
- NODE_ENV=production - NODE_ENV=production
- NEXT_PUBLIC_API_BASE_URL=https://habib.nwhco.ir - NEXT_PUBLIC_API_BASE_URL=https://habib.nwhco.ir
- API_BASE_URL=http://najm_staging_web:8000
- API_BASE_URL=http://najm-backend:8000
- NEXT_PUBLIC_SECURITY_KEY=t5yugymks5458fd4ghfg6h6fg - NEXT_PUBLIC_SECURITY_KEY=t5yugymks5458fd4ghfg6h6fg
healthcheck: healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/healthz"] test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/healthz"]
@ -25,7 +25,9 @@ services:
retries: 3 retries: 3
start_period: 15s start_period: 15s
networks: networks:
- najm_staging_net
najm_staging_net:
aliases:
- marriage-staging-web
networks: networks:
najm_staging_net: najm_staging_net:

17
next.config.ts

@ -6,7 +6,6 @@ const nextConfig: NextConfig = {
poweredByHeader: false, poweredByHeader: false,
reactStrictMode: true, reactStrictMode: true,
allowedDevOrigins: ["192.168.1.64"], allowedDevOrigins: ["192.168.1.64"],
turbopack: {},
devIndicators: false, devIndicators: false,
// Compression // Compression
@ -21,10 +20,6 @@ const nextConfig: NextConfig = {
protocol: "https", protocol: "https",
hostname: "habibapp.com", hostname: "habibapp.com",
}, },
{
protocol: "https",
hostname: "habib.nwhco.ir",
},
], ],
}, },
@ -86,20 +81,12 @@ const nextConfig: NextConfig = {
], ],
}, },
{ {
// Application HTML pages – must never serve stale HTML so hashed JS chunks match current deployment
// Application HTML shell – App Shell + Stale-While-Revalidate pattern for instant WebView rendering
source: "/:path((?!api/|_next/|fonts/|assets/).*)", source: "/:path((?!api/|_next/|fonts/|assets/).*)",
headers: [ headers: [
{ {
key: "Cache-Control", key: "Cache-Control",
value: "no-store, no-cache, must-revalidate, max-age=0",
},
{
key: "Pragma",
value: "no-cache",
},
{
key: "Expires",
value: "0",
value: "public, max-age=0, stale-while-revalidate=86400",
}, },
], ],
}, },

2
package.json

@ -4,7 +4,7 @@
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --webpack", "dev": "next dev --webpack",
"build": "next build",
"build": "next build --webpack",
"start": "next start", "start": "next start",
"lint": "biome check", "lint": "biome check",
"format": "biome format --write", "format": "biome format --write",

374
src/app/marriage-advisors/page.tsx

@ -1,373 +1,5 @@
"use client";
import MarriageAdvisorsClient from "@/components/Componentes/marriage-advisors-client";
import { useState } from "react";
import Image from "next/image";
import { Ic } from "@/icons";
import StickyHeader from "@/components/Componentes/sticky-header";
import NavigationButton from "@/components/Componentes/navigation-button";
import { PageBackground } from "@/components/Componentes/page-background";
import SupportSheet from "@/components/Componentes/support-sheet";
import { useI18n } from "@/translations/provider";
import {
useMarriageAdvisorsQuery,
type MarriageConsultant,
} from "@/hooks/marriage/use-marriage-advisors";
import { postActionToFlutter } from "@/lib/webview-actions";
const FALLBACK_AVATAR = "/assets/images/Avatar Image.png";
type MarriageAdvisorsPageProps = {
onClose?: () => void;
};
export default function MarriageAdvisorsPage({
onClose,
}: MarriageAdvisorsPageProps = {}) {
const { dictionary: t } = useI18n();
const [isSupportOpen, setIsSupportOpen] = useState(false);
const { data, isLoading, isError, refetch } = useMarriageAdvisorsQuery();
const advisors = data?.results ?? [];
const mainStyle = {
backgroundImage: `url("/assets/images/islamic_pattern_2_2892_3864.svg")`,
backgroundColor: "#F5F5F5",
backgroundRepeat: "repeat-y",
backgroundSize: "100% auto",
};
// Opens the native Talk consultant page; the Flutter side fetches the
// consultant by username, so only the bare username is needed. Outside the
// WebView (plain browser) postActionToFlutter is a no-op returning false.
const openConsultantPage = (username: string) => {
postActionToFlutter("open_consultant_page", { consultant: username });
};
return (
<>
<PageBackground />
<main
className="-mx-[17px] flex h-dvh flex-col overflow-hidden pb-10"
style={mainStyle}
>
<StickyHeader sticky className="shrink-0">
<div className="flex items-center justify-between gap-3">
<NavigationButton
variant="transparent"
icon="back"
iconLabel={t["Go back"]}
onClick={onClose}
/>
<h1
className="min-w-0 flex-1 text-center font-semibold text-[14px] leading-[16px] text-white"
>
{t["Marriage advisors"]}
</h1>
<div className="size-10 shrink-0" />
</div>
</StickyHeader>
<section className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-[17px] pb-32 pt-5 flex flex-col items-center gap-4">
{isLoading && <AdvisorsSkeleton />}
{isError && (
<div className="flex w-[343px] flex-col items-center gap-3 rounded-[15px] border border-[#EBEBEB] bg-white px-4 py-8 text-center shadow-[0px_4px_12px_rgba(0,0,0,0.05)]">
<p
className="text-[14px] leading-[20px] text-[#747474]"
>
{t["Failed to load advisors."]}
</p>
<button
type="button"
onClick={() => refetch()}
className="cursor-pointer rounded-[20px] border-none bg-[rgba(14,177,60,0.1)] px-4 py-1.5 text-[12px] font-semibold text-[#0EB13C] transition-transform active:scale-95"
>
{t["Try again"]}
</button>
</div>
)}
{!isLoading && !isError && advisors.length === 0 && (
<div className="flex w-[343px] flex-col items-center rounded-[15px] border border-[#EBEBEB] bg-white px-4 py-8 text-center shadow-[0px_4px_12px_rgba(0,0,0,0.05)]">
<p
className="text-[14px] leading-[20px] text-[#747474]"
>
{t["No advisors are currently available."]}
</p>
</div>
)}
{advisors.map((advisor) => (
<AdvisorCard
key={advisor.username}
advisor={advisor}
onOpen={() => openConsultantPage(advisor.username)}
/>
))}
</section>
</main>
<SupportSheet
isOpen={isSupportOpen}
onClose={() => setIsSupportOpen(false)}
/>
</>
);
}
function AdvisorCard({
advisor,
onOpen,
}: {
advisor: MarriageConsultant;
onOpen: () => void;
}) {
const { dictionary: t, locale } = useI18n();
const isRtl = locale === "fa" || locale === "ar" || locale === "ur";
const unreadCount = advisor.unread_count ?? 0;
const isOnline = advisor.status === "online";
const rating = Number(advisor.avg_rate ?? 0);
const hasChat = advisor.contact_type?.includes("chat");
const hasVoice = advisor.contact_type?.includes("voice");
const hasVideo = advisor.contact_type?.includes("video");
const hasActiveContact = hasChat || hasVoice || hasVideo;
const topics = advisor.topics?.filter(Boolean) ?? [];
return (
<div
role="button"
tabIndex={0}
aria-label={advisor.fullname ?? advisor.username}
onClick={onOpen}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onOpen();
}
}}
className="relative w-full max-w-[343px] cursor-pointer transition-transform active:scale-[0.98] outline-none"
>
{/* Outer Card Container */}
<div
className={`w-full rounded-[15px] bg-white py-3 shadow-[0_4px_16px_rgba(0,0,0,0.06)] transition-all ${
unreadCount > 0
? "border border-[#ECA533]"
: "border border-[#EBEBEB]"
}`}
>
<div className="flex flex-col">
{/* Header Row: Avatar + Info + Rating */}
<div className="flex items-center gap-3 px-3">
{/* Avatar with Status Dot */}
<div className="relative size-12 shrink-0">
{advisor.avatar_url ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={advisor.avatar_url}
alt={advisor.fullname ?? advisor.username}
className="size-full rounded-full object-cover bg-[#EBEBEB]"
onError={(event) => {
event.currentTarget.src = FALLBACK_AVATAR;
}}
/>
) : (
<div className="size-full rounded-full bg-[#EBEBEB] flex items-center justify-center">
<svg
width="24"
height="24"
viewBox="0 0 33 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="text-[#8B8B8B]"
>
<path
d="M16.5 17.3333C15.4802 17.3333 14.4833 17.0401 13.6353 16.4907C12.7874 15.9413 12.1265 15.1604 11.7362 14.2467C11.346 13.3331 11.2439 12.3278 11.4428 11.3579C11.6418 10.388 12.1329 9.49706 12.854 8.7978C13.5751 8.09853 14.4939 7.62233 15.4941 7.4294C16.4943 7.23648 17.531 7.33549 18.4732 7.71393C19.4154 8.09237 20.2207 8.73323 20.7873 9.55548C21.3538 10.3777 21.6562 11.3444 21.6562 12.3333C21.6562 13.6594 21.113 14.9312 20.146 15.8689C19.179 16.8065 17.8675 17.3333 16.5 17.3333ZM16.5 9.33333C15.8881 9.33333 15.29 9.50928 14.7812 9.83892C14.2724 10.1686 13.8759 10.6371 13.6417 11.1853C13.4076 11.7335 13.3463 12.3367 13.4657 12.9186C13.5851 13.5005 13.8797 14.0351 14.3124 14.4547C14.7451 14.8742 15.2963 15.1599 15.8964 15.2757C16.4966 15.3914 17.1186 15.332 17.6839 15.105C18.2492 14.8779 18.7324 14.4934 19.0724 14C19.4123 13.5067 19.5938 12.9267 19.5938 12.3333C19.5938 11.5377 19.2678 10.7746 18.6876 10.212C18.1074 9.6494 17.3205 9.33333 16.5 9.33333Z"
fill="currentColor"
/>
</svg>
</div>
)}
{/* Status indicator dot badge (top-start) */}
<div className="absolute top-0 -left-0.5 rtl:-right-0.5 rtl:left-auto flex size-3 items-center justify-center rounded-full bg-white p-0.5">
<div
className={`flex size-2.5 items-center justify-center rounded-full ${
isOnline ? "bg-[#0EB13C]/20" : "bg-white"
}`}
>
<div
className={`size-1.5 rounded-full ${
isOnline ? "bg-[#0EB13C]" : "bg-[#747474]"
}`}
/>
</div>
</div>
</div>
{/* Consultant Info */}
<div className="min-w-0 flex-1 flex flex-col justify-center gap-1">
<div className="flex items-center gap-2">
{advisor.is_ai && (
<span className="inline-flex size-4 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-[#F45EA0] to-[#704EE9] text-[9px] font-bold text-white leading-none">
AI
</span>
)}
<h3 className="truncate font-sans text-[15px] font-bold text-[#151C31] leading-tight text-start">
{advisor.fullname ?? advisor.username}
</h3>
{rating > 0 && (
<div className="ms-auto flex shrink-0 items-center gap-1">
<svg
width="12"
height="12"
viewBox="0 0 24 23"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="size-3 text-[#F4C51D]"
>
<path
d="M12.0137 0.5C12.3276 0.5 12.6304 0.567504 12.9268 0.705078C13.2033 0.83346 13.4498 1.03558 13.6641 1.32812L13.6699 1.33594L16.8877 5.55078L16.9814 5.67285L17.127 5.72168L22.0283 7.36426V7.36523C22.5075 7.53628 22.8608 7.81149 23.1113 8.18945C23.3746 8.58681 23.5 8.99869 23.5 9.43457C23.5 9.64108 23.4672 9.85698 23.3975 10.083C23.3466 10.2477 23.2761 10.4069 23.1855 10.5615L23.0889 10.7139L19.9551 15.2051L19.8613 15.3398L19.8652 15.5029L19.9766 20.2646C19.9765 20.8763 19.7652 21.3933 19.3281 21.8408C18.8902 22.2891 18.3925 22.5 17.8125 22.5C17.8116 22.5 17.8078 22.4994 17.8008 22.499C17.7924 22.4985 17.7811 22.4984 17.7666 22.4971C17.737 22.4944 17.6975 22.4898 17.6484 22.4834C17.5546 22.4711 17.4318 22.4514 17.2783 22.4258L12.123 20.9561L11.9854 20.917L11.8477 20.9561L6.71484 22.4355L6.69043 22.4434L6.66699 22.4521C6.64021 22.4629 6.60211 22.4717 6.54785 22.4717H6.24316C5.63483 22.4717 5.11477 22.2631 4.66016 21.8291C4.22037 21.4092 4.00813 20.8947 4.02344 20.248L4.13477 15.5029L4.13867 15.3408L4.0459 15.207L0.910156 10.6846C0.770004 10.4806 0.668646 10.2734 0.601562 10.0635C0.533314 9.8498 0.500011 9.64082 0.5 9.43457C0.5 8.99869 0.625355 8.58681 0.888672 8.18945C1.13917 7.81149 1.49253 7.53628 1.97168 7.36523L1.9707 7.36426L6.84668 5.72168L6.99023 5.67285L7.08301 5.55273L10.3291 1.33789L10.3359 1.32812C10.5481 1.0385 10.7969 0.836235 11.082 0.707031C11.3901 0.567552 11.6997 0.500029 12.0137 0.5Z"
fill="#F4C51D"
/>
</svg>
<span className="font-sans text-[10px] font-semibold text-[#F4C51D]">
{rating.toFixed(1)}
</span>
</div>
)}
</div>
<p className="truncate font-sans text-[10px] font-semibold text-[#73889D] text-start">
{advisor.slogan ?? ""}
</p>
</div>
</div>
{/* Contact Chips */}
{hasActiveContact && (
<div className="flex flex-wrap items-center gap-2 pt-3 px-4">
{hasChat && (
<div className="inline-flex items-center gap-1 rounded-[16px] bg-[#0EB13C]/10 px-2 py-1 text-[10px] font-semibold text-[#0EB13C]">
<svg
width="12"
height="12"
viewBox="0 0 14 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="size-3 shrink-0 text-[#0EB13C]"
>
<path
d="M7.01331 0.333252C10.8 0.333252 13.6666 3.43791 13.6666 6.9899C13.6666 11.1094 10.3066 13.6666 6.99998 13.6666C5.90665 13.6666 4.69331 13.3728 3.71998 12.7986C3.37998 12.5916 3.09331 12.4381 2.72665 12.5583L1.37998 12.9589C1.03998 13.0657 0.733313 12.7986 0.833313 12.4381L1.27998 10.9425C1.35331 10.7355 1.33998 10.5152 1.23331 10.3416C0.65998 9.28668 0.333313 8.13162 0.333313 7.00993C0.333313 3.498 3.13998 0.333252 7.01331 0.333252ZM10.06 6.16199C9.58665 6.16199 9.20665 6.54257 9.20665 7.01661C9.20665 7.48398 9.58665 7.87122 10.06 7.87122C10.5333 7.87122 10.9133 7.48398 10.9133 7.01661C10.9133 6.54257 10.5333 6.16199 10.06 6.16199ZM6.98665 6.16199C6.51998 6.15532 6.13331 6.54257 6.13331 7.00993C6.13331 7.48398 6.51331 7.86455 6.98665 7.87122C7.45998 7.87122 7.83998 7.48398 7.83998 7.01661C7.83998 6.54257 7.45998 6.16199 6.98665 6.16199ZM3.91331 6.16199C3.43998 6.16199 3.05998 6.54257 3.05998 7.01661C3.05998 7.48398 3.44665 7.87122 3.91331 7.87122C4.38665 7.86455 4.76665 7.48398 4.76665 7.01661C4.76665 6.54257 4.38665 6.16199 3.91331 6.16199Z"
fill="currentColor"
/>
</svg>
<span>{t["Text Message"] || "Text Message"}</span>
</div>
)}
{hasVoice && (
<div className="inline-flex items-center gap-1 rounded-[16px] bg-[#0EB13C]/10 px-2 py-1 text-[10px] font-semibold text-[#0EB13C]">
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="size-3 shrink-0 text-[#0EB13C]"
>
<path
d="M11.5643 0.576816L9.12681 0.0143159C8.86196 -0.0466215 8.59009 0.0916597 8.48228 0.340097L7.35728 2.9651C7.25884 3.19478 7.32446 3.46432 7.51899 3.62135L8.93931 4.78385C8.09556 6.5815 6.62134 8.07682 4.78618 8.93697L3.62368 7.51666C3.46431 7.32213 3.19712 7.2565 2.96743 7.35494L0.342432 8.47994C0.0916504 8.5901 -0.0466309 8.86197 0.0143066 9.12682L0.576807 11.5643C0.6354 11.8174 0.8604 12.0003 1.12524 12.0003C7.12759 12.0003 12.0002 7.13697 12.0002 1.12525C12.0002 0.862753 11.8198 0.63541 11.5643 0.576816Z"
fill="currentColor"
/>
</svg>
<span>{t["Voice Call"] || "Voice Call"}</span>
</div>
)}
{hasVideo && (
<div className="inline-flex items-center gap-1 rounded-[16px] bg-[#0EB13C]/10 px-2 py-1 text-[10px] font-semibold text-[#0EB13C]">
<svg
width="12"
height="12"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="size-3 shrink-0 text-[#0EB13C]"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M3.33398 3.33334C2.80355 3.33334 2.29484 3.54406 1.91977 3.91913C1.5447 4.2942 1.33398 4.80291 1.33398 5.33334V10.6667C1.33398 11.1971 1.5447 11.7058 1.91977 12.0809C2.29484 12.456 2.80355 12.6667 3.33398 12.6667H9.33398C9.86442 12.6667 10.3731 12.456 10.7482 12.0809C11.1233 11.7058 11.334 11.1971 11.334 10.6667V9.60934L12.9607 11.236C13.59 11.866 14.6673 11.4193 14.6673 10.5287V5.47134C14.6673 4.58068 13.59 4.13401 12.9607 4.76468L11.334 6.39001V5.33334C11.334 4.80291 11.1233 4.2942 10.7482 3.91913C10.3731 3.54406 9.86442 3.33334 9.33398 3.33334H3.33398Z"
fill="currentColor"
/>
</svg>
<span>{t["Video Call"] || "Video Call"}</span>
</div>
)}
</div>
)}
{/* Topics Badges */}
{topics.length > 0 && (
<>
<div className="my-2.5 border-t border-[#EBEBEB]" />
<div className="flex flex-wrap items-center gap-2 px-4 pb-1">
{topics.map((topic) => (
<span
key={topic}
className="inline-flex items-center rounded-[24px] bg-[#F4F4F8] px-2.5 py-1 font-sans text-[10px] font-semibold text-[#747474]"
>
{topic}
</span>
))}
</div>
</>
)}
</div>
</div>
{/* Floating Unread Badge at Top Corner */}
{unreadCount > 0 && (
<div className="absolute -top-2.5 end-0 z-10 flex items-center gap-1 rounded-[16px] bg-[#ECA533] px-2 py-0.5 text-white shadow-sm">
<span className="text-[10px] font-bold">{unreadCount}</span>
<span className="text-[8.5px] font-normal">
{t["New Message"] || "New Message"}
</span>
</div>
)}
</div>
);
}
function AdvisorsSkeleton() {
return (
<>
{[0, 1, 2].map((index) => (
<div
key={index}
className="w-full max-w-[343px] animate-pulse rounded-[15px] border border-[#EBEBEB] bg-white py-3 shadow-[0_4px_16px_rgba(0,0,0,0.04)]"
>
<div className="flex items-center gap-3 px-3">
<div className="size-12 shrink-0 rounded-full bg-[#F0F0F0]" />
<div className="min-w-0 flex-1 flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<div className="h-4 w-32 rounded bg-[#F0F0F0]" />
<div className="h-3 w-8 rounded bg-[#F0F0F0]" />
</div>
<div className="h-3 w-44 rounded bg-[#F0F0F0]" />
</div>
</div>
<div className="flex gap-2 pt-3 px-4">
<div className="h-5 w-20 rounded-[16px] bg-[#F0F0F0]" />
<div className="h-5 w-20 rounded-[16px] bg-[#F0F0F0]" />
</div>
<div className="my-2.5 border-t border-[#F0F0F0]" />
<div className="flex gap-2 px-4 pb-1">
<div className="h-5 w-16 rounded-[24px] bg-[#F0F0F0]" />
<div className="h-5 w-20 rounded-[24px] bg-[#F0F0F0]" />
<div className="h-5 w-14 rounded-[24px] bg-[#F0F0F0]" />
</div>
</div>
))}
</>
);
export default function MarriageAdvisorsPage() {
return <MarriageAdvisorsClient />;
} }

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

@ -1,752 +1,5 @@
"use client";
import NewMatchProfileClient from "@/components/Componentes/new-match-profile-client";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import Button from "@/components/Componentes/button";
import DismissReasonSheet from "@/components/Componentes/dismiss-reason-sheet";
import FemaleConsentSheet from "@/components/Componentes/female-consent-sheet";
import InformationSheet from "@/components/Componentes/information-sheet";
import { LoadingSkeleton } from "@/components/Componentes/loading-skeleton";
import { LoadingThreeDot } from "@/components/Componentes/loading-three-dot";
import NavigationButton from "@/components/Componentes/navigation-button";
import { PageBackground } from "@/components/Componentes/page-background";
import StickyHeader from "@/components/Componentes/sticky-header";
import type {
MarriageCaseStatus,
MarriageField,
MarriageFieldValue,
MarriageGender,
MarriagePhoneFieldValue,
MarriageProfileResponse,
} from "@/hooks/marriage/types";
import { useRespondToMarriageCaseMutation } from "@/hooks/marriage/use-case-respond";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import { getSubmitPath } from "@/lib/get-submit-path";
import { markMatchStarted } from "@/lib/match-start-grace";
import { localizePath } from "@/translations/config";
import { useI18n } from "@/translations/provider";
import {
formatFieldLabel,
formatFieldValue,
formatOptionValue,
getOrderedSummaryFields,
isMarriagePhoneFieldValue,
titleFromKey,
} from "@/lib/marriage-field-formatter";
function isImageField(field: MarriageField) {
return /(avatar|image|photo|picture|portrait|upload)/i.test(
`${field.key} ${field.label}`,
);
}
function isFirstNameField(field: MarriageField) {
const key = (field.key || "").toLowerCase();
return (
key === "first_name" ||
key === "personal_identity.first_name" ||
key.endsWith(".first_name") ||
key.endsWith("_first_name") ||
key === "full_name" ||
key === "fullname" ||
key === "q1_full_name" ||
key.endsWith(".full_name") ||
key.endsWith("_full_name")
);
}
function canAcceptProfile(
gender: MarriageGender | null | undefined,
status: MarriageCaseStatus | null | undefined,
) {
if (!gender || !status) {
return false;
}
if (gender === "female") {
return status === "introduced" || status === "male_accepted";
}
return status === "introduced";
}
function MatchField({
field,
dictionary,
}: {
field: MarriageField;
dictionary?: Record<string, string>;
}) {
const value = formatOptionValue(field.value, dictionary);
if (!value || isImageField(field)) {
return null;
}
const label = formatFieldLabel(field, dictionary);
return (
<div className="flex flex-col items-start w-full border-b border-black/10 pb-[14px] mt-[14px]">
<p className="text-[12px] font-semibold leading-[17px] text-[#978787]">
{label}
</p>
<p className="text-[16px] font-bold leading-[20px] text-[#111111] mt-[7px] text-left">
{value}
</p>
</div>
);
}
function MatchPublicProfileFields({
publicInfo,
dictionary,
}: {
publicInfo: MarriageField[] | null | undefined;
dictionary?: Record<string, string>;
}) {
const visibleFields = useMemo(() => {
if (!publicInfo) return [];
return publicInfo.filter((field) => {
if (
field.value === null ||
field.value === "" ||
isImageField(field) ||
isFirstNameField(field)
) {
return false;
}
if ((field as any).private === true) {
return false;
}
return true;
});
}, [publicInfo]);
if (!visibleFields.length) {
return (
<div className="mt-6 rounded-[16px] bg-white/70 p-5 text-center shadow-xs">
<p className="group-12 font-medium text-[#747474]">
{dictionary?.["No public information is available to display."] ||
"No public information is available to display."}
</p>
</div>
);
}
return (
<div className="flex flex-col w-full mt-6" style={{ gap: "14px" }}>
{visibleFields.map((field) => (
<MatchField
key={field.key}
field={field}
dictionary={dictionary}
/>
))}
</div>
);
}
function NewMatchProfileSkeleton({
hideBackButton = false,
onClose,
}: {
hideBackButton?: boolean;
onClose?: () => void;
}) {
const { dictionary: t } = useI18n();
return (
<>
<PageBackground />
<main className="-mx-[17px] flex h-dvh flex-col overflow-hidden bg-[#F5F5F5]">
<StickyHeader sticky className="shrink-0">
<div className="flex items-center justify-between gap-3">
{!hideBackButton ? (
<NavigationButton
variant="transparent"
icon="back"
iconLabel={t["Go back"]}
onClick={onClose}
/>
) : (
<div className="size-10 shrink-0" />
)}
<h1
className="min-w-0 flex-1 text-center font-semibold text-[14px] leading-[16px] text-white"
>
{t["More detail"]}
</h1>
<div className="size-10 shrink-0" />
</div>
</StickyHeader>
<section className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-[17px] pb-6 pt-5">
<div className="flex flex-col items-center justify-center w-full">
<LoadingSkeleton className="size-[90px] rounded-full shadow-sm" />
<LoadingSkeleton className="mt-3.5 h-7 w-44 rounded-xl shadow-xs" />
</div>
<div className="mt-6 space-y-4 rounded-[18px] bg-white/80 p-5 shadow-xs">
<LoadingSkeleton className="h-4 w-44 rounded-md" />
<div className="space-y-4 pt-1">
<div className="space-y-1.5 border-b border-[#000000]/05 pb-3">
<LoadingSkeleton className="h-3 w-24" />
<LoadingSkeleton className="h-4 w-48" />
</div>
<div className="space-y-1.5 border-b border-[#000000]/05 pb-3">
<LoadingSkeleton className="h-3 w-28" />
<LoadingSkeleton className="h-4 w-36" />
</div>
<div className="space-y-1.5 border-b border-[#000000]/05 pb-3">
<LoadingSkeleton className="h-3 w-20" />
<LoadingSkeleton className="h-4 w-56" />
</div>
<div className="space-y-1.5 border-b border-[#000000]/05 pb-3">
<LoadingSkeleton className="h-3 w-32" />
<LoadingSkeleton className="h-4 w-40" />
</div>
</div>
</div>
</section>
<div
style={{ paddingBottom: `calc(16px + var(--safe-bottom, 0px))` }}
className="shrink-0 z-30 w-full rounded-t-[24px] bg-white px-[17px] pt-4 shadow-[0_-4px_24px_rgba(0,0,0,0.08)]"
>
<div className="flex gap-3">
<LoadingSkeleton className="w-1/3 h-[52px] rounded-[12px]" />
<LoadingSkeleton className="w-2/3 h-[52px] rounded-[12px]" />
</div>
</div>
</main>
</>
);
}
function formatBoldText(text: string) {
if (!text) return "";
const parts = text.split(/\*\*([^*]+)\*\*/g);
return parts.map((part, index) => {
if (index % 2 === 1) {
return (
<strong key={index} className="font-bold text-[#111111]">
{part}
</strong>
);
}
return part;
});
}
type NewMatchProfilePageProps = {
onClose?: () => void;
profile?: MarriageProfileResponse;
};
export default function NewMatchProfilePage({
onClose,
profile: profileProp,
}: NewMatchProfilePageProps = {}) {
const { dictionary: t, locale } = useI18n();
const router = useRouter();
const [isRequestSheetOpen, setIsRequestSheetOpen] = useState(false);
const [isFemaleConsentChecked, setIsFemaleConsentChecked] = useState(false);
const [isDeclineSheetOpen, setIsDeclineSheetOpen] = useState(false);
const [isMaleDeclineWarningOpen, setIsMaleDeclineWarningOpen] =
useState(false);
const [isDismissReasonSheetOpen, setIsDismissReasonSheetOpen] =
useState(false);
const {
data: queriedProfile,
isLoading: isQueryLoading,
refetch: refetchProfile,
} = useMarriageProfileQuery({
enabled: !profileProp,
});
const profile = profileProp ?? queriedProfile;
const isLoading = !profileProp && isQueryLoading;
useEffect(() => {
// If rendered as an overlay (onClose is passed), NEVER redirect away from current page
if (!profile || Boolean(onClose)) {
return;
}
const targetPath = getSubmitPath(profile);
const caseStatus = profile?.active_case?.status;
const isProfileMatched = profile?.status === "matched";
const isViewingAllowed =
caseStatus === "female_accepted" ||
caseStatus === "payment_pending" ||
caseStatus === "payment_done" ||
caseStatus === "contacted" ||
caseStatus === "finalized" ||
isProfileMatched;
if (
targetPath !== "/new-match" &&
targetPath !== "/request-sent" &&
targetPath !== "/request-accepted" &&
!isViewingAllowed
) {
router.replace(localizePath(targetPath, locale));
}
}, [profile, locale, router, onClose]);
const caseId = profile?.active_case?.case_id;
const caseStatus = profile?.active_case?.status;
const isFemaleProfile = profile?.gender === "female";
const respondMutation = useRespondToMarriageCaseMutation(caseId ?? "", {
onSuccess: async (data, variables) => {
onClose?.();
if (variables.action === "accept") {
const nextPath =
profile?.gender === "female"
? "/request-accepted"
: "/request-sent";
router.replace(localizePath(nextPath, locale));
return;
}
router.replace(localizePath("/finding-match", locale));
},
});
const candidateName = useMemo(() => {
const publicInfo = profile?.match_summary?.public_info ?? [];
const firstNameIdx = publicInfo.findIndex(
(f) =>
f.key === "personal_identity.first_name" ||
f.key?.endsWith(".first_name") ||
f.key === "first_name",
);
const lastNameIdx = publicInfo.findIndex(
(f) =>
f.key === "personal_identity.last_name" ||
f.key?.endsWith(".last_name") ||
f.key === "last_name",
);
if (firstNameIdx !== -1 && publicInfo[firstNameIdx].value) {
const firstName = formatFieldValue(publicInfo[firstNameIdx].value);
if (lastNameIdx !== -1 && publicInfo[lastNameIdx].value) {
const lastName = formatFieldValue(publicInfo[lastNameIdx].value);
return `${firstName} ${lastName}`.trim();
}
return firstName || "";
}
const nameField = publicInfo.find(
(f) =>
f.key === "q1_full_name" ||
f.key.endsWith(".full_name") ||
f.key.toLowerCase().includes("fullname") ||
f.key.toLowerCase().includes("display_name"),
);
const formatted = formatFieldValue(nameField?.value ?? null);
if (formatted) return formatted;
const firstVal = publicInfo.find((f) => Boolean(f.value))?.value ?? null;
return (
formatFieldValue(firstVal) ||
(profile?.match_summary?.id
? `Profile #${profile.match_summary.id}`
: "Profile")
);
}, [profile?.match_summary]);
const isCandidateFemale =
profile?.match_summary?.gender === "female" || profile?.gender === "male";
const avatarSrc = isCandidateFemale
? "/assets/images/female_avatar.svg"
: "/assets/images/Group 1597880481.png";
const isRedirecting = useMemo(() => {
if (!profile) return false;
const targetPath = getSubmitPath(profile);
const caseStatus = profile?.active_case?.status;
const isProfileMatched = profile?.status === "matched";
const isViewingAllowed =
caseStatus === "payment_done" ||
caseStatus === "contacted" ||
caseStatus === "finalized" ||
isProfileMatched;
return (
targetPath !== "/new-match" &&
targetPath !== "/request-sent" &&
!isViewingAllowed
);
}, [profile]);
const isSubmitting = respondMutation.isPending;
if ((isLoading && !profile) || !profile) {
return (
<NewMatchProfileSkeleton
hideBackButton={isSubmitting}
onClose={onClose}
/>
);
}
const isAcceptProfileEnabled =
Boolean(caseId) &&
!isSubmitting &&
canAcceptProfile(profile?.gender, caseStatus);
const isDeclineProfileEnabled = isAcceptProfileEnabled;
const nameParts = candidateName.trim().split(/\s+/);
const _firstName = nameParts[0] || "";
const _lastName = nameParts.slice(1).join(" ") || "";
const mainClass = "-mx-[17px] flex h-dvh flex-col overflow-hidden";
const mainStyle = {
backgroundImage: `url("/assets/images/islamic_pattern_2_2892_3864.svg")`,
backgroundColor: "#F5F5F5",
backgroundRepeat: "repeat-y",
backgroundSize: "100% auto",
};
return (
<>
<PageBackground />
{isRequestSheetOpen ? (
isFemaleProfile ? (
<FemaleConsentSheet
title={t["Final Consent"]}
description={formatBoldText(
t[
"Approving this profile will **display your contact number** to the gentleman. Ensure **family consensus** before proceeding."
],
)}
buttons={({ close }) => (
<div className="space-y-5">
<button
type="button"
onClick={() => setIsFemaleConsentChecked((value) => !value)}
className="flex w-full items-start gap-4 rounded-[15px] border-2 border-white bg-white/50 px-5 py-5 text-left shadow-[0_8px_24px_rgba(0,0,0,0.05)]"
>
<span
className={[
"mt-1 flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-full border-1",
isFemaleConsentChecked
? "border-[#F0445B] bg-[#F0445B]"
: "border-[#2B2B2B] bg-white",
].join(" ")}
aria-hidden="true"
>
{isFemaleConsentChecked ? (
<span className="h-3 w-3 rounded-full bg-[#F0445B]" />
) : null}
</span>
<span className="text-xs leading-[1.35] text-[#3F3F3F]">
{formatBoldText(
t[
"I confirm the family has **reviewed this profile** and **consents to communicate**."
],
)}
</span>
</button>
<div className="grid w-full grid-cols-2 gap-3">
<Button
variant="outlined"
className="py-[18px] text-[18px]"
onClick={close}
>
{t.Cancel}
</Button>
<Button
className="py-[18px] text-[18px]"
disabled={
!caseId ||
isSubmitting ||
!isAcceptProfileEnabled ||
!isFemaleConsentChecked
}
onClick={async () => {
close();
if (!isAcceptProfileEnabled) {
return;
}
markMatchStarted();
await respondMutation.mutateAsync({ action: "accept" });
}}
>
{t.Confirm}
</Button>
</div>
</div>
)}
onClose={() => {
setIsFemaleConsentChecked(false);
setIsRequestSheetOpen(false);
}}
/>
) : (
<InformationSheet
icon="check"
title={t["Request to Proceed"]}
description={
t[
"Upon your approval, your request will be sent to the lady, and we must await her response. This process may take between 2 to 4 days. Thank you for your patience."
]
}
buttons={({ close }) => (
<div className="grid w-full grid-cols-2 gap-3">
<Button
variant="outlined"
className="py-[18px]"
onClick={close}
>
{t.Cancel}
</Button>
<Button
className="py-[18px]"
disabled={
!isAcceptProfileEnabled || respondMutation.isPending
}
isLoading={respondMutation.isPending}
onClick={async () => {
close();
if (!isAcceptProfileEnabled) {
return;
}
markMatchStarted();
await respondMutation.mutateAsync({ action: "accept" });
}}
>
{t.Confirm}
</Button>
</div>
)}
onClose={() => setIsRequestSheetOpen(false)}
/>
)
) : null}
{isDeclineSheetOpen ? (
<InformationSheet
icon="warning"
title={t["Decline Profile"] || t["Reject Profile"]}
description={
t[
"Are you sure you've fully reviewed the profile and want to decline this profile?"
] ||
t[
"Are you sure you've fully reviewed the profile and want to reject this profile?"
]
}
buttons={({ close }) => (
<div className="grid w-full grid-cols-2 gap-3">
<Button variant="outlined" className="py-[18px]" onClick={close}>
{t.Cancel}
</Button>
<Button
onClick={() => {
close();
setIsDismissReasonSheetOpen(true);
}}
>
{t.Decline || t["Decline"] || t.Reject}
</Button>
</div>
)}
onClose={() => setIsDeclineSheetOpen(false)}
/>
) : null}
{isMaleDeclineWarningOpen ? (
<InformationSheet
icon="warning"
title={t["Decline Warning"] || t["Rejection Warning"]}
description={
t[
"Please review the person’s full profile once more before making your final decision."
]
}
buttons={({ close }) => (
<div className="grid w-full grid-cols-2 gap-3">
<Button variant="outlined" className="py-[18px]" onClick={close}>
{t.Cancel}
</Button>
<Button
className="py-[18px]"
onClick={() => {
close();
setIsDismissReasonSheetOpen(true);
}}
>
{t.Decline || t["Decline"] || t.Reject}
</Button>
</div>
)}
onClose={() => setIsMaleDeclineWarningOpen(false)}
/>
) : null}
{isDismissReasonSheetOpen ? (
<DismissReasonSheet
onClose={() => setIsDismissReasonSheetOpen(false)}
onSubmit={async (reason) => {
if (!caseId) {
return;
}
await respondMutation.mutateAsync({
action: "reject",
custom_note: reason,
});
}}
/>
) : null}
<main className={mainClass} style={mainStyle}>
<StickyHeader sticky className="shrink-0">
<div className="flex items-center justify-between gap-3">
<NavigationButton
variant="transparent"
icon="back"
iconLabel={t["Go back"]}
onClick={onClose}
/>
<h1
className="min-w-0 flex-1 text-center font-semibold text-[14px] leading-[16px] text-white"
>
{t["More detail"]}
</h1>
<div className="size-10 shrink-0" />
</div>
</StickyHeader>
<section className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-[17px] pb-6 pt-5">
<div
className="mx-auto flex flex-col items-center justify-center"
style={{
width: "343px",
height: isCandidateFemale ? "124px" : "125px",
gap: "10px",
}}
>
{/* Frame 2095586585 - Avatar Section */}
<div
className="flex flex-col items-center justify-center w-full"
style={{
height: "125px",
gap: "8px",
}}
>
{/* Avatar Group */}
<div className="relative overflow-hidden rounded-full w-[90px] h-[90px]">
<Image
src={avatarSrc}
alt=""
width={90}
height={90}
className="w-full h-full object-cover"
/>
</div>
{/* Frame 2095586663 - Names Section */}
<div
className="flex flex-row items-center justify-center w-full animate-fade-in"
style={{
height: "26px",
}}
>
<span
className="font-bold text-[22px] leading-[26px] text-[#F0445B] text-center"
style={{
WebkitTextStroke: "2px rgba(255, 255, 255, 0.5)",
paintOrder: "stroke fill",
}}
>
{candidateName}
</span>
</div>
</div>
</div>
<MatchPublicProfileFields
publicInfo={profile?.match_summary?.public_info}
dictionary={t}
/>
</section>
<div
style={{ paddingBottom: `calc(16px + var(--safe-bottom, 0px))` }}
className="shrink-0 z-30 w-full rounded-t-[24px] bg-white px-[17px] pt-4 shadow-[0_-4px_24px_rgba(0,0,0,0.08)]"
>
{(Boolean(onClose) && !isAcceptProfileEnabled) ||
caseStatus === "female_accepted" ||
caseStatus === "payment_pending" ||
caseStatus === "payment_done" ||
caseStatus === "contacted" ||
caseStatus === "finalized" ||
profile?.status === "matched" ? (
<button
type="button"
onClick={() => (onClose ? onClose() : router.back())}
className="w-full h-[52px] flex items-center justify-center rounded-[12px] bg-[#F5F5F5] text-[#36363C] font-semibold text-[16px] transition-transform active:scale-[0.98] cursor-pointer hover:bg-[#EBEBEB]"
>
{t.Back}
</button>
) : (
<div className="flex gap-3">
<button
type="button"
disabled={!isDeclineProfileEnabled || isSubmitting}
onClick={() => {
if (isFemaleProfile) {
setIsDeclineSheetOpen(true);
} else {
setIsMaleDeclineWarningOpen(true);
}
}}
className="inline-flex w-1/3 h-[52px] items-center justify-center rounded-[12px] border border-[#BFBFBF] bg-white px-4 text-[16px] font-semibold text-[#9A9A9A] disabled:cursor-not-allowed disabled:opacity-50"
>
{t.Decline || t["Decline"] || t.Reject}
</button>
<button
type="button"
disabled={!isAcceptProfileEnabled || isSubmitting}
onClick={() => {
if (!isAcceptProfileEnabled) {
return;
}
if (isFemaleProfile) {
setIsFemaleConsentChecked(false);
}
setIsRequestSheetOpen(true);
}}
className="inline-flex w-2/3 h-[52px] whitespace-nowrap items-center justify-center gap-1 rounded-[12px] bg-[#F0445B] px-4 text-[16px] font-semibold text-white shadow-[0_8px_16px_rgba(240,68,91,0.24)] disabled:cursor-not-allowed disabled:opacity-50"
>
{isSubmitting ? (
<LoadingThreeDot />
) : (
<>
<Image
src="/assets/images/Icfdason.svg"
alt=""
width={28}
height={28}
/>
<span>{t["Accept Profile"]}</span>
</>
)}
</button>
</div>
)}
</div>
</main>
</>
);
export default function NewMatchProfilePage() {
return <NewMatchProfileClient />;
} }

373
src/components/Componentes/marriage-advisors-client.tsx

@ -0,0 +1,373 @@
"use client";
import { useState } from "react";
import Image from "next/image";
import { Ic } from "@/icons";
import StickyHeader from "@/components/Componentes/sticky-header";
import NavigationButton from "@/components/Componentes/navigation-button";
import { PageBackground } from "@/components/Componentes/page-background";
import SupportSheet from "@/components/Componentes/support-sheet";
import { useI18n } from "@/translations/provider";
import {
useMarriageAdvisorsQuery,
type MarriageConsultant,
} from "@/hooks/marriage/use-marriage-advisors";
import { postActionToFlutter } from "@/lib/webview-actions";
const FALLBACK_AVATAR = "/assets/images/Avatar Image.png";
type MarriageAdvisorsClientProps = {
onClose?: () => void;
};
export default function MarriageAdvisorsClient({
onClose,
}: MarriageAdvisorsClientProps = {}) {
const { dictionary: t } = useI18n();
const [isSupportOpen, setIsSupportOpen] = useState(false);
const { data, isLoading, isError, refetch } = useMarriageAdvisorsQuery();
const advisors = data?.results ?? [];
const mainStyle = {
backgroundImage: `url("/assets/images/islamic_pattern_2_2892_3864.svg")`,
backgroundColor: "#F5F5F5",
backgroundRepeat: "repeat-y",
backgroundSize: "100% auto",
};
// Opens the native Talk consultant page; the Flutter side fetches the
// consultant by username, so only the bare username is needed. Outside the
// WebView (plain browser) postActionToFlutter is a no-op returning false.
const openConsultantPage = (username: string) => {
postActionToFlutter("open_consultant_page", { consultant: username });
};
return (
<>
<PageBackground />
<main
className="-mx-[17px] flex h-dvh flex-col overflow-hidden pb-10"
style={mainStyle}
>
<StickyHeader sticky className="shrink-0">
<div className="flex items-center justify-between gap-3">
<NavigationButton
variant="transparent"
icon="back"
iconLabel={t["Go back"]}
onClick={onClose}
/>
<h1
className="min-w-0 flex-1 text-center font-semibold text-[14px] leading-[16px] text-white"
>
{t["Marriage advisors"]}
</h1>
<div className="size-10 shrink-0" />
</div>
</StickyHeader>
<section className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-[17px] pb-32 pt-5 flex flex-col items-center gap-4">
{isLoading && <AdvisorsSkeleton />}
{isError && (
<div className="flex w-[343px] flex-col items-center gap-3 rounded-[15px] border border-[#EBEBEB] bg-white px-4 py-8 text-center shadow-[0px_4px_12px_rgba(0,0,0,0.05)]">
<p
className="text-[14px] leading-[20px] text-[#747474]"
>
{t["Failed to load advisors."]}
</p>
<button
type="button"
onClick={() => refetch()}
className="cursor-pointer rounded-[20px] border-none bg-[rgba(14,177,60,0.1)] px-4 py-1.5 text-[12px] font-semibold text-[#0EB13C] transition-transform active:scale-95"
>
{t["Try again"]}
</button>
</div>
)}
{!isLoading && !isError && advisors.length === 0 && (
<div className="flex w-[343px] flex-col items-center rounded-[15px] border border-[#EBEBEB] bg-white px-4 py-8 text-center shadow-[0px_4px_12px_rgba(0,0,0,0.05)]">
<p
className="text-[14px] leading-[20px] text-[#747474]"
>
{t["No advisors are currently available."]}
</p>
</div>
)}
{advisors.map((advisor) => (
<AdvisorCard
key={advisor.username}
advisor={advisor}
onOpen={() => openConsultantPage(advisor.username)}
/>
))}
</section>
</main>
<SupportSheet
isOpen={isSupportOpen}
onClose={() => setIsSupportOpen(false)}
/>
</>
);
}
function AdvisorCard({
advisor,
onOpen,
}: {
advisor: MarriageConsultant;
onOpen: () => void;
}) {
const { dictionary: t, locale } = useI18n();
const isRtl = locale === "fa" || locale === "ar" || locale === "ur";
const unreadCount = advisor.unread_count ?? 0;
const isOnline = advisor.status === "online";
const rating = Number(advisor.avg_rate ?? 0);
const hasChat = advisor.contact_type?.includes("chat");
const hasVoice = advisor.contact_type?.includes("voice");
const hasVideo = advisor.contact_type?.includes("video");
const hasActiveContact = hasChat || hasVoice || hasVideo;
const topics = advisor.topics?.filter(Boolean) ?? [];
return (
<div
role="button"
tabIndex={0}
aria-label={advisor.fullname ?? advisor.username}
onClick={onOpen}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onOpen();
}
}}
className="relative w-full max-w-[343px] cursor-pointer transition-transform active:scale-[0.98] outline-none"
>
{/* Outer Card Container */}
<div
className={`w-full rounded-[15px] bg-white py-3 shadow-[0_4px_16px_rgba(0,0,0,0.06)] transition-all ${
unreadCount > 0
? "border border-[#ECA533]"
: "border border-[#EBEBEB]"
}`}
>
<div className="flex flex-col">
{/* Header Row: Avatar + Info + Rating */}
<div className="flex items-center gap-3 px-3">
{/* Avatar with Status Dot */}
<div className="relative size-12 shrink-0">
{advisor.avatar_url ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={advisor.avatar_url}
alt={advisor.fullname ?? advisor.username}
className="size-full rounded-full object-cover bg-[#EBEBEB]"
onError={(event) => {
event.currentTarget.src = FALLBACK_AVATAR;
}}
/>
) : (
<div className="size-full rounded-full bg-[#EBEBEB] flex items-center justify-center">
<svg
width="24"
height="24"
viewBox="0 0 33 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="text-[#8B8B8B]"
>
<path
d="M16.5 17.3333C15.4802 17.3333 14.4833 17.0401 13.6353 16.4907C12.7874 15.9413 12.1265 15.1604 11.7362 14.2467C11.346 13.3331 11.2439 12.3278 11.4428 11.3579C11.6418 10.388 12.1329 9.49706 12.854 8.7978C13.5751 8.09853 14.4939 7.62233 15.4941 7.4294C16.4943 7.23648 17.531 7.33549 18.4732 7.71393C19.4154 8.09237 20.2207 8.73323 20.7873 9.55548C21.3538 10.3777 21.6562 11.3444 21.6562 12.3333C21.6562 13.6594 21.113 14.9312 20.146 15.8689C19.179 16.8065 17.8675 17.3333 16.5 17.3333ZM16.5 9.33333C15.8881 9.33333 15.29 9.50928 14.7812 9.83892C14.2724 10.1686 13.8759 10.6371 13.6417 11.1853C13.4076 11.7335 13.3463 12.3367 13.4657 12.9186C13.5851 13.5005 13.8797 14.0351 14.3124 14.4547C14.7451 14.8742 15.2963 15.1599 15.8964 15.2757C16.4966 15.3914 17.1186 15.332 17.6839 15.105C18.2492 14.8779 18.7324 14.4934 19.0724 14C19.4123 13.5067 19.5938 12.9267 19.5938 12.3333C19.5938 11.5377 19.2678 10.7746 18.6876 10.212C18.1074 9.6494 17.3205 9.33333 16.5 9.33333Z"
fill="currentColor"
/>
</svg>
</div>
)}
{/* Status indicator dot badge (top-start) */}
<div className="absolute top-0 -left-0.5 rtl:-right-0.5 rtl:left-auto flex size-3 items-center justify-center rounded-full bg-white p-0.5">
<div
className={`flex size-2.5 items-center justify-center rounded-full ${
isOnline ? "bg-[#0EB13C]/20" : "bg-white"
}`}
>
<div
className={`size-1.5 rounded-full ${
isOnline ? "bg-[#0EB13C]" : "bg-[#747474]"
}`}
/>
</div>
</div>
</div>
{/* Consultant Info */}
<div className="min-w-0 flex-1 flex flex-col justify-center gap-1">
<div className="flex items-center gap-2">
{advisor.is_ai && (
<span className="inline-flex size-4 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-[#F45EA0] to-[#704EE9] text-[9px] font-bold text-white leading-none">
AI
</span>
)}
<h3 className="truncate font-sans text-[15px] font-bold text-[#151C31] leading-tight text-start">
{advisor.fullname ?? advisor.username}
</h3>
{rating > 0 && (
<div className="ms-auto flex shrink-0 items-center gap-1">
<svg
width="12"
height="12"
viewBox="0 0 24 23"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="size-3 text-[#F4C51D]"
>
<path
d="M12.0137 0.5C12.3276 0.5 12.6304 0.567504 12.9268 0.705078C13.2033 0.83346 13.4498 1.03558 13.6641 1.32812L13.6699 1.33594L16.8877 5.55078L16.9814 5.67285L17.127 5.72168L22.0283 7.36426V7.36523C22.5075 7.53628 22.8608 7.81149 23.1113 8.18945C23.3746 8.58681 23.5 8.99869 23.5 9.43457C23.5 9.64108 23.4672 9.85698 23.3975 10.083C23.3466 10.2477 23.2761 10.4069 23.1855 10.5615L23.0889 10.7139L19.9551 15.2051L19.8613 15.3398L19.8652 15.5029L19.9766 20.2646C19.9765 20.8763 19.7652 21.3933 19.3281 21.8408C18.8902 22.2891 18.3925 22.5 17.8125 22.5C17.8116 22.5 17.8078 22.4994 17.8008 22.499C17.7924 22.4985 17.7811 22.4984 17.7666 22.4971C17.737 22.4944 17.6975 22.4898 17.6484 22.4834C17.5546 22.4711 17.4318 22.4514 17.2783 22.4258L12.123 20.9561L11.9854 20.917L11.8477 20.9561L6.71484 22.4355L6.69043 22.4434L6.66699 22.4521C6.64021 22.4629 6.60211 22.4717 6.54785 22.4717H6.24316C5.63483 22.4717 5.11477 22.2631 4.66016 21.8291C4.22037 21.4092 4.00813 20.8947 4.02344 20.248L4.13477 15.5029L4.13867 15.3408L4.0459 15.207L0.910156 10.6846C0.770004 10.4806 0.668646 10.2734 0.601562 10.0635C0.533314 9.8498 0.500011 9.64082 0.5 9.43457C0.5 8.99869 0.625355 8.58681 0.888672 8.18945C1.13917 7.81149 1.49253 7.53628 1.97168 7.36523L1.9707 7.36426L6.84668 5.72168L6.99023 5.67285L7.08301 5.55273L10.3291 1.33789L10.3359 1.32812C10.5481 1.0385 10.7969 0.836235 11.082 0.707031C11.3901 0.567552 11.6997 0.500029 12.0137 0.5Z"
fill="#F4C51D"
/>
</svg>
<span className="font-sans text-[10px] font-semibold text-[#F4C51D]">
{rating.toFixed(1)}
</span>
</div>
)}
</div>
<p className="truncate font-sans text-[10px] font-semibold text-[#73889D] text-start">
{advisor.slogan ?? ""}
</p>
</div>
</div>
{/* Contact Chips */}
{hasActiveContact && (
<div className="flex flex-wrap items-center gap-2 pt-3 px-4">
{hasChat && (
<div className="inline-flex items-center gap-1 rounded-[16px] bg-[#0EB13C]/10 px-2 py-1 text-[10px] font-semibold text-[#0EB13C]">
<svg
width="12"
height="12"
viewBox="0 0 14 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="size-3 shrink-0 text-[#0EB13C]"
>
<path
d="M7.01331 0.333252C10.8 0.333252 13.6666 3.43791 13.6666 6.9899C13.6666 11.1094 10.3066 13.6666 6.99998 13.6666C5.90665 13.6666 4.69331 13.3728 3.71998 12.7986C3.37998 12.5916 3.09331 12.4381 2.72665 12.5583L1.37998 12.9589C1.03998 13.0657 0.733313 12.7986 0.833313 12.4381L1.27998 10.9425C1.35331 10.7355 1.33998 10.5152 1.23331 10.3416C0.65998 9.28668 0.333313 8.13162 0.333313 7.00993C0.333313 3.498 3.13998 0.333252 7.01331 0.333252ZM10.06 6.16199C9.58665 6.16199 9.20665 6.54257 9.20665 7.01661C9.20665 7.48398 9.58665 7.87122 10.06 7.87122C10.5333 7.87122 10.9133 7.48398 10.9133 7.01661C10.9133 6.54257 10.5333 6.16199 10.06 6.16199ZM6.98665 6.16199C6.51998 6.15532 6.13331 6.54257 6.13331 7.00993C6.13331 7.48398 6.51331 7.86455 6.98665 7.87122C7.45998 7.87122 7.83998 7.48398 7.83998 7.01661C7.83998 6.54257 7.45998 6.16199 6.98665 6.16199ZM3.91331 6.16199C3.43998 6.16199 3.05998 6.54257 3.05998 7.01661C3.05998 7.48398 3.44665 7.87122 3.91331 7.87122C4.38665 7.86455 4.76665 7.48398 4.76665 7.01661C4.76665 6.54257 4.38665 6.16199 3.91331 6.16199Z"
fill="currentColor"
/>
</svg>
<span>{t["Text Message"] || "Text Message"}</span>
</div>
)}
{hasVoice && (
<div className="inline-flex items-center gap-1 rounded-[16px] bg-[#0EB13C]/10 px-2 py-1 text-[10px] font-semibold text-[#0EB13C]">
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="size-3 shrink-0 text-[#0EB13C]"
>
<path
d="M11.5643 0.576816L9.12681 0.0143159C8.86196 -0.0466215 8.59009 0.0916597 8.48228 0.340097L7.35728 2.9651C7.25884 3.19478 7.32446 3.46432 7.51899 3.62135L8.93931 4.78385C8.09556 6.5815 6.62134 8.07682 4.78618 8.93697L3.62368 7.51666C3.46431 7.32213 3.19712 7.2565 2.96743 7.35494L0.342432 8.47994C0.0916504 8.5901 -0.0466309 8.86197 0.0143066 9.12682L0.576807 11.5643C0.6354 11.8174 0.8604 12.0003 1.12524 12.0003C7.12759 12.0003 12.0002 7.13697 12.0002 1.12525C12.0002 0.862753 11.8198 0.63541 11.5643 0.576816Z"
fill="currentColor"
/>
</svg>
<span>{t["Voice Call"] || "Voice Call"}</span>
</div>
)}
{hasVideo && (
<div className="inline-flex items-center gap-1 rounded-[16px] bg-[#0EB13C]/10 px-2 py-1 text-[10px] font-semibold text-[#0EB13C]">
<svg
width="12"
height="12"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="size-3 shrink-0 text-[#0EB13C]"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M3.33398 3.33334C2.80355 3.33334 2.29484 3.54406 1.91977 3.91913C1.5447 4.2942 1.33398 4.80291 1.33398 5.33334V10.6667C1.33398 11.1971 1.5447 11.7058 1.91977 12.0809C2.29484 12.456 2.80355 12.6667 3.33398 12.6667H9.33398C9.86442 12.6667 10.3731 12.456 10.7482 12.0809C11.1233 11.7058 11.334 11.1971 11.334 10.6667V9.60934L12.9607 11.236C13.59 11.866 14.6673 11.4193 14.6673 10.5287V5.47134C14.6673 4.58068 13.59 4.13401 12.9607 4.76468L11.334 6.39001V5.33334C11.334 4.80291 11.1233 4.2942 10.7482 3.91913C10.3731 3.54406 9.86442 3.33334 9.33398 3.33334H3.33398Z"
fill="currentColor"
/>
</svg>
<span>{t["Video Call"] || "Video Call"}</span>
</div>
)}
</div>
)}
{/* Topics Badges */}
{topics.length > 0 && (
<>
<div className="my-2.5 border-t border-[#EBEBEB]" />
<div className="flex flex-wrap items-center gap-2 px-4 pb-1">
{topics.map((topic) => (
<span
key={topic}
className="inline-flex items-center rounded-[24px] bg-[#F4F4F8] px-2.5 py-1 font-sans text-[10px] font-semibold text-[#747474]"
>
{topic}
</span>
))}
</div>
</>
)}
</div>
</div>
{/* Floating Unread Badge at Top Corner */}
{unreadCount > 0 && (
<div className="absolute -top-2.5 end-0 z-10 flex items-center gap-1 rounded-[16px] bg-[#ECA533] px-2 py-0.5 text-white shadow-sm">
<span className="text-[10px] font-bold">{unreadCount}</span>
<span className="text-[8.5px] font-normal">
{t["New Message"] || "New Message"}
</span>
</div>
)}
</div>
);
}
function AdvisorsSkeleton() {
return (
<>
{[0, 1, 2].map((index) => (
<div
key={index}
className="w-full max-w-[343px] animate-pulse rounded-[15px] border border-[#EBEBEB] bg-white py-3 shadow-[0_4px_16px_rgba(0,0,0,0.04)]"
>
<div className="flex items-center gap-3 px-3">
<div className="size-12 shrink-0 rounded-full bg-[#F0F0F0]" />
<div className="min-w-0 flex-1 flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<div className="h-4 w-32 rounded bg-[#F0F0F0]" />
<div className="h-3 w-8 rounded bg-[#F0F0F0]" />
</div>
<div className="h-3 w-44 rounded bg-[#F0F0F0]" />
</div>
</div>
<div className="flex gap-2 pt-3 px-4">
<div className="h-5 w-20 rounded-[16px] bg-[#F0F0F0]" />
<div className="h-5 w-20 rounded-[16px] bg-[#F0F0F0]" />
</div>
<div className="my-2.5 border-t border-[#F0F0F0]" />
<div className="flex gap-2 px-4 pb-1">
<div className="h-5 w-16 rounded-[24px] bg-[#F0F0F0]" />
<div className="h-5 w-20 rounded-[24px] bg-[#F0F0F0]" />
<div className="h-5 w-14 rounded-[24px] bg-[#F0F0F0]" />
</div>
</div>
))}
</>
);
}

4
src/components/Componentes/marriage-advisors-overlay.tsx

@ -1,7 +1,7 @@
"use client"; "use client";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import MarriageAdvisorsPage from "@/app/marriage-advisors/page";
import MarriageAdvisorsClient from "@/components/Componentes/marriage-advisors-client";
import SectionOverlayHost from "@/components/Componentes/section-overlay-host"; import SectionOverlayHost from "@/components/Componentes/section-overlay-host";
import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler"; import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler";
@ -69,7 +69,7 @@ export function MarriageAdvisorsOverlay({
}: MarriageAdvisorsOverlayProps) { }: MarriageAdvisorsOverlayProps) {
return ( return (
<SectionOverlayHost open={open} onClose={onClose}> <SectionOverlayHost open={open} onClose={onClose}>
<MarriageAdvisorsPage onClose={onClose} />
<MarriageAdvisorsClient onClose={onClose} />
</SectionOverlayHost> </SectionOverlayHost>
); );
} }

4
src/components/Componentes/match-profile-overlay.tsx

@ -1,7 +1,7 @@
"use client"; "use client";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import NewMatchProfilePage from "@/app/new-match/profile/page";
import NewMatchProfileClient from "@/components/Componentes/new-match-profile-client";
import SectionOverlayHost from "@/components/Componentes/section-overlay-host"; import SectionOverlayHost from "@/components/Componentes/section-overlay-host";
import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler"; import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler";
import type { MarriageProfileResponse } from "@/hooks/marriage/types"; import type { MarriageProfileResponse } from "@/hooks/marriage/types";
@ -72,7 +72,7 @@ export function MatchProfileOverlay({
}: MatchProfileOverlayProps) { }: MatchProfileOverlayProps) {
return ( return (
<SectionOverlayHost open={open} onClose={onClose}> <SectionOverlayHost open={open} onClose={onClose}>
<NewMatchProfilePage onClose={onClose} profile={profile} />
<NewMatchProfileClient onClose={onClose} profile={profile} />
</SectionOverlayHost> </SectionOverlayHost>
); );
} }

752
src/components/Componentes/new-match-profile-client.tsx

@ -0,0 +1,752 @@
"use client";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import Button from "@/components/Componentes/button";
import DismissReasonSheet from "@/components/Componentes/dismiss-reason-sheet";
import FemaleConsentSheet from "@/components/Componentes/female-consent-sheet";
import InformationSheet from "@/components/Componentes/information-sheet";
import { LoadingSkeleton } from "@/components/Componentes/loading-skeleton";
import { LoadingThreeDot } from "@/components/Componentes/loading-three-dot";
import NavigationButton from "@/components/Componentes/navigation-button";
import { PageBackground } from "@/components/Componentes/page-background";
import StickyHeader from "@/components/Componentes/sticky-header";
import type {
MarriageCaseStatus,
MarriageField,
MarriageFieldValue,
MarriageGender,
MarriagePhoneFieldValue,
MarriageProfileResponse,
} from "@/hooks/marriage/types";
import { useRespondToMarriageCaseMutation } from "@/hooks/marriage/use-case-respond";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import { getSubmitPath } from "@/lib/get-submit-path";
import { markMatchStarted } from "@/lib/match-start-grace";
import { localizePath } from "@/translations/config";
import { useI18n } from "@/translations/provider";
import {
formatFieldLabel,
formatFieldValue,
formatOptionValue,
getOrderedSummaryFields,
isMarriagePhoneFieldValue,
titleFromKey,
} from "@/lib/marriage-field-formatter";
function isImageField(field: MarriageField) {
return /(avatar|image|photo|picture|portrait|upload)/i.test(
`${field.key} ${field.label}`,
);
}
function isFirstNameField(field: MarriageField) {
const key = (field.key || "").toLowerCase();
return (
key === "first_name" ||
key === "personal_identity.first_name" ||
key.endsWith(".first_name") ||
key.endsWith("_first_name") ||
key === "full_name" ||
key === "fullname" ||
key === "q1_full_name" ||
key.endsWith(".full_name") ||
key.endsWith("_full_name")
);
}
function canAcceptProfile(
gender: MarriageGender | null | undefined,
status: MarriageCaseStatus | null | undefined,
) {
if (!gender || !status) {
return false;
}
if (gender === "female") {
return status === "introduced" || status === "male_accepted";
}
return status === "introduced";
}
function MatchField({
field,
dictionary,
}: {
field: MarriageField;
dictionary?: Record<string, string>;
}) {
const value = formatOptionValue(field.value, dictionary);
if (!value || isImageField(field)) {
return null;
}
const label = formatFieldLabel(field, dictionary);
return (
<div className="flex flex-col items-start w-full border-b border-black/10 pb-[14px] mt-[14px]">
<p className="text-[12px] font-semibold leading-[17px] text-[#978787]">
{label}
</p>
<p className="text-[16px] font-bold leading-[20px] text-[#111111] mt-[7px] text-left">
{value}
</p>
</div>
);
}
function MatchPublicProfileFields({
publicInfo,
dictionary,
}: {
publicInfo: MarriageField[] | null | undefined;
dictionary?: Record<string, string>;
}) {
const visibleFields = useMemo(() => {
if (!publicInfo) return [];
return publicInfo.filter((field) => {
if (
field.value === null ||
field.value === "" ||
isImageField(field) ||
isFirstNameField(field)
) {
return false;
}
if ((field as any).private === true) {
return false;
}
return true;
});
}, [publicInfo]);
if (!visibleFields.length) {
return (
<div className="mt-6 rounded-[16px] bg-white/70 p-5 text-center shadow-xs">
<p className="group-12 font-medium text-[#747474]">
{dictionary?.["No public information is available to display."] ||
"No public information is available to display."}
</p>
</div>
);
}
return (
<div className="flex flex-col w-full mt-6" style={{ gap: "14px" }}>
{visibleFields.map((field) => (
<MatchField
key={field.key}
field={field}
dictionary={dictionary}
/>
))}
</div>
);
}
function NewMatchProfileSkeleton({
hideBackButton = false,
onClose,
}: {
hideBackButton?: boolean;
onClose?: () => void;
}) {
const { dictionary: t } = useI18n();
return (
<>
<PageBackground />
<main className="-mx-[17px] flex h-dvh flex-col overflow-hidden bg-[#F5F5F5]">
<StickyHeader sticky className="shrink-0">
<div className="flex items-center justify-between gap-3">
{!hideBackButton ? (
<NavigationButton
variant="transparent"
icon="back"
iconLabel={t["Go back"]}
onClick={onClose}
/>
) : (
<div className="size-10 shrink-0" />
)}
<h1
className="min-w-0 flex-1 text-center font-semibold text-[14px] leading-[16px] text-white"
>
{t["More detail"]}
</h1>
<div className="size-10 shrink-0" />
</div>
</StickyHeader>
<section className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-[17px] pb-6 pt-5">
<div className="flex flex-col items-center justify-center w-full">
<LoadingSkeleton className="size-[90px] rounded-full shadow-sm" />
<LoadingSkeleton className="mt-3.5 h-7 w-44 rounded-xl shadow-xs" />
</div>
<div className="mt-6 space-y-4 rounded-[18px] bg-white/80 p-5 shadow-xs">
<LoadingSkeleton className="h-4 w-44 rounded-md" />
<div className="space-y-4 pt-1">
<div className="space-y-1.5 border-b border-[#000000]/05 pb-3">
<LoadingSkeleton className="h-3 w-24" />
<LoadingSkeleton className="h-4 w-48" />
</div>
<div className="space-y-1.5 border-b border-[#000000]/05 pb-3">
<LoadingSkeleton className="h-3 w-28" />
<LoadingSkeleton className="h-4 w-36" />
</div>
<div className="space-y-1.5 border-b border-[#000000]/05 pb-3">
<LoadingSkeleton className="h-3 w-20" />
<LoadingSkeleton className="h-4 w-56" />
</div>
<div className="space-y-1.5 border-b border-[#000000]/05 pb-3">
<LoadingSkeleton className="h-3 w-32" />
<LoadingSkeleton className="h-4 w-40" />
</div>
</div>
</div>
</section>
<div
style={{ paddingBottom: `calc(16px + var(--safe-bottom, 0px))` }}
className="shrink-0 z-30 w-full rounded-t-[24px] bg-white px-[17px] pt-4 shadow-[0_-4px_24px_rgba(0,0,0,0.08)]"
>
<div className="flex gap-3">
<LoadingSkeleton className="w-1/3 h-[52px] rounded-[12px]" />
<LoadingSkeleton className="w-2/3 h-[52px] rounded-[12px]" />
</div>
</div>
</main>
</>
);
}
function formatBoldText(text: string) {
if (!text) return "";
const parts = text.split(/\*\*([^*]+)\*\*/g);
return parts.map((part, index) => {
if (index % 2 === 1) {
return (
<strong key={index} className="font-bold text-[#111111]">
{part}
</strong>
);
}
return part;
});
}
type NewMatchProfileClientProps = {
onClose?: () => void;
profile?: MarriageProfileResponse;
};
export default function NewMatchProfileClient({
onClose,
profile: profileProp,
}: NewMatchProfileClientProps = {}) {
const { dictionary: t, locale } = useI18n();
const router = useRouter();
const [isRequestSheetOpen, setIsRequestSheetOpen] = useState(false);
const [isFemaleConsentChecked, setIsFemaleConsentChecked] = useState(false);
const [isDeclineSheetOpen, setIsDeclineSheetOpen] = useState(false);
const [isMaleDeclineWarningOpen, setIsMaleDeclineWarningOpen] =
useState(false);
const [isDismissReasonSheetOpen, setIsDismissReasonSheetOpen] =
useState(false);
const {
data: queriedProfile,
isLoading: isQueryLoading,
refetch: refetchProfile,
} = useMarriageProfileQuery({
enabled: !profileProp,
});
const profile = profileProp ?? queriedProfile;
const isLoading = !profileProp && isQueryLoading;
useEffect(() => {
// If rendered as an overlay (onClose is passed), NEVER redirect away from current page
if (!profile || Boolean(onClose)) {
return;
}
const targetPath = getSubmitPath(profile);
const caseStatus = profile?.active_case?.status;
const isProfileMatched = profile?.status === "matched";
const isViewingAllowed =
caseStatus === "female_accepted" ||
caseStatus === "payment_pending" ||
caseStatus === "payment_done" ||
caseStatus === "contacted" ||
caseStatus === "finalized" ||
isProfileMatched;
if (
targetPath !== "/new-match" &&
targetPath !== "/request-sent" &&
targetPath !== "/request-accepted" &&
!isViewingAllowed
) {
router.replace(localizePath(targetPath, locale));
}
}, [profile, locale, router, onClose]);
const caseId = profile?.active_case?.case_id;
const caseStatus = profile?.active_case?.status;
const isFemaleProfile = profile?.gender === "female";
const respondMutation = useRespondToMarriageCaseMutation(caseId ?? "", {
onSuccess: async (data, variables) => {
onClose?.();
if (variables.action === "accept") {
const nextPath =
profile?.gender === "female"
? "/request-accepted"
: "/request-sent";
router.replace(localizePath(nextPath, locale));
return;
}
router.replace(localizePath("/finding-match", locale));
},
});
const candidateName = useMemo(() => {
const publicInfo = profile?.match_summary?.public_info ?? [];
const firstNameIdx = publicInfo.findIndex(
(f) =>
f.key === "personal_identity.first_name" ||
f.key?.endsWith(".first_name") ||
f.key === "first_name",
);
const lastNameIdx = publicInfo.findIndex(
(f) =>
f.key === "personal_identity.last_name" ||
f.key?.endsWith(".last_name") ||
f.key === "last_name",
);
if (firstNameIdx !== -1 && publicInfo[firstNameIdx].value) {
const firstName = formatFieldValue(publicInfo[firstNameIdx].value);
if (lastNameIdx !== -1 && publicInfo[lastNameIdx].value) {
const lastName = formatFieldValue(publicInfo[lastNameIdx].value);
return `${firstName} ${lastName}`.trim();
}
return firstName || "";
}
const nameField = publicInfo.find(
(f) =>
f.key === "q1_full_name" ||
f.key.endsWith(".full_name") ||
f.key.toLowerCase().includes("fullname") ||
f.key.toLowerCase().includes("display_name"),
);
const formatted = formatFieldValue(nameField?.value ?? null);
if (formatted) return formatted;
const firstVal = publicInfo.find((f) => Boolean(f.value))?.value ?? null;
return (
formatFieldValue(firstVal) ||
(profile?.match_summary?.id
? `Profile #${profile.match_summary.id}`
: "Profile")
);
}, [profile?.match_summary]);
const isCandidateFemale =
profile?.match_summary?.gender === "female" || profile?.gender === "male";
const avatarSrc = isCandidateFemale
? "/assets/images/female_avatar.svg"
: "/assets/images/Group 1597880481.png";
const isRedirecting = useMemo(() => {
if (!profile) return false;
const targetPath = getSubmitPath(profile);
const caseStatus = profile?.active_case?.status;
const isProfileMatched = profile?.status === "matched";
const isViewingAllowed =
caseStatus === "payment_done" ||
caseStatus === "contacted" ||
caseStatus === "finalized" ||
isProfileMatched;
return (
targetPath !== "/new-match" &&
targetPath !== "/request-sent" &&
!isViewingAllowed
);
}, [profile]);
const isSubmitting = respondMutation.isPending;
if ((isLoading && !profile) || !profile) {
return (
<NewMatchProfileSkeleton
hideBackButton={isSubmitting}
onClose={onClose}
/>
);
}
const isAcceptProfileEnabled =
Boolean(caseId) &&
!isSubmitting &&
canAcceptProfile(profile?.gender, caseStatus);
const isDeclineProfileEnabled = isAcceptProfileEnabled;
const nameParts = candidateName.trim().split(/\s+/);
const _firstName = nameParts[0] || "";
const _lastName = nameParts.slice(1).join(" ") || "";
const mainClass = "-mx-[17px] flex h-dvh flex-col overflow-hidden";
const mainStyle = {
backgroundImage: `url("/assets/images/islamic_pattern_2_2892_3864.svg")`,
backgroundColor: "#F5F5F5",
backgroundRepeat: "repeat-y",
backgroundSize: "100% auto",
};
return (
<>
<PageBackground />
{isRequestSheetOpen ? (
isFemaleProfile ? (
<FemaleConsentSheet
title={t["Final Consent"]}
description={formatBoldText(
t[
"Approving this profile will **display your contact number** to the gentleman. Ensure **family consensus** before proceeding."
],
)}
buttons={({ close }) => (
<div className="space-y-5">
<button
type="button"
onClick={() => setIsFemaleConsentChecked((value) => !value)}
className="flex w-full items-start gap-4 rounded-[15px] border-2 border-white bg-white/50 px-5 py-5 text-left shadow-[0_8px_24px_rgba(0,0,0,0.05)]"
>
<span
className={[
"mt-1 flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-full border-1",
isFemaleConsentChecked
? "border-[#F0445B] bg-[#F0445B]"
: "border-[#2B2B2B] bg-white",
].join(" ")}
aria-hidden="true"
>
{isFemaleConsentChecked ? (
<span className="h-3 w-3 rounded-full bg-[#F0445B]" />
) : null}
</span>
<span className="text-xs leading-[1.35] text-[#3F3F3F]">
{formatBoldText(
t[
"I confirm the family has **reviewed this profile** and **consents to communicate**."
],
)}
</span>
</button>
<div className="grid w-full grid-cols-2 gap-3">
<Button
variant="outlined"
className="py-[18px] text-[18px]"
onClick={close}
>
{t.Cancel}
</Button>
<Button
className="py-[18px] text-[18px]"
disabled={
!caseId ||
isSubmitting ||
!isAcceptProfileEnabled ||
!isFemaleConsentChecked
}
onClick={async () => {
close();
if (!isAcceptProfileEnabled) {
return;
}
markMatchStarted();
await respondMutation.mutateAsync({ action: "accept" });
}}
>
{t.Confirm}
</Button>
</div>
</div>
)}
onClose={() => {
setIsFemaleConsentChecked(false);
setIsRequestSheetOpen(false);
}}
/>
) : (
<InformationSheet
icon="check"
title={t["Request to Proceed"]}
description={
t[
"Upon your approval, your request will be sent to the lady, and we must await her response. This process may take between 2 to 4 days. Thank you for your patience."
]
}
buttons={({ close }) => (
<div className="grid w-full grid-cols-2 gap-3">
<Button
variant="outlined"
className="py-[18px]"
onClick={close}
>
{t.Cancel}
</Button>
<Button
className="py-[18px]"
disabled={
!isAcceptProfileEnabled || respondMutation.isPending
}
isLoading={respondMutation.isPending}
onClick={async () => {
close();
if (!isAcceptProfileEnabled) {
return;
}
markMatchStarted();
await respondMutation.mutateAsync({ action: "accept" });
}}
>
{t.Confirm}
</Button>
</div>
)}
onClose={() => setIsRequestSheetOpen(false)}
/>
)
) : null}
{isDeclineSheetOpen ? (
<InformationSheet
icon="warning"
title={t["Decline Profile"] || t["Reject Profile"]}
description={
t[
"Are you sure you've fully reviewed the profile and want to decline this profile?"
] ||
t[
"Are you sure you've fully reviewed the profile and want to reject this profile?"
]
}
buttons={({ close }) => (
<div className="grid w-full grid-cols-2 gap-3">
<Button variant="outlined" className="py-[18px]" onClick={close}>
{t.Cancel}
</Button>
<Button
onClick={() => {
close();
setIsDismissReasonSheetOpen(true);
}}
>
{t.Decline || t["Decline"] || t.Reject}
</Button>
</div>
)}
onClose={() => setIsDeclineSheetOpen(false)}
/>
) : null}
{isMaleDeclineWarningOpen ? (
<InformationSheet
icon="warning"
title={t["Decline Warning"] || t["Rejection Warning"]}
description={
t[
"Please review the person’s full profile once more before making your final decision."
]
}
buttons={({ close }) => (
<div className="grid w-full grid-cols-2 gap-3">
<Button variant="outlined" className="py-[18px]" onClick={close}>
{t.Cancel}
</Button>
<Button
className="py-[18px]"
onClick={() => {
close();
setIsDismissReasonSheetOpen(true);
}}
>
{t.Decline || t["Decline"] || t.Reject}
</Button>
</div>
)}
onClose={() => setIsMaleDeclineWarningOpen(false)}
/>
) : null}
{isDismissReasonSheetOpen ? (
<DismissReasonSheet
onClose={() => setIsDismissReasonSheetOpen(false)}
onSubmit={async (reason) => {
if (!caseId) {
return;
}
await respondMutation.mutateAsync({
action: "reject",
custom_note: reason,
});
}}
/>
) : null}
<main className={mainClass} style={mainStyle}>
<StickyHeader sticky className="shrink-0">
<div className="flex items-center justify-between gap-3">
<NavigationButton
variant="transparent"
icon="back"
iconLabel={t["Go back"]}
onClick={onClose}
/>
<h1
className="min-w-0 flex-1 text-center font-semibold text-[14px] leading-[16px] text-white"
>
{t["More detail"]}
</h1>
<div className="size-10 shrink-0" />
</div>
</StickyHeader>
<section className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-[17px] pb-6 pt-5">
<div
className="mx-auto flex flex-col items-center justify-center"
style={{
width: "343px",
height: isCandidateFemale ? "124px" : "125px",
gap: "10px",
}}
>
{/* Frame 2095586585 - Avatar Section */}
<div
className="flex flex-col items-center justify-center w-full"
style={{
height: "125px",
gap: "8px",
}}
>
{/* Avatar Group */}
<div className="relative overflow-hidden rounded-full w-[90px] h-[90px]">
<Image
src={avatarSrc}
alt=""
width={90}
height={90}
className="w-full h-full object-cover"
/>
</div>
{/* Frame 2095586663 - Names Section */}
<div
className="flex flex-row items-center justify-center w-full animate-fade-in"
style={{
height: "26px",
}}
>
<span
className="font-bold text-[22px] leading-[26px] text-[#F0445B] text-center"
style={{
WebkitTextStroke: "2px rgba(255, 255, 255, 0.5)",
paintOrder: "stroke fill",
}}
>
{candidateName}
</span>
</div>
</div>
</div>
<MatchPublicProfileFields
publicInfo={profile?.match_summary?.public_info}
dictionary={t}
/>
</section>
<div
style={{ paddingBottom: `calc(16px + var(--safe-bottom, 0px))` }}
className="shrink-0 z-30 w-full rounded-t-[24px] bg-white px-[17px] pt-4 shadow-[0_-4px_24px_rgba(0,0,0,0.08)]"
>
{(Boolean(onClose) && !isAcceptProfileEnabled) ||
caseStatus === "female_accepted" ||
caseStatus === "payment_pending" ||
caseStatus === "payment_done" ||
caseStatus === "contacted" ||
caseStatus === "finalized" ||
profile?.status === "matched" ? (
<button
type="button"
onClick={() => (onClose ? onClose() : router.back())}
className="w-full h-[52px] flex items-center justify-center rounded-[12px] bg-[#F5F5F5] text-[#36363C] font-semibold text-[16px] transition-transform active:scale-[0.98] cursor-pointer hover:bg-[#EBEBEB]"
>
{t.Back}
</button>
) : (
<div className="flex gap-3">
<button
type="button"
disabled={!isDeclineProfileEnabled || isSubmitting}
onClick={() => {
if (isFemaleProfile) {
setIsDeclineSheetOpen(true);
} else {
setIsMaleDeclineWarningOpen(true);
}
}}
className="inline-flex w-1/3 h-[52px] items-center justify-center rounded-[12px] border border-[#BFBFBF] bg-white px-4 text-[16px] font-semibold text-[#9A9A9A] disabled:cursor-not-allowed disabled:opacity-50"
>
{t.Decline || t["Decline"] || t.Reject}
</button>
<button
type="button"
disabled={!isAcceptProfileEnabled || isSubmitting}
onClick={() => {
if (!isAcceptProfileEnabled) {
return;
}
if (isFemaleProfile) {
setIsFemaleConsentChecked(false);
}
setIsRequestSheetOpen(true);
}}
className="inline-flex w-2/3 h-[52px] whitespace-nowrap items-center justify-center gap-1 rounded-[12px] bg-[#F0445B] px-4 text-[16px] font-semibold text-white shadow-[0_8px_16px_rgba(240,68,91,0.24)] disabled:cursor-not-allowed disabled:opacity-50"
>
{isSubmitting ? (
<LoadingThreeDot />
) : (
<>
<Image
src="/assets/images/Icfdason.svg"
alt=""
width={28}
height={28}
/>
<span>{t["Accept Profile"]}</span>
</>
)}
</button>
</div>
)}
</div>
</main>
</>
);
}

206
src/components/Componentes/question-photo.test.tsx

@ -1,206 +0,0 @@
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { QuestionField } from "@/lib/schema-adapter";
import { QuestionPhoto } from "./question-photo";
let answerMap: Record<string, unknown> = {};
const mockSetAnswerValue = vi.fn((q: QuestionField, val: unknown) => {
answerMap[q.id] = val;
});
const mockRegisterPendingUpload = vi.fn();
const mockUploadTmpMedia = vi.fn(async (file: File) => ({
path: `/media/tmp/${file.name}`,
name: file.name,
}));
vi.mock("@/hooks/marriage/use-upload-tmp-media", () => ({
useUploadTmpMediaMutation: ({
onSuccess,
onError,
}: {
onSuccess?: (data: any) => void;
onError?: (err: any) => void;
}) => ({
mutate: (file: File) => {
mockUploadTmpMedia(file)
.then((res) => onSuccess?.(res))
.catch((err) => onError?.(err));
},
mutateAsync: async (file: File) => {
try {
const res = await mockUploadTmpMedia(file);
onSuccess?.(res);
return res;
} catch (err) {
onError?.(err);
throw err;
}
},
isPending: false,
isError: false,
}),
}));
vi.mock("./question-answer-storage", () => ({
useQuestionAnswers: () => ({
getAnswerValue: (q: QuestionField) => answerMap[q.id] ?? null,
setAnswerValue: mockSetAnswerValue,
registerPendingUpload: mockRegisterPendingUpload,
isLoading: false,
}),
}));
vi.mock("@/translations/provider", () => ({
useI18n: () => ({
locale: "en",
dictionary: {
"Photo upload failed. Please try again.": "Photo upload failed. Please try again.",
"Failed to load image. Please try uploading again.": "Failed to load image. Please try uploading again.",
},
}),
}));
const mockPhotoQuestion: QuestionField = {
id: "documents_verification.face_photo",
title: "Face Photo",
type: "photo",
order: 1,
required: true,
baseRequired: true,
isVisible: true,
description: "Recent clear face photo",
tooltip: "",
extras: {
options: [".jpg", ".jpeg", ".png"],
} as any,
options: [],
};
describe("QuestionPhoto Component", () => {
beforeEach(() => {
answerMap = {};
mockSetAnswerValue.mockClear();
mockUploadTmpMedia.mockClear();
mockRegisterPendingUpload.mockClear();
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
it("renders default avatar placeholder when no photo uploaded", () => {
const { container } = render(<QuestionPhoto question={mockPhotoQuestion} />);
expect(screen.getByText(/Face/)).toBeInTheDocument();
expect(screen.getByText(/Photo/)).toBeInTheDocument();
expect(screen.getByText("Recent clear face photo")).toBeInTheDocument();
// Default placeholder SVG is rendered
expect(container.querySelector("img[src*='Frame 2095586679.svg']")).toBeInTheDocument();
});
it("renders uploaded image and camera badge when answer is provided", () => {
answerMap[mockPhotoQuestion.id] = "https://example.com/avatar.jpg";
const { container } = render(<QuestionPhoto question={mockPhotoQuestion} />);
const img = container.querySelector("img[src='https://example.com/avatar.jpg']");
expect(img).toBeInTheDocument();
expect(img).toHaveClass("rounded-full");
// Camera badge rendered
expect(container.querySelector(".bg-\\[\\#F0445B\\]")).toBeInTheDocument();
});
it("triggers upload and renders circular spinner without black background overlay during file selection", async () => {
let resolveUpload!: (val: any) => void;
mockUploadTmpMedia.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveUpload = resolve;
}),
);
const { container } = render(<QuestionPhoto question={mockPhotoQuestion} />);
const fileInput = container.querySelector("input[type='file']") as HTMLInputElement;
const testFile = new File(["dummy content"], "my-photo.jpg", { type: "image/jpeg" });
const originalCreateObjectURL = URL.createObjectURL;
URL.createObjectURL = vi.fn(() => "blob:http://localhost/test-blob");
try {
fireEvent.change(fileInput, { target: { files: [testFile] } });
await waitFor(() => {
const spinner = container.querySelector(".animate-spin");
expect(spinner).toBeInTheDocument();
expect(spinner).toHaveClass("border-[#F0445B]/25");
});
expect(container.querySelector(".bg-black\\/40")).toBeNull();
expect(container.querySelector(".bg-black")).toBeNull();
resolveUpload({ path: "/media/uploaded.jpg" });
await waitFor(() => {
expect(container.querySelector(".animate-spin")).toBeNull();
});
} finally {
URL.createObjectURL = originalCreateObjectURL;
}
});
it("shows error toast, reverts to default avatar placeholder, and clears answer when upload fails", async () => {
mockUploadTmpMedia.mockImplementationOnce(
() => Promise.reject(new Error("Network Error")),
);
const { container } = render(<QuestionPhoto question={mockPhotoQuestion} />);
const fileInput = container.querySelector("input[type='file']") as HTMLInputElement;
const testFile = new File(["dummy content"], "bad-photo.jpg", { type: "image/jpeg" });
const originalCreateObjectURL = URL.createObjectURL;
const originalRevokeObjectURL = URL.revokeObjectURL;
URL.createObjectURL = vi.fn(() => "blob:http://localhost/bad-blob");
URL.revokeObjectURL = vi.fn();
try {
fireEvent.change(fileInput, { target: { files: [testFile] } });
// Error toast should appear
await waitFor(() => {
// Toast message should appear
expect(screen.getByText("Photo upload failed. Please try again.")).toBeInTheDocument();
// Answer value is NOT cleared — matches yesterday's behavior
// (error just shows toast, does not wipe the form state)
expect(mockSetAnswerValue).not.toHaveBeenCalledWith(mockPhotoQuestion, null);
// File input is reset allowing re-upload
expect(fileInput.value).toBe("");
});
} finally {
URL.createObjectURL = originalCreateObjectURL;
URL.revokeObjectURL = originalRevokeObjectURL;
}
});
it("keeps stored answer when rendered image fails to load (preview-only failure)", async () => {
answerMap[mockPhotoQuestion.id] = "https://example.com/broken-avatar.jpg";
const { container } = render(<QuestionPhoto question={mockPhotoQuestion} />);
const img = container.querySelector("img[src='https://example.com/broken-avatar.jpg']") as HTMLImageElement;
expect(img).toBeInTheDocument();
// Trigger image error (e.g. 404 / ERR_CONNECTION_REFUSED)
fireEvent.error(img);
// The stored answer should NOT be cleared — the file is already on the server.
// This matches yesterday's behavior where preview errors never wiped the answer.
expect(mockSetAnswerValue).not.toHaveBeenCalledWith(mockPhotoQuestion, null);
// The component should remain in "answered" state via data-question-answered
// because storedValue still holds the uploaded path
expect(container.querySelector("[data-question-answered='true']")).toBeInTheDocument();
});
});

58
src/components/Componentes/question-photo.tsx

@ -6,10 +6,9 @@ import type { QuestionField } from "@/lib/schema-adapter";
import { useUploadTmpMediaMutation } from "@/hooks/marriage/use-upload-tmp-media"; import { useUploadTmpMediaMutation } from "@/hooks/marriage/use-upload-tmp-media";
import { getApiRequestUrl } from "@/lib/http"; import { getApiRequestUrl } from "@/lib/http";
import { isInFlutterWebView, uploadFile } from "@/lib/webview-actions"; import { isInFlutterWebView, uploadFile } from "@/lib/webview-actions";
import { useI18n } from "@/translations/provider";
import ErrorToast from "./error-toast";
import { useQuestionAnswers } from "./question-answer-storage"; import { useQuestionAnswers } from "./question-answer-storage";
import QuestionTitle from "./question-title"; import QuestionTitle from "./question-title";
import { LoadingSkeleton } from "./loading-skeleton";
type QuestionPhotoProps = { type QuestionPhotoProps = {
question: QuestionField; question: QuestionField;
@ -23,16 +22,15 @@ export function QuestionPhoto({
disabled, disabled,
}: QuestionPhotoProps) { }: QuestionPhotoProps) {
const inputId = useId(); const inputId = useId();
const { dictionary: t } = useI18n();
const [localPreviewUrl, setLocalPreviewUrl] = useState<string | null>(null); const [localPreviewUrl, setLocalPreviewUrl] = useState<string | null>(null);
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const [toastMessage, setToastMessage] = useState<string | null>(null);
const acceptedFiles = const acceptedFiles =
question.extras?.options && question.extras.options.length > 0 question.extras?.options && question.extras.options.length > 0
? question.extras.options.join(",") ? question.extras.options.join(",")
: "image/*"; : "image/*";
const descriptionContent = description ?? question.description;
const { getAnswerValue, setAnswerValue, registerPendingUpload } = const { getAnswerValue, setAnswerValue, registerPendingUpload } =
useQuestionAnswers(); useQuestionAnswers();
const storedValue = getAnswerValue(question); const storedValue = getAnswerValue(question);
@ -50,16 +48,10 @@ export function QuestionPhoto({
setLocalPreviewUrl(response.path); setLocalPreviewUrl(response.path);
} }
setIsUploading(false); setIsUploading(false);
isInitiatorRef.current = false;
}, },
onError: (error) => { onError: (error) => {
console.error("Photo upload error:", error); console.error("Photo upload error:", error);
setIsUploading(false); setIsUploading(false);
isInitiatorRef.current = false;
setToastMessage(
t["Photo upload failed. Please try again."] ??
"Photo upload failed. Please try again.",
);
}, },
}); });
@ -69,18 +61,19 @@ export function QuestionPhoto({
useEffect(() => { useEffect(() => {
if (!isInFlutterWebView()) return; if (!isInFlutterWebView()) return;
const unsubscribe = window.addFlutterResponseListener?.((event: any) => {
const unsubscribe = window.addFlutterResponseListener?.((event) => {
if (event.action !== "upload_file") return; if (event.action !== "upload_file") return;
if (event.requestId && String(event.requestId) !== String(question.id)) return; if (event.requestId && String(event.requestId) !== String(question.id)) return;
if (!event.requestId && !isInitiatorRef.current) return; if (!event.requestId && !isInitiatorRef.current) return;
switch (event.status) { switch (event.status) {
case "picking":
break;
case "picked":
case "progress": case "progress":
setIsUploading(true); setIsUploading(true);
if (event.data?.base64) {
const b64 = event.data.base64.startsWith("data:")
? event.data.base64
: `data:image/jpeg;base64,${event.data.base64}`;
if (event.data?.files?.[0]?.base64) {
const b64 = event.data.files[0].base64;
setLocalPreviewUrl(b64); setLocalPreviewUrl(b64);
} }
break; break;
@ -209,10 +202,6 @@ export function QuestionPhoto({
.catch((err) => { .catch((err) => {
console.error("Photo upload error:", err); console.error("Photo upload error:", err);
setIsUploading(false); setIsUploading(false);
setToastMessage(
t["Photo upload failed. Please try again."] ??
"Photo upload failed. Please try again.",
);
}); });
registerPendingUpload?.(question.id, uploadPromise); registerPendingUpload?.(question.id, uploadPromise);
@ -256,14 +245,8 @@ export function QuestionPhoto({
disabled ? "pointer-events-none opacity-30" : "", disabled ? "pointer-events-none opacity-30" : "",
].join(" ")} ].join(" ")}
> >
{toastMessage && (
<ErrorToast
message={toastMessage}
onClose={() => setToastMessage(null)}
/>
)}
<QuestionTitle <QuestionTitle
question={question}
question={{ ...question, description: "" }}
className="justify-center text-center" className="justify-center text-center"
/> />
@ -281,11 +264,11 @@ export function QuestionPhoto({
className="flex w-full cursor-pointer flex-col items-center" className="flex w-full cursor-pointer flex-col items-center"
> >
<span className="flex w-full flex-col items-center"> <span className="flex w-full flex-col items-center">
<div className="relative flex h-[92px] w-[86px] items-start justify-center">
{hasAnswer && displayUrl ? (
<div className="relative flex h-[92px] w-[86px] items-center justify-center">
{displayUrl ? (
<img <img
src={displayUrl} src={displayUrl}
alt=""
alt={typeof question.title === "string" ? question.title : ""}
className="h-[86px] w-[86px] rounded-full object-cover border border-[#D7DBE2]" className="h-[86px] w-[86px] rounded-full object-cover border border-[#D7DBE2]"
/> />
) : ( ) : (
@ -300,8 +283,8 @@ export function QuestionPhoto({
)} )}
{/* Red camera badge when image is uploaded */} {/* Red camera badge when image is uploaded */}
{hasAnswer && !isPending && (
<span className="absolute bottom-0 right-0 flex h-7 w-7 items-center justify-center rounded-full bg-[#F0445B]">
{displayUrl && (
<span className="absolute bottom-[2px] right-0 flex h-7 w-7 items-center justify-center rounded-full bg-[#F0445B]">
<svg <svg
width="14" width="14"
height="14" height="14"
@ -319,13 +302,19 @@ export function QuestionPhoto({
</span> </span>
)} )}
{/* Circular loading spinner during upload without black overlay */}
{/* Loading spinner during upload matching Flutter ProfileAvatarWidget */}
{isPending && ( {isPending && (
<div className="absolute top-0 left-0 flex h-[86px] w-[86px] items-center justify-center rounded-full pointer-events-none z-10">
<div className="size-7 animate-spin rounded-full border-[2.5px] border-[#F0445B]/25 border-t-[#F0445B]" />
<div className="absolute inset-0 flex items-center justify-center rounded-full bg-black/40 backdrop-blur-[1px] z-10 transition-all duration-200">
<div className="h-7 w-7 animate-spin rounded-full border-[2.5px] border-white border-t-transparent shadow-sm" />
</div> </div>
)} )}
</div> </div>
{descriptionContent ? (
<span className="mt-4 block max-w-[350px] group-10 leading-[1.35] font-semibold text-[#D44747]">
{descriptionContent}
</span>
) : null}
</span> </span>
</label> </label>
</div> </div>
@ -333,4 +322,3 @@ export function QuestionPhoto({
} }
export default QuestionPhoto; export default QuestionPhoto;

1
src/translations/locales/en.json

@ -832,6 +832,7 @@
"Subscription required": "Subscription required", "Subscription required": "Subscription required",
"To view profiles, you need a subscription. Each subscription includes up to {count} matches.": "To view profiles, you need a subscription. Each subscription includes up to {count} matches.", "To view profiles, you need a subscription. Each subscription includes up to {count} matches.": "To view profiles, you need a subscription. Each subscription includes up to {count} matches.",
"To view profiles, a subscription is required. This helps cover our support and matching services and applies only to male users": "To view profiles, a subscription is required. This helps cover our support and matching services and applies only to male users.", "To view profiles, a subscription is required. This helps cover our support and matching services and applies only to male users": "To view profiles, a subscription is required. This helps cover our support and matching services and applies only to male users.",
"To view profiles, a subscription is required. This helps cover our advisory and matching services and applies only to male users": "To view profiles, a subscription is required. This helps cover our advisory and matching services and applies only to male users.",
"{count} case introductions remaining of your subscription.": "{count} case introductions remaining of your subscription.", "{count} case introductions remaining of your subscription.": "{count} case introductions remaining of your subscription.",
"{days} days and {count} case introductions remaining of your subscription.": "{days} days and {count} case introductions remaining of your subscription.", "{days} days and {count} case introductions remaining of your subscription.": "{days} days and {count} case introductions remaining of your subscription.",
"You do not have enough Habib Coins": "You do not have enough Habib Coins", "You do not have enough Habib Coins": "You do not have enough Habib Coins",

Loading…
Cancel
Save