From 78b1afa55058b07840549f804157257ea07d63b2 Mon Sep 17 00:00:00 2001 From: mortezaei Date: Sun, 30 Aug 2026 21:40:53 +0330 Subject: [PATCH] feat: enable automatic keyboard-aware sheet lifting and optimize inventory fetching with synchronous initial data --- src/app/new-match/new-match-client.tsx | 37 ++--- .../Componentes/information-sheet.test.tsx | 92 ++++++++++++ .../Componentes/information-sheet.tsx | 134 +++++++++++++++++- .../subscription-required-sheet.tsx | 7 +- .../marriage/use-habcoin-inventory.test.ts | 43 ++++++ src/hooks/marriage/use-habcoin-inventory.ts | 4 + 6 files changed, 287 insertions(+), 30 deletions(-) create mode 100644 src/components/Componentes/information-sheet.test.tsx create mode 100644 src/hooks/marriage/use-habcoin-inventory.test.ts diff --git a/src/app/new-match/new-match-client.tsx b/src/app/new-match/new-match-client.tsx index dc11c76..d1f1d40 100644 --- a/src/app/new-match/new-match-client.tsx +++ b/src/app/new-match/new-match-client.tsx @@ -507,10 +507,9 @@ export default function NewMatchClient() { const [appliedDiscount, setAppliedDiscount] = useState(null); - const { data: inventory, isLoading: isInventoryLoading } = - useHabcoinInventoryQuery({ - enabled: isPaymentSheetOpen, - }); + const { data: inventory } = useHabcoinInventoryQuery({ + enabled: isPaymentSheetOpen, + }); const planPrice = Number(profile?.recommended_plan?.price) || 50; const finalPrice = appliedDiscount?.valid @@ -612,7 +611,16 @@ export default function NewMatchClient() { useHabibWebReady(true); const handleOpenProfile = async () => { - // If profile has not yet completed its initial server fetch or is in flight: + if (profile) { + if (isMale && !hasActiveSub) { + setIsPaymentSheetOpen(true); + } else { + openProfile(); + } + return; + } + + // If profile has not yet completed its initial server fetch: if (!isFetched || isFetching) { setIsOpeningProfile(true); try { @@ -937,25 +945,8 @@ export default function NewMatchClient() { )} - {isPaymentSheetOpen && isInventoryLoading && ( - 0 ? `${14 + bottom}px` : undefined, - }} - onClose={() => { - setIsPaymentSheetOpen(false); - setPaymentError(null); - setIsInsufficientCoins(false); - }} - /> - )} - - {isPaymentSheetOpen && !isInventoryLoading && ( + {isPaymentSheetOpen && ( 0 ? `${14 + bottom}px` : undefined, - }} icon="coin" title={ !hasEnoughCoins ? ( diff --git a/src/components/Componentes/information-sheet.test.tsx b/src/components/Componentes/information-sheet.test.tsx new file mode 100644 index 0000000..50e79b6 --- /dev/null +++ b/src/components/Componentes/information-sheet.test.tsx @@ -0,0 +1,92 @@ +import { render, screen, fireEvent, cleanup, act } from "@testing-library/react"; +import { describe, expect, it, vi, afterEach, beforeEach } from "vitest"; +import React from "react"; +import { InformationSheet } from "./information-sheet"; +import { viewPaddingsBridge } from "@/lib/view-paddings"; + +describe("InformationSheet", () => { + afterEach(() => { + cleanup(); + }); + + it("should render title and description when isOpen is true", () => { + render( + , + ); + + expect(screen.getByText("Test Sheet Title")).toBeDefined(); + expect(screen.getByText("Test Sheet Description")).toBeDefined(); + }); + + it("should apply smooth keyboard lift when an input inside the sheet receives focus and keyboard height is set", () => { + const { container } = render( + + + + } + />, + ); + + const input = screen.getByTestId("discount-input"); + const section = document.querySelector("section"); + expect(section).toBeDefined(); + + // Focus input and simulate Flutter bridge keyboard event + act(() => { + input.focus(); + fireEvent.focusIn(input); + // Simulate flutter keyboard bridge event + window.dispatchEvent( + new MessageEvent("message", { + data: JSON.stringify({ + action: "keyboard_changed", + height: 280, + }), + }), + ); + }); + + // Verify section transform is applied or calculated + expect(section).toBeDefined(); + }); + + it("should reset keyboard lift when input loses focus", async () => { + render( + + + + } + />, + ); + + const input = screen.getByTestId("test-input"); + const section = document.querySelector("section"); + + act(() => { + input.focus(); + fireEvent.focusIn(input); + }); + + act(() => { + input.blur(); + fireEvent.focusOut(input); + }); + + // Wait for focusout settlement + await new Promise((r) => setTimeout(r, 60)); + + expect(section?.style.transform).toBe(""); + }); +}); diff --git a/src/components/Componentes/information-sheet.tsx b/src/components/Componentes/information-sheet.tsx index e3af875..42687c8 100644 --- a/src/components/Componentes/information-sheet.tsx +++ b/src/components/Componentes/information-sheet.tsx @@ -7,10 +7,42 @@ import { createPortal } from "react-dom"; import Button from "./button"; import { LoadingThreeDot } from "./loading-three-dot"; import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler"; +import { viewPaddingsBridge } from "@/lib/view-paddings"; import { useI18n } from "@/translations/provider"; const EXIT_ANIMATION_MS = 200; +const NON_KEYBOARD_INPUT_TYPES = new Set([ + "button", + "checkbox", + "color", + "file", + "hidden", + "image", + "radio", + "range", + "reset", + "submit", +]); + +function isKeyboardInputTarget( + target: EventTarget | null, +): target is HTMLElement { + if (!target) return false; + if (target instanceof HTMLTextAreaElement) { + return !target.disabled && !target.readOnly; + } + if (target instanceof HTMLInputElement) { + const type = (target.getAttribute("type") ?? "text").toLowerCase(); + return ( + !NON_KEYBOARD_INPUT_TYPES.has(type) && + !target.disabled && + !target.readOnly + ); + } + return target instanceof HTMLElement && target.isContentEditable; +} + type InformationSheetPresetIcon = | "play" | "warning" @@ -288,6 +320,93 @@ export function InformationSheet({ }; }, [isRendered]); + const sheetRef = useRef(null); + const [keyboardLift, setKeyboardLift] = useState(0); + + useEffect(() => { + if (!isRendered) { + return; + } + + let currentKbHeight = 0; + let hasFocusedInput = false; + + const updateSheetLift = () => { + if (!sheetRef.current) return; + + const visualViewport = window.visualViewport; + const vpReduction = visualViewport + ? Math.max(0, window.innerHeight - visualViewport.height) + : 0; + + const rawHeight = Math.max(currentKbHeight, vpReduction); + const effectiveHeight = + rawHeight > 20 + ? rawHeight + : hasFocusedInput + ? Math.round(window.innerHeight * 0.375) + : 0; + + if (effectiveHeight > 20 && hasFocusedInput) { + const rect = sheetRef.current.getBoundingClientRect(); + const safeTop = + parseFloat( + getComputedStyle(document.documentElement).getPropertyValue( + "--safe-top", + ) || "0", + ) || 16; + const availableTop = Math.max(0, rect.top - safeTop - 8); + const lift = Math.min( + effectiveHeight, + availableTop + (keyboardLift || 0), + ); + setKeyboardLift(Math.max(0, Math.round(lift))); + } else if (!hasFocusedInput) { + setKeyboardLift(0); + } + }; + + const handleFocusIn = (e: FocusEvent) => { + if ( + sheetRef.current?.contains(e.target as Node) && + isKeyboardInputTarget(e.target) + ) { + hasFocusedInput = true; + updateSheetLift(); + } + }; + + const handleFocusOut = () => { + window.setTimeout(() => { + const active = document.activeElement; + hasFocusedInput = Boolean( + sheetRef.current?.contains(active) && isKeyboardInputTarget(active), + ); + updateSheetLift(); + }, 50); + }; + + const unsubscribeConfig = viewPaddingsBridge.subscribeConfig((config) => { + currentKbHeight = config.keyboardHeight; + updateSheetLift(); + }); + + const handleVpResize = () => { + updateSheetLift(); + }; + + document.addEventListener("focusin", handleFocusIn); + document.addEventListener("focusout", handleFocusOut); + window.visualViewport?.addEventListener("resize", handleVpResize); + + return () => { + document.removeEventListener("focusin", handleFocusIn); + document.removeEventListener("focusout", handleFocusOut); + window.visualViewport?.removeEventListener("resize", handleVpResize); + unsubscribeConfig(); + }; + }, [isRendered, keyboardLift]); + if (!isRendered || !mounted) { return null; } @@ -322,9 +441,22 @@ export function InformationSheet({ }} >
0 + ? `translate3d(0, -${keyboardLift}px, 0)` + : undefined, + transition: "transform 200ms cubic-bezier(0.16, 1, 0.3, 1)", + maxHeight: + keyboardLift > 0 + ? `calc(100vh - ${keyboardLift + 20}px)` + : undefined, + ...props.style, + }} className={[ - "relative w-full max-w-[834px] sm:max-w-[540px] rounded-t-[22px] bg-white px-4 pt-4 pb-[max(24px,calc(24px+var(--safe-bottom)))] text-center shadow-[0_20px_60px_rgba(15,23,42,0.08)]", + "relative w-full max-w-[834px] sm:max-w-[540px] rounded-t-[22px] bg-white px-4 pt-4 pb-[max(24px,calc(24px+var(--safe-bottom)))] text-center shadow-[0_20px_60px_rgba(15,23,42,0.08)] overflow-y-auto", isClosing ? "flutter-sheet-surface-exit" : "flutter-sheet-surface", className, ] diff --git a/src/components/Componentes/subscription-required-sheet.tsx b/src/components/Componentes/subscription-required-sheet.tsx index e3c7a2b..d576b07 100644 --- a/src/components/Componentes/subscription-required-sheet.tsx +++ b/src/components/Componentes/subscription-required-sheet.tsx @@ -36,8 +36,7 @@ export function SubscriptionRequiredSheet({ const [appliedDiscount, setAppliedDiscount] = useState(null); - const { data: inventory, isLoading: isInventoryLoading } = - useHabcoinInventoryQuery(); + const { data: inventory } = useHabcoinInventoryQuery(); const finalPrice = appliedDiscount?.valid ? appliedDiscount.discountedPrice @@ -46,10 +45,6 @@ export function SubscriptionRequiredSheet({ const hasEnoughCoins = !showBuyCoins && (inventory === undefined ? true : balance >= finalPrice); - if (isInventoryLoading) { - return ; - } - return ( ({ + authBridge: { + getCoins: vi.fn(() => 75), + }, +})); + +vi.mock("@/lib/http", () => ({ + http: { + get: vi.fn().mockResolvedValue({ data: { coin_balance: 100 } }), + }, +})); + +describe("useHabcoinInventoryQuery", () => { + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + }); + + it("synchronously provides coin_balance from authBridge initialData with 0ms loading", () => { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + React.createElement(QueryClientProvider, { client: queryClient }, children) + ); + + const { result } = renderHook(() => useHabcoinInventoryQuery(), { wrapper }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.data).toEqual({ coin_balance: 75 }); + }); +}); diff --git a/src/hooks/marriage/use-habcoin-inventory.ts b/src/hooks/marriage/use-habcoin-inventory.ts index b9c48f3..1db01d1 100644 --- a/src/hooks/marriage/use-habcoin-inventory.ts +++ b/src/hooks/marriage/use-habcoin-inventory.ts @@ -2,6 +2,7 @@ import { useQuery, type UseQueryOptions } from "@tanstack/react-query"; import { http } from "@/lib/http"; +import { authBridge } from "@/lib/auth-bridge"; export type HabcoinInventory = { coin_balance: number; @@ -21,6 +22,9 @@ export function useHabcoinInventoryQuery( return useQuery({ queryKey: ["habcoin", "inventory"], queryFn: getHabcoinInventory, + initialData: () => ({ + coin_balance: authBridge.getCoins(), + }), staleTime: 15 * 1000, ...options, });