@ -1,11 +1,26 @@
"use client" ;
import { useCallback , useEffect , useId , useMemo , useRef , useState } from "react" ;
import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler" ;
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 ;
@ -17,13 +32,25 @@ export function FemaleOutcomeSheet({
onClose ,
onSubmit ,
} : FemaleOutcomeSheetProps ) {
const { dictionary : t } = useI18n ( ) ;
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 < HTMLElement > ( null ) ;
const headerRef = useRef < HTMLDivElement > ( null ) ;
const contentRef = useRef < HTMLDivElement > ( null ) ;
const sheetSizeRef = useRef ( SHEET_INITIAL_SIZE ) ;
const restingStateRef = useRef < number > ( SHEET_INITIAL_SIZE ) ;
useEffect ( ( ) = > {
isMountedRef . current = true ;
return ( ) = > {
@ -31,9 +58,6 @@ export function FemaleOutcomeSheet({
} ;
} , [ ] ) ;
// Outcome selection state: "ongoing" (Option A) or "canceled" (Option B)
const [ outcome , setOutcome ] = useState < "ongoing" | "canceled" > ( "ongoing" ) ;
// Rejection/Cancellation reason states
const reasons = useMemo (
( ) = > [
@ -64,36 +88,302 @@ export function FemaleOutcomeSheet({
} , [ selectedReasons , reasons ] ) ;
const closeSheet = useCallback ( ( ) = > {
if ( isClosing ) {
if ( isClosingRef . current ) {
return ;
}
isClosingRef . current = true ;
setIsClosing ( true ) ;
window . setTimeout ( ( ) = > {
if ( isMountedRef . current ) {
setIsVisible ( false ) ;
}
isClosingRef . current = false ;
onClose ? . ( ) ;
} , EXIT_ANIMATION_MS ) ;
} , [ isClosing , onClose ] ) ;
} , [ onClose ] ) ;
useHardwareBackHandler ( closeSheet , isVisible && ! isClosing ) ;
useSheetScrollLock ( isVisible && ! isClosing , { onBack : closeSheet } ) ;
// Handle escape key
useEffect ( ( ) = > {
if ( ! isVisible ) {
return ;
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 < typeof setTimeout > | null = null ;
let wheelSnapTimeout : ReturnType < typeof setTimeout > | 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 ) ;
}
const previousBodyOverflow = document . body . style . overflow ;
const previousHtmlOverflow = document . documentElement . style . overflow ;
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 ) ;
} ;
document . body . style . overflow = "hidden" ;
document . documentElement . style . overflow = "hidden" ;
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 ( ) = > {
document . body . style . overflow = previousBodyOverflow ;
document . documentElement . style . overflow = previousHtmlOverflow ;
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 ] ) ;
} , [ isVisible , isClosing , isExpanded , closeSheet ] ) ;
if ( ! isVisible ) {
return null ;
@ -115,6 +405,7 @@ export function FemaleOutcomeSheet({
role = "dialog"
aria - modal = "true"
aria - label = { t [ "What was the outcome of your contact?" ] }
dir = { direction }
tabIndex = { - 1 }
onClick = { ( event ) = > {
if ( closeOnOutside && event . target === event . currentTarget ) {
@ -133,20 +424,39 @@ export function FemaleOutcomeSheet({
} }
>
< section
ref = { sheetRef }
style = { {
height : isExpanded
? "calc(var(--sheet-size, 0.75) * (100svh - max(var(--safe-top, 0px), env(safe-area-inset-top, 0px))))"
: undefined ,
maxHeight :
"calc(100svh - max(var(--safe-top, 0px), env(safe-area-inset-top, 0px)))" ,
} }
className = { [
"flex max-h-[85vh] w-full max-w-[834px] sm:max-w-[540px] flex-col overflow-hidden rounded-t-[22px] bg-white p-4 text-center shadow-[0_20px_60px_rgba(15,23,42,0.08)]" ,
"flex w-full max-w-[834px] sm:max-w-[540px] flex-col overflow-hidden rounded-t-[22px] bg-white p-4 pt-2.5 text-start shadow-[0_20px_60px_rgba(15,23,42,0.08)]" ,
isExpanded ? "flutter-draggable-sheet" : "h-auto max-h-[82svh]" ,
isClosing ? "flutter-sheet-surface-exit" : "flutter-sheet-surface" ,
]
. filter ( Boolean )
. join ( " " ) }
>
< div className = "mx-auto flex h-full min-h-0 w-full flex-col items-center" >
< div className = "flex min-h-0 w-full flex-1 flex-col items-center" >
< h2 className = "text-[18px] leading-[1.2] font-bold tracking-[-0.03em] text-[#171717]" >
{ /* Drag handle */ }
< div
ref = { headerRef }
className = "flex flex-col select-none touch-none shrink-0"
>
< div className = "flex justify-center pt-0 pb-2.5 cursor-grab active:cursor-grabbing" >
< div className = "h-1.2 w-10 rounded-full bg-[#D0D5DD]" / >
< / div >
< / div >
< div className = "mx-auto flex h-full min-h-0 w-full flex-col" >
< div className = "flex min-h-0 w-full flex-1 flex-col" >
< h2 className = "w-full text-start text-[18px] leading-[1.2] font-bold tracking-[-0.03em] text-[#171717]" >
{ t [ "What was the outcome of your contact?" ] }
< / h2 >
< p className = "mt-3.5 w-full text-start text-[14px] leading-[1.45] text-[#2C2C2C] dir-auto font-medium" >
< p className = "mt-3.5 w-full text-start text-[14px] leading-[1.45] text-[#2C2C2C] font-medium" >
{
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."
@ -162,11 +472,14 @@ export function FemaleOutcomeSheet({
{ t [ "What was the outcome of your contact?" ] }
< / legend >
< div className = "flex min-h-0 flex-1 flex-col gap-[14px] overflow-y-auto pr-1" >
< div
ref = { contentRef }
className = "flex min-h-0 flex-1 flex-col gap-[14px] overflow-y-auto pe-1"
>
{ /* Option A: Ongoing acquaintance */ }
< label
className = { [
"flex cursor-pointer items-center gap-3 rounded-[14px] px-[14px] py-[18px] text-lef t border transition-all" ,
"flex cursor-pointer items-center gap-3 rounded-[14px] px-[14px] py-[18px] text-star t border transition-all" ,
outcome === "ongoing"
? "bg-[#FFECEF] border-[#F0445B]/30"
: "bg-[#ECECEC] border-transparent" ,
@ -195,7 +508,7 @@ export function FemaleOutcomeSheet({
< span className = "h-3 w-3 rounded-full bg-[#F0445B]" / >
) : null }
< / span >
< span className = "text-[15px] leading-snug font-bold text-[#262626] flex-1" >
< span className = "text-[15px] leading-snug font-bold text-[#262626] flex-1 text-start " >
{
t [
"We are in the acquaintance/proposal process and nothing is finalized yet"
@ -207,7 +520,7 @@ export function FemaleOutcomeSheet({
{ /* Option B: Canceled / Decline */ }
< label
className = { [
"flex cursor-pointer items-center gap-3 rounded-[14px] px-[14px] py-[18px] text-lef t border transition-all" ,
"flex cursor-pointer items-center gap-3 rounded-[14px] px-[14px] py-[18px] text-star t border transition-all" ,
outcome === "canceled"
? "bg-[#FFECEF] border-[#F0445B]/30"
: "bg-[#ECECEC] border-transparent" ,
@ -242,20 +555,18 @@ export function FemaleOutcomeSheet({
< span className = "h-3 w-3 rounded-full bg-[#F0445B]" / >
) : null }
< / span >
< span className = "text-[15px] leading-snug font-bold text-[#262626] flex-1" >
{
t [
"We did not reach the agreement needed to continue acquaintance and decided not to proceed"
] ||
< span className = "text-[15px] leading-snug font-bold text-[#262626] flex-1 text-start" >
{ t [
"We did not reach the agreement needed to continue acquaintance and decided not to proceed"
}
] ||
"We did not reach the agreement needed to continue acquaintance and decided not to proceed" }
< / span >
< / label >
{ /* Reasons List (if Canceled is chosen) */ }
{ outcome === "canceled" ? (
< div className = "mt-3 flex flex-col gap-3 pl -2 text-start animate-fadeIn" >
< p className = "text-[13px] font-bold text-[#555] mb-1" >
< div className = "mt-3 flex flex-col gap-3 ps -2 text-start animate-fadeIn" >
< p className = "text-[13px] font-bold text-[#555] mb-1 text-start " >
{ t [ "Please select the reason for cancellation:" ] }
< / p >
< div className = "flex flex-col gap-[10px]" >
@ -317,8 +628,8 @@ export function FemaleOutcomeSheet({
) : null }
< / span >
< span className = "min-w-0 flex-1" >
< span className = "text-[14px] leading-none font-bold" >
< span className = "min-w-0 flex-1 text-start " >
< span className = "text-[14px] leading-none font-bold text-start " >
{ option }
< / span >
< / span >
@ -327,7 +638,7 @@ export function FemaleOutcomeSheet({
{ isReasonChecked && isOtherReason ? (
< textarea
ref = { textareaRef }
className = "mt-1 h-[90px] w-full resize-none rounded-[12px] border border-[#BFBFBF] bg-transparent px-3 py-2 text-[14px] text-[#171717] outline-none placeholder:text-[#AAAAAA]"
className = "mt-1 h-[90px] w-full resize-none rounded-[12px] border border-[#BFBFBF] bg-transparent px-3 py-2 text-[14px] text-[#171717] outline-none placeholder:text-[#AAAAAA] text-start "
placeholder = {
t [
"Feel free to briefly explain your decision..."
@ -377,7 +688,9 @@ export function FemaleOutcomeSheet({
onClick = { ( ) = > {
const activeReasons = selectedReasons . map ( ( r ) = > {
if ( r === reasons [ reasons . length - 1 ] ) {
return reasonText . trim ( ) ? ` ${ r } : ${ reasonText . trim ( ) } ` : r ;
return reasonText . trim ( )
? ` ${ r } : ${ reasonText . trim ( ) } `
: r ;
}
return r ;
} ) ;