"use client"; import { useCallback, useEffect, useId, useMemo, useRef, useState, } from "react"; import { localeDirections } from "@/translations/config"; import { useI18n } from "@/translations/provider"; import { useSheetScrollLock } from "./use-sheet-scroll-lock"; const EXIT_ANIMATION_MS = 200; // Parity with Flutter DraggableScrollableSheet: // initialChildSize: 0.75, minChildSize: 0.6, maxChildSize: 1.0, shouldCloseOnMinExtent: true const SHEET_INITIAL_SIZE = 0.75; const SHEET_MIN_SIZE = 0.6; const SHEET_MAX_SIZE = 1; const GESTURE_ENGAGE_PX = 10; export type FemaleOutcomeSheetProps = { closeOnOutside?: boolean; onClose?: () => void; onSubmit?: (status: "success" | "failure", reason?: string) => void; }; export function FemaleOutcomeSheet({ closeOnOutside = true, onClose, onSubmit, }: FemaleOutcomeSheetProps) { const { dictionary: t, locale } = useI18n(); const direction = localeDirections[locale] ?? "ltr"; const groupId = useId(); const [isVisible, setIsVisible] = useState(true); const [isClosing, setIsClosing] = useState(false); const isClosingRef = useRef(false); const isMountedRef = useRef(true); // Outcome selection state: "ongoing" (Option A) or "canceled" (Option B) const [outcome, setOutcome] = useState<"ongoing" | "canceled">("ongoing"); const isExpanded = outcome === "canceled"; const sheetRef = useRef(null); const headerRef = useRef(null); const contentRef = useRef(null); const sheetSizeRef = useRef(SHEET_INITIAL_SIZE); const restingStateRef = useRef(SHEET_INITIAL_SIZE); useEffect(() => { isMountedRef.current = true; return () => { isMountedRef.current = false; }; }, []); // Rejection/Cancellation reason states const reasons = useMemo( () => [ t["Not a good personal fit"], t["No mutual interest"], t["Different expectations"], t["No connection felt"], t["Location not suitable"], t["Other reasons"], ], [t], ); const [selectedReasons, setSelectedReasons] = useState([]); const [reasonText, setReasonText] = useState(""); const textareaRef = useRef(null); useEffect(() => { const otherReason = reasons[reasons.length - 1]; if (selectedReasons.includes(otherReason)) { const timeoutId = setTimeout(() => { textareaRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest", }); }, 100); return () => clearTimeout(timeoutId); } }, [selectedReasons, reasons]); const closeSheet = useCallback(() => { if (isClosingRef.current) { return; } isClosingRef.current = true; setIsClosing(true); window.setTimeout(() => { if (isMountedRef.current) { setIsVisible(false); } isClosingRef.current = false; onClose?.(); }, EXIT_ANIMATION_MS); }, [onClose]); useSheetScrollLock(isVisible && !isClosing, { onBack: closeSheet }); // Handle escape key useEffect(() => { if (!isVisible) return; const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { closeSheet(); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [isVisible, closeSheet]); // Adaptive height transition when switching between ongoing and canceled states useEffect(() => { if (!sheetRef.current) return; if (isExpanded) { sheetSizeRef.current = SHEET_INITIAL_SIZE; restingStateRef.current = SHEET_INITIAL_SIZE; sheetRef.current.style.transition = "height 260ms cubic-bezier(0.16, 1, 0.3, 1)"; sheetRef.current.style.setProperty( "--sheet-size", SHEET_INITIAL_SIZE.toFixed(4), ); const timer = window.setTimeout(() => { if (sheetRef.current) { sheetRef.current.style.transition = ""; } }, 270); return () => clearTimeout(timer); } else { sheetRef.current.style.transition = "height 260ms cubic-bezier(0.16, 1, 0.3, 1)"; sheetRef.current.style.removeProperty("--sheet-size"); const timer = window.setTimeout(() => { if (sheetRef.current) { sheetRef.current.style.transition = ""; } }, 270); return () => clearTimeout(timer); } }, [isExpanded]); // Drag-to-resize gesture handlers matching Flutter DraggableScrollableSheet // Active only when isExpanded is true (option 2 chosen) useEffect(() => { if (!isVisible || isClosing || !isExpanded) return; const content = contentRef.current; const sheet = sheetRef.current; const header = headerRef.current; if (!sheet) return; let active = false; let engaged = false; let closing = false; let isHeaderDrag = false; let startX = 0; let startY = 0; let lastY = 0; let snapTimeout: ReturnType | null = null; let wheelSnapTimeout: ReturnType | null = null; const contentOverflows = () => content ? content.scrollHeight > content.clientHeight + 1 : false; const snapTo = (targetSize: number) => { sheetSizeRef.current = targetSize; restingStateRef.current = targetSize; sheet.style.transition = "height 260ms cubic-bezier(0.16, 1, 0.3, 1)"; sheet.style.setProperty("--sheet-size", targetSize.toFixed(4)); if (snapTimeout) clearTimeout(snapTimeout); snapTimeout = setTimeout(() => { if (sheet) { sheet.style.transition = ""; } }, 270); }; const resize = (deltaPx: number) => { const viewportHeight = window.innerHeight || 1; const nextSize = Math.min( SHEET_MAX_SIZE, Math.max( SHEET_MIN_SIZE, sheetSizeRef.current + deltaPx / viewportHeight, ), ); sheetSizeRef.current = nextSize; sheet.style.setProperty("--sheet-size", nextSize.toFixed(4)); }; const handleTouchStart = (event: TouchEvent, fromHeader: boolean) => { if (event.touches.length !== 1) { active = false; return; } if (snapTimeout) { clearTimeout(snapTimeout); snapTimeout = null; } sheet.style.transition = "none"; active = true; engaged = fromHeader; isHeaderDrag = fromHeader; startX = event.touches[0].clientX; startY = event.touches[0].clientY; lastY = startY; }; const handleTouchMove = (event: TouchEvent) => { if (!active || closing || event.touches.length !== 1) return; const touch = event.touches[0]; const deltaY = lastY - touch.clientY; // finger up = grow sheet const pulled = startY - touch.clientY; // cumulative distance lastY = touch.clientY; if (!engaged) { if (Math.abs(pulled) < GESTURE_ENGAGE_PX) return; if (Math.abs(touch.clientX - startX) >= Math.abs(pulled)) return; const atTop = !content || content.scrollTop <= 0; const canGrow = pulled > 0 && atTop && contentOverflows() && sheetSizeRef.current < SHEET_MAX_SIZE - 0.001; const canShrink = pulled < 0 && atTop; if (!canGrow && !canShrink) return; engaged = true; } const atTop = !content || content.scrollTop <= 0; const size = sheetSizeRef.current; const consume = isHeaderDrag ? true : deltaY > 0 ? atTop && size < SHEET_MAX_SIZE - 0.001 : atTop; if (!consume) return; // at ceiling or scrolling inside content if (event.cancelable) { event.preventDefault(); } if (deltaY > 0) { resize(deltaY); } else if (size <= SHEET_MIN_SIZE + 0.001) { closing = true; // shouldCloseOnMinExtent: true closeSheet(); } else { resize(deltaY); } }; const handleTouchEnd = () => { if (!active || closing) { active = false; engaged = false; isHeaderDrag = false; return; } active = false; engaged = false; isHeaderDrag = false; const currentSize = sheetSizeRef.current; const baseline = restingStateRef.current; const totalPulled = startY - lastY; // > 0 finger pulled up, < 0 finger pulled down // Flutter shouldCloseOnMinExtent: true when dragged near or below 60% floor if (currentSize <= SHEET_MIN_SIZE + 0.01) { closing = true; closeSheet(); return; } // Snapping between only two states: 0.75 (default) and 1.0 (expanded) if (baseline === SHEET_MAX_SIZE) { // Dragging down from 1.0: if pulled down >= 30px or size <= 0.94, snap to 0.75 if (totalPulled <= -30 || currentSize <= 0.94) { snapTo(SHEET_INITIAL_SIZE); } else { snapTo(SHEET_MAX_SIZE); } } else { // Dragging from 0.75: // If pulled down strongly, close if (totalPulled <= -60) { closing = true; closeSheet(); } else if (totalPulled >= 30 || currentSize >= 0.78) { // Dragged up enough to expand to 1.0 snapTo(SHEET_MAX_SIZE); } else { snapTo(SHEET_INITIAL_SIZE); } } }; const handleWheel = (event: WheelEvent) => { if (closing) return; const delta = event.deltaY; const atTop = !content || content.scrollTop <= 0; const size = sheetSizeRef.current; const consume = delta > 0 ? atTop && contentOverflows() && size < SHEET_MAX_SIZE - 0.001 : atTop; if (!consume) return; if (event.cancelable) { event.preventDefault(); } if (delta > 0) { resize(delta); } else if (size <= SHEET_MIN_SIZE + 0.001) { closing = true; closeSheet(); } else { resize(delta); } if (wheelSnapTimeout) clearTimeout(wheelSnapTimeout); wheelSnapTimeout = setTimeout(() => { if (closing) return; const current = sheetSizeRef.current; if (current <= SHEET_MIN_SIZE + 0.01) { closing = true; closeSheet(); } else if (current >= 0.82) { snapTo(SHEET_MAX_SIZE); } else { snapTo(SHEET_INITIAL_SIZE); } }, 160); }; const onHeaderTouchStart = (e: TouchEvent) => handleTouchStart(e, true); const onContentTouchStart = (e: TouchEvent) => handleTouchStart(e, false); if (header) { header.addEventListener("touchstart", onHeaderTouchStart, { passive: true, }); } if (content) { content.addEventListener("touchstart", onContentTouchStart, { passive: true, }); content.addEventListener("touchmove", handleTouchMove, { passive: false, }); content.addEventListener("touchend", handleTouchEnd, { passive: true }); content.addEventListener("touchcancel", handleTouchEnd, { passive: true }); content.addEventListener("wheel", handleWheel, { passive: false }); } sheet.addEventListener("touchmove", handleTouchMove, { passive: false }); sheet.addEventListener("touchend", handleTouchEnd, { passive: true }); sheet.addEventListener("touchcancel", handleTouchEnd, { passive: true }); return () => { if (snapTimeout) clearTimeout(snapTimeout); if (wheelSnapTimeout) clearTimeout(wheelSnapTimeout); if (header) { header.removeEventListener("touchstart", onHeaderTouchStart); } if (content) { content.removeEventListener("touchstart", onContentTouchStart); content.removeEventListener("touchmove", handleTouchMove); content.removeEventListener("touchend", handleTouchEnd); content.removeEventListener("touchcancel", handleTouchEnd); content.removeEventListener("wheel", handleWheel); } sheet.removeEventListener("touchmove", handleTouchMove); sheet.removeEventListener("touchend", handleTouchEnd); sheet.removeEventListener("touchcancel", handleTouchEnd); }; }, [isVisible, isClosing, isExpanded, closeSheet]); if (!isVisible) { return null; } const isCanceledDisabled = selectedReasons.length === 0 || (selectedReasons.includes(reasons[reasons.length - 1]) && reasonText.trim() === ""); return (
{ if (closeOnOutside && event.target === event.currentTarget) { closeSheet(); } }} onKeyDown={(event) => { if ( closeOnOutside && event.target === event.currentTarget && (event.key === "Escape" || event.key === "Enter" || event.key === " ") ) { event.preventDefault(); closeSheet(); } }} >
{/* Drag handle */}

{t["What was the outcome of your contact?"]}

{ t[ "We hope the acquaintance process is going well. Please let us know if you want to continue the acquaintance process or if the match has been canceled." ] }

{t["What was the outcome of your contact?"]}
{/* Option A: Ongoing acquaintance */} {/* Option B: Canceled / Decline */} {/* Reasons List (if Canceled is chosen) */} {outcome === "canceled" ? (

{t["Please select the reason for cancellation:"]}

{reasons.map((option) => { const isReasonChecked = selectedReasons.includes(option); const isOtherReason = option === reasons[reasons.length - 1]; return (
{isReasonChecked && isOtherReason ? (