Browse Source

feat: enable automatic keyboard-aware sheet lifting and optimize inventory fetching with synchronous initial data

master
mortezaei 2 weeks ago
parent
commit
78b1afa550
  1. 37
      src/app/new-match/new-match-client.tsx
  2. 92
      src/components/Componentes/information-sheet.test.tsx
  3. 134
      src/components/Componentes/information-sheet.tsx
  4. 7
      src/components/Componentes/subscription-required-sheet.tsx
  5. 43
      src/hooks/marriage/use-habcoin-inventory.test.ts
  6. 4
      src/hooks/marriage/use-habcoin-inventory.ts

37
src/app/new-match/new-match-client.tsx

@ -507,10 +507,9 @@ export default function NewMatchClient() {
const [appliedDiscount, setAppliedDiscount] =
useState<CheckDiscountResult | null>(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() {
</div>
)}
{isPaymentSheetOpen && isInventoryLoading && (
<InformationSheet
isLoading={true}
style={{
paddingBottom: bottom > 0 ? `${14 + bottom}px` : undefined,
}}
onClose={() => {
setIsPaymentSheetOpen(false);
setPaymentError(null);
setIsInsufficientCoins(false);
}}
/>
)}
{isPaymentSheetOpen && !isInventoryLoading && (
{isPaymentSheetOpen && (
<InformationSheet
style={{
paddingBottom: bottom > 0 ? `${14 + bottom}px` : undefined,
}}
icon="coin"
title={
!hasEnoughCoins ? (

92
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(
<InformationSheet
isOpen={true}
title="Test Sheet Title"
description="Test Sheet Description"
/>,
);
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(
<InformationSheet
isOpen={true}
title="Payment Sheet"
description={
<div>
<input type="text" placeholder="Enter discount code" data-testid="discount-input" />
</div>
}
/>,
);
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(
<InformationSheet
isOpen={true}
title="Payment Sheet"
description={
<div>
<input type="text" placeholder="Enter code" data-testid="test-input" />
</div>
}
/>,
);
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("");
});
});

134
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<HTMLElement>(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({
}}
>
<section
ref={sheetRef}
{...props}
style={{
transform:
keyboardLift > 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,
]

7
src/components/Componentes/subscription-required-sheet.tsx

@ -36,8 +36,7 @@ export function SubscriptionRequiredSheet({
const [appliedDiscount, setAppliedDiscount] =
useState<CheckDiscountResult | null>(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 <InformationSheet isLoading={true} onClose={onClose} />;
}
return (
<InformationSheet
icon="coin"

43
src/hooks/marriage/use-habcoin-inventory.test.ts

@ -0,0 +1,43 @@
import { renderHook } from "@testing-library/react";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import React from "react";
import { useHabcoinInventoryQuery } from "./use-habcoin-inventory";
import { authBridge } from "@/lib/auth-bridge";
vi.mock("@/lib/auth-bridge", () => ({
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 });
});
});

4
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,
});

Loading…
Cancel
Save