|
|
@@ -61,6 +61,88 @@ const objInfo={
|
|
|
inSubmit:false
|
|
|
}
|
|
|
|
|
|
+// Compact OTP entry: N individual single-digit boxes. First box is focused
|
|
|
+// on mount; typing a digit auto-advances to the next box, Backspace on an
|
|
|
+// empty box jumps back, and pasting a code spreads it across all boxes.
|
|
|
+const OtpBoxes = ({length = 4, value = "", onChange, error = false}) => {
|
|
|
+ const refs = React.useRef([]);
|
|
|
+ React.useEffect(() => {
|
|
|
+ // focus the first box once when the OTP step appears
|
|
|
+ if (refs.current[0]) refs.current[0].focus();
|
|
|
+ // eslint-disable-next-line
|
|
|
+ }, []);
|
|
|
+ const digits = String(value || "").replace(/\D+/g, "").slice(0, length).split("");
|
|
|
+ while (digits.length < length) digits.push("");
|
|
|
+ const update = (next) => {
|
|
|
+ const joined = next.join("").slice(0, length);
|
|
|
+ if (onChange) onChange(joined);
|
|
|
+ };
|
|
|
+ const onBoxChange = (i, raw) => {
|
|
|
+ const ch = String(raw || "").replace(/\D+/g, "");
|
|
|
+ if (ch.length === 0) {
|
|
|
+ const next = [...digits];
|
|
|
+ next[i] = "";
|
|
|
+ update(next);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ // accept paste / multi-char by spreading across remaining boxes
|
|
|
+ const chars = ch.split("");
|
|
|
+ const next = [...digits];
|
|
|
+ let pos = i;
|
|
|
+ for (let k = 0; k < chars.length && pos < length; k++, pos++) {
|
|
|
+ next[pos] = chars[k];
|
|
|
+ }
|
|
|
+ update(next);
|
|
|
+ const target = Math.min(pos, length - 1);
|
|
|
+ if (refs.current[target]) refs.current[target].focus();
|
|
|
+ };
|
|
|
+ const onKeyDown = (i, e) => {
|
|
|
+ if (e.key === "Backspace" && !digits[i] && i > 0) {
|
|
|
+ e.preventDefault();
|
|
|
+ const next = [...digits];
|
|
|
+ next[i - 1] = "";
|
|
|
+ update(next);
|
|
|
+ if (refs.current[i - 1]) refs.current[i - 1].focus();
|
|
|
+ } else if (e.key === "ArrowLeft" && i > 0) {
|
|
|
+ if (refs.current[i - 1]) refs.current[i - 1].focus();
|
|
|
+ } else if (e.key === "ArrowRight" && i < length - 1) {
|
|
|
+ if (refs.current[i + 1]) refs.current[i + 1].focus();
|
|
|
+ }
|
|
|
+ };
|
|
|
+ const boxStyle = {
|
|
|
+ width: '3.25rem',
|
|
|
+ height: '3.25rem',
|
|
|
+ textAlign: 'center',
|
|
|
+ fontSize: '1.5rem',
|
|
|
+ fontWeight: 700,
|
|
|
+ color: 'var(--header)',
|
|
|
+ background: 'var(--input-bg, rgba(255,255,255,0.04))',
|
|
|
+ border: `1px solid ${error ? '#ff5252' : 'var(--input-border, rgba(255,255,255,0.15))'}`,
|
|
|
+ borderRadius: '0.6rem',
|
|
|
+ outline: 'none',
|
|
|
+ caretColor: '#3bc117',
|
|
|
+ };
|
|
|
+ return (
|
|
|
+ <div style={{display:'flex',justifyContent:'center',gap:'0.75rem'}}>
|
|
|
+ {digits.map((d, i) => (
|
|
|
+ <input
|
|
|
+ key={i}
|
|
|
+ ref={(el) => { refs.current[i] = el; }}
|
|
|
+ type="text"
|
|
|
+ inputMode="numeric"
|
|
|
+ autoComplete="one-time-code"
|
|
|
+ maxLength={1}
|
|
|
+ value={d}
|
|
|
+ onChange={(e) => onBoxChange(i, e.target.value)}
|
|
|
+ onKeyDown={(e) => onKeyDown(i, e)}
|
|
|
+ onFocus={(e) => e.target.select()}
|
|
|
+ style={boxStyle}
|
|
|
+ />
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+};
|
|
|
+
|
|
|
const LoginForm = ({close}) => {
|
|
|
const {theme,isMobile} = useThemeProvider();
|
|
|
const [showType,setShowType]=useState(TYPE_LOGIN);
|
|
|
@@ -73,7 +155,8 @@ const LoginForm = ({close}) => {
|
|
|
const dispatch = useDispatch();
|
|
|
const navigate=useNavigate();
|
|
|
const [loginType,setLoginType]=useState(LOGIN_TYPE_PHONE);
|
|
|
- const {register, handleSubmit, formState: {errors}, control} = useForm({
|
|
|
+ const {register, handleSubmit, formState: {errors, isValid}, control, trigger, getValues, watch} = useForm({
|
|
|
+ mode: 'onChange',
|
|
|
defaultValues: {
|
|
|
// country:'55',
|
|
|
account: '',
|
|
|
@@ -94,6 +177,10 @@ const LoginForm = ({close}) => {
|
|
|
const smsLoginStatus=useSelector(getSMSLoginStatus);
|
|
|
const isAccountLogin=useSelector(getAccountLogin);
|
|
|
const [regType,setRegType]=useState(REG_TYPE_PHONE);
|
|
|
+ // SMS-register two-step flow:
|
|
|
+ // 1 = collect phone + password (then click "Next" to send OTP)
|
|
|
+ // 2 = enter OTP code, then submit registration
|
|
|
+ const [smsRegStep,setSmsRegStep]=useState(1);
|
|
|
const loginOpen=useSelector(getLoginOpen);
|
|
|
const registerOpen=useSelector(getRegisterOpen);
|
|
|
//登录类型列表
|
|
|
@@ -201,6 +288,11 @@ const LoginForm = ({close}) => {
|
|
|
setRegType(list[0].type);
|
|
|
}
|
|
|
},[registerOpen])
|
|
|
+ // Reset SMS-register flow back to step 1 when leaving the SMS reg tab
|
|
|
+ // or navigating between login/register pages.
|
|
|
+ useEffect(()=>{
|
|
|
+ setSmsRegStep(1);
|
|
|
+ },[regType,showType])
|
|
|
//effect_______
|
|
|
const navigatRoute=(route)=>{
|
|
|
navigate(route,{replace:true});
|
|
|
@@ -765,12 +857,34 @@ const LoginForm = ({close}) => {
|
|
|
</form>
|
|
|
)
|
|
|
}
|
|
|
- const renderRegSms=()=>{
|
|
|
+ // ---- SMS Register (two-step) -------------------------------------
|
|
|
+ // Step 1: collect phone + password, then click "Next" to send the OTP
|
|
|
+ // and advance to step 2.
|
|
|
+ // Step 2: show the 4-digit OTP input + countdown; submit triggers the
|
|
|
+ // original reqRegister with all fields (password is mirrored
|
|
|
+ // to repassword since we no longer ask the user twice).
|
|
|
+ const onClickRegSmsNext = async () => {
|
|
|
+ const ok = await trigger(['phone', 'password']);
|
|
|
+ if (!ok) return;
|
|
|
+ // fire OTP and switch to step 2
|
|
|
+ const phone = getValues('phone');
|
|
|
+ if (phone == null || phone === '') return;
|
|
|
+ if (otpStatus !== REQ_STATUS.LOADING) {
|
|
|
+ dispatch(reqSendCode({phone, country}));
|
|
|
+ }
|
|
|
+ setSmsRegStep(2);
|
|
|
+ };
|
|
|
+ const formatPhoneDisplay = (digits) => {
|
|
|
+ try {
|
|
|
+ const {applyPhoneMask, getPhoneFormat} = require('@db/phoneFormats');
|
|
|
+ return applyPhoneMask(digits || '', getPhoneFormat(country).mask);
|
|
|
+ } catch (e) {
|
|
|
+ return digits || '';
|
|
|
+ }
|
|
|
+ };
|
|
|
+ const renderRegSmsStep1 = () => {
|
|
|
return (
|
|
|
- <form key={"reg_sms"} autoComplete="off" className={styles.l_form} onSubmit={handleSubmit((data) => {
|
|
|
- data.type=APP_LOGIN_TYPE.SMS;
|
|
|
- onSubmitReg(data);
|
|
|
- })}>
|
|
|
+ <form key={"reg_sms_1"} autoComplete="off" className={styles.l_form} onSubmit={(e)=>{e.preventDefault(); onClickRegSmsNext();}}>
|
|
|
<Controller control={control}
|
|
|
name="phone"
|
|
|
rules={{required: true, validate: validatePhone}}
|
|
|
@@ -801,63 +915,52 @@ const LoginForm = ({close}) => {
|
|
|
innerRef={ref}/>
|
|
|
)}
|
|
|
/>
|
|
|
- <Controller control={control}
|
|
|
- name="repassword"
|
|
|
- rules={{required: true, minLength: 6}}
|
|
|
- render={({field: {ref, onChange, value}, fieldState: {error}}) => (
|
|
|
- <PasswordInput
|
|
|
- className={classNames({'input-control--error': error})}
|
|
|
- value={value}
|
|
|
- message={error ? i18n.t("form_errors.pass") : ''}
|
|
|
- onChange={e => onChange(e.target.value)}
|
|
|
- placeholder={i18n.t('login.re_pwd')}
|
|
|
- innerRef={ref}/>
|
|
|
- )}
|
|
|
- />
|
|
|
+ {(() => {
|
|
|
+ // gate the "Next" button on field validity (subscribe via watch)
|
|
|
+ const phoneDigits = watch('phone') || '';
|
|
|
+ const pwd = watch('password') || '';
|
|
|
+ const phoneOk = validatePhone(phoneDigits);
|
|
|
+ const pwdOk = pwd.length >= 6;
|
|
|
+ const enabled = phoneOk && pwdOk && otpStatus !== REQ_STATUS.LOADING;
|
|
|
+ return (
|
|
|
+ <button type="submit" className={"button-green"} disabled={!enabled} style={!enabled?{opacity:0.5,cursor:'not-allowed'}:undefined}>
|
|
|
+ {otpStatus === REQ_STATUS.LOADING ? renderLoading() : i18n.t("login.next")}
|
|
|
+ </button>
|
|
|
+ );
|
|
|
+ })()}
|
|
|
+ {renderRegToLogin()}
|
|
|
+ </form>
|
|
|
+ );
|
|
|
+ };
|
|
|
+ const renderRegSmsStep2 = () => {
|
|
|
+ return (
|
|
|
+ <form key={"reg_sms_2"} autoComplete="off" className={styles.l_form} onSubmit={handleSubmit((data) => {
|
|
|
+ data.type = APP_LOGIN_TYPE.SMS;
|
|
|
+ // user only entered password once — mirror to repassword for the API
|
|
|
+ data.repassword = data.password;
|
|
|
+ onSubmitReg(data);
|
|
|
+ })}>
|
|
|
<Controller control={control}
|
|
|
name="code"
|
|
|
- rules={{required: true}}
|
|
|
- render={({field: {ref, onChange, value}, fieldState: {error}}) => {
|
|
|
- return (
|
|
|
- <div className={`d-flex flex-column g-6`}>
|
|
|
- <div
|
|
|
- className={`input-control ${classNames({'input-control--error': error})}`}>
|
|
|
- <input
|
|
|
- type={'text'}
|
|
|
- autoComplete="off"
|
|
|
- ref={ref}
|
|
|
- onChange={e => onChange(e.target.value)}
|
|
|
- placeholder={i18n.t('login.otp_ph')}
|
|
|
- />
|
|
|
- <button type={"button"}
|
|
|
- className={`${styles.otp_btn} ${otpCD > 0 ? styles.cd_timer : ''}`}
|
|
|
- onClick={onSendCode}>
|
|
|
- {
|
|
|
- otpStatus === REQ_STATUS.LOADING ? (
|
|
|
- <CircularProgress color="success" size={22} thickness={5}/>) : (
|
|
|
- <>
|
|
|
- {
|
|
|
- otpCD <= 0 ? i18n.t('login.btn_otp') : otpCD + "S"
|
|
|
- }
|
|
|
- </>
|
|
|
- )
|
|
|
- }
|
|
|
- </button>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- )
|
|
|
- }}
|
|
|
+ rules={{required: true, minLength: 4}}
|
|
|
+ render={({field: {onChange, value}, fieldState: {error}}) => (
|
|
|
+ <OtpBoxes length={4} value={value || ''} error={!!error} onChange={onChange}/>
|
|
|
+ )}
|
|
|
/>
|
|
|
- <button className={"button-green"}>
|
|
|
- {
|
|
|
- regStatus === REQ_STATUS.LOADING ? renderLoading() : i18n.t("login.sign_up_btn")
|
|
|
- }
|
|
|
- </button>
|
|
|
- {/*{renderGuestReg()}*/}
|
|
|
+ {(() => {
|
|
|
+ const code = watch('code') || '';
|
|
|
+ const enabled = code.length >= 4 && regStatus !== REQ_STATUS.LOADING;
|
|
|
+ return (
|
|
|
+ <button type="submit" className={"button-green"} disabled={!enabled} style={!enabled?{opacity:0.5,cursor:'not-allowed'}:undefined}>
|
|
|
+ {regStatus === REQ_STATUS.LOADING ? renderLoading() : i18n.t("login.sign_up_btn")}
|
|
|
+ </button>
|
|
|
+ );
|
|
|
+ })()}
|
|
|
{renderRegToLogin()}
|
|
|
</form>
|
|
|
- )
|
|
|
- }
|
|
|
+ );
|
|
|
+ };
|
|
|
+ const renderRegSms = () => smsRegStep === 2 ? renderRegSmsStep2() : renderRegSmsStep1();
|
|
|
const renderRegMail=()=>{
|
|
|
return (
|
|
|
<form key={"reg_mail"} autoComplete="off" className={styles.l_form} onSubmit={handleSubmit((data) => {
|