canina/frontend/application/components/OtpInput5.tsx
parsa aghaei 86e8101d8c
Some checks failed
Deploy Canina / deploy (push) Successful in 2m27s
E2E Playwright Tests / Run Full E2E & Security Suites (push) Failing after 0s
fix: mobile UX improvements - scroll restoration, OTP autofill, swipe direction, back button
- 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).
2026-09-05 18:19:11 +03:30

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