- ClientLayout: implement proper manual scroll restoration using module-level _wasPopNavigation flag and sessionStorage. Fixes scroll jumping to top/bottom on Back navigation. Scroll position saved on scroll (debounced) and cleanup, restored via double-rAF on popstate navigation. - AuthModal/LoginModal: fix Android back button to push only ONE history sentinel on modal open (not on every subView/step change). Prevents stacking of extra history entries. Back in sub-views re-pushes sentinel for next back press. - AuthModal/LoginModal: remove navigator.credentials.get (WebOTP API) which was showing an intrusive permission modal. The native autoComplete=one-time-code on the first OTP input provides the suggestion bar above the keyboard instead. - AuthModal/LoginModal: add isVerifyingRef guard to prevent double toast on successful login. Both onComplete callback and form submit were calling triggerVerifyOtp/handleVerifyOtpWithCode simultaneously. - AuthModal: remove triggerVerifyOtp from handleOtpChange to eliminate the second duplicate call (only onComplete now triggers verification). - OtpInput5: remove maxLength=1 restriction from first input so browser can inject the full 5-digit OTP code. Multi-char input is already handled by handleDigitChange which distributes digits across all boxes. - ProductPage: fix swipe gesture direction in media gallery. Swipe right -> next item, swipe left -> previous item (matches RTL intuition for Persian users).
145 lines
4.5 KiB
TypeScript
145 lines
4.5 KiB
TypeScript
"use client";
|
|
|
|
import React, { useRef, useEffect } from "react";
|
|
import { toEnglishDigits } from "../lib/utils";
|
|
|
|
interface OtpInput5Props {
|
|
value: string;
|
|
onChange: (val: string) => void;
|
|
disabled?: boolean;
|
|
autoFocus?: boolean;
|
|
onComplete?: (code: string) => void;
|
|
}
|
|
|
|
export default function OtpInput5({
|
|
value,
|
|
onChange,
|
|
disabled = false,
|
|
autoFocus = true,
|
|
onComplete,
|
|
}: OtpInput5Props) {
|
|
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
|
|
|
|
// Array of 5 characters
|
|
const digits = Array.from({ length: 5 }, (_, i) => value[i] || "");
|
|
|
|
useEffect(() => {
|
|
if (autoFocus && inputRefs.current[0]) {
|
|
const timer = setTimeout(() => {
|
|
inputRefs.current[0]?.focus({ preventScroll: true });
|
|
}, 80);
|
|
return () => clearTimeout(timer);
|
|
}
|
|
}, [autoFocus]);
|
|
|
|
const handleDigitChange = (index: number, char: string) => {
|
|
const cleanChars = toEnglishDigits(char).replace(/[^0-9]/g, "");
|
|
if (!cleanChars) {
|
|
// Empty / Backspace in controlled input
|
|
const newDigits = [...digits];
|
|
newDigits[index] = "";
|
|
const newVal = newDigits.join("");
|
|
onChange(newVal);
|
|
return;
|
|
}
|
|
|
|
// If multiple chars pasted or typed
|
|
if (cleanChars.length > 1) {
|
|
const slice5 = cleanChars.slice(0, 5);
|
|
onChange(slice5);
|
|
const nextIndex = Math.min(slice5.length, 4);
|
|
inputRefs.current[nextIndex]?.focus();
|
|
if (slice5.length === 5 && onComplete) {
|
|
onComplete(slice5);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const single = cleanChars[0];
|
|
const newDigits = [...digits];
|
|
newDigits[index] = single;
|
|
const newVal = newDigits.join("");
|
|
onChange(newVal);
|
|
|
|
if (index < 4) {
|
|
inputRefs.current[index + 1]?.focus();
|
|
}
|
|
|
|
if (newVal.length === 5 && onComplete) {
|
|
onComplete(newVal);
|
|
}
|
|
};
|
|
|
|
const handleKeyDown = (index: number, e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
if (e.key === "Backspace") {
|
|
if (!digits[index] && index > 0) {
|
|
// Move to previous and clear
|
|
inputRefs.current[index - 1]?.focus();
|
|
const newDigits = [...digits];
|
|
newDigits[index - 1] = "";
|
|
onChange(newDigits.join(""));
|
|
}
|
|
} else if (e.key === "ArrowLeft") {
|
|
// In LTR inputs, Left goes to previous
|
|
if (index > 0) {
|
|
inputRefs.current[index - 1]?.focus();
|
|
}
|
|
} else if (e.key === "ArrowRight") {
|
|
// In LTR inputs, Right goes to next
|
|
if (index < 4) {
|
|
inputRefs.current[index + 1]?.focus();
|
|
}
|
|
}
|
|
};
|
|
|
|
const handlePaste = (e: React.ClipboardEvent) => {
|
|
e.preventDefault();
|
|
const pasted = e.clipboardData.getData("text");
|
|
const clean = toEnglishDigits(pasted).replace(/[^0-9]/g, "").slice(0, 5);
|
|
if (clean) {
|
|
onChange(clean);
|
|
const targetIndex = Math.min(clean.length, 4);
|
|
inputRefs.current[targetIndex]?.focus();
|
|
if (clean.length === 5 && onComplete) {
|
|
onComplete(clean);
|
|
}
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="flex items-center justify-center gap-2.5 sm:gap-3.5 my-2" dir="ltr">
|
|
{[0, 1, 2, 3, 4].map((idx) => {
|
|
const val = digits[idx] || "";
|
|
const isFilled = Boolean(val);
|
|
|
|
return (
|
|
<input
|
|
key={idx}
|
|
ref={(el) => {
|
|
inputRefs.current[idx] = el;
|
|
}}
|
|
type="tel"
|
|
inputMode="numeric"
|
|
pattern="[0-9]*"
|
|
// idx === 0: no maxLength so browser OTP autofill can inject the full code.
|
|
// The handleDigitChange handler distributes multi-char input across all boxes.
|
|
maxLength={idx === 0 ? undefined : 1}
|
|
autoComplete={idx === 0 ? "one-time-code" : "off"}
|
|
value={val}
|
|
disabled={disabled}
|
|
onChange={(e) => handleDigitChange(idx, e.target.value)}
|
|
onKeyDown={(e) => handleKeyDown(idx, e)}
|
|
onPaste={handlePaste}
|
|
onFocus={(e) => e.target.select()}
|
|
className={`w-12 h-14 sm:w-14 sm:h-16 text-center text-xl sm:text-2xl font-black font-mono rounded-2xl border-2 transition-all outline-none shadow-xs ${
|
|
isFilled
|
|
? "border-canina-blue bg-canina-blue/5 text-canina-blue ring-2 ring-canina-blue/20"
|
|
: "border-medical-gray-200 bg-medical-gray-50/80 text-medical-gray-900 focus:border-canina-blue focus:bg-white focus:ring-4 focus:ring-canina-blue/15"
|
|
} ${disabled ? "opacity-50 cursor-not-allowed" : "cursor-text"}`}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|