From 8d59166fe81219c8dd6bafabc7a125072a21ff2d Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Mon, 10 Aug 2026 09:53:15 +0330 Subject: [PATCH] Show existing-Meshkee signup notice as an acknowledge modal. After admit, continue via SMS OTP so login-otp can link the user to that website with unchanged password/profile. Co-authored-by: Cursor --- apps/customer/src/i18n/messages.ts | 16 +- apps/customer/src/pages/LoginPage.module.css | 108 ++++++++- apps/customer/src/pages/LoginPage.tsx | 222 +++++++++++++++---- apps/customer/src/services/authService.ts | 1 + 4 files changed, 294 insertions(+), 53 deletions(-) diff --git a/apps/customer/src/i18n/messages.ts b/apps/customer/src/i18n/messages.ts index 72a4c10..2b9473c 100644 --- a/apps/customer/src/i18n/messages.ts +++ b/apps/customer/src/i18n/messages.ts @@ -328,10 +328,14 @@ const en = { 'signup.error.length': 'Password must be at least 8 characters.', 'signup.error.create': 'Unable to create account.', 'signup.error.cellExists': - 'This mobile number already has a Meshkee account on {sites}. For a better experience we use that account’s name and password. Sign in with that password, reset it, or use one-time login — then you can access this website too.', + 'This mobile number already has an account in Meshkee Unified Management System (__MESHKEE_APP__) on {sites}. Your password and data will stay the same. Continue to verify by SMS and join this website too.', 'signup.error.cellExistsGeneric': - 'This mobile number already has a Meshkee account on another website. For a better experience we use that account’s name and password. Sign in with that password, reset it, or use one-time login — then you can access this website too.', + 'This mobile number already has an account in Meshkee Unified Management System (__MESHKEE_APP__) on another website. Your password and data will stay the same. Continue to verify by SMS and join this website too.', 'signup.info.verify': 'Account created. Enter the SMS code to verify your number and sign in.', + 'signup.info.verifyExisting': + 'Enter the SMS code to join this website. Your Meshkee password and profile stay the same.', + 'signup.cellExists.title': 'Existing Meshkee account', + 'signup.cellExists.admit': 'I understand, continue', 'forgot.back': 'Back to sign in', 'forgot.title': 'Forgot password', @@ -709,10 +713,14 @@ const fa: Record = { 'signup.error.length': 'رمز عبور باید حداقل ۸ کاراکتر باشد.', 'signup.error.create': 'ایجاد حساب ممکن نشد.', 'signup.error.cellExists': - 'این شماره موبایل قبلاً در مشکی حساب دارد ({sites}). برای تجربه بهتر از همان نام و رمز استفاده می‌کنیم. با همان رمز وارد شوید، رمز را بازیابی کنید، یا ورود یک‌بارمصرف را امتحان کنید — بعد می‌توانید وارد این وب‌سایت هم شوید.', + 'این شماره موبایل قبلاً در سیستم مدیریت یکپارچه ی مشکی (__MESHKEE_APP__) حساب دارد ({sites}). رمز عبور و اطلاعات شما همان خواهد بود. با ادامه، تأیید پیامکی را انجام دهید تا وارد این وب‌سایت هم شوید.', 'signup.error.cellExistsGeneric': - 'این شماره موبایل قبلاً در مشکی (وب‌سایت دیگری) حساب دارد. برای تجربه بهتر از همان نام و رمز استفاده می‌کنیم. با همان رمز وارد شوید، رمز را بازیابی کنید، یا ورود یک‌بارمصرف را امتحان کنید — بعد می‌توانید وارد این وب‌سایت هم شوید.', + 'این شماره موبایل قبلاً در سیستم مدیریت یکپارچه ی مشکی (__MESHKEE_APP__) (وب‌سایت دیگری) حساب دارد. رمز عبور و اطلاعات شما همان خواهد بود. با ادامه، تأیید پیامکی را انجام دهید تا وارد این وب‌سایت هم شوید.', 'signup.info.verify': 'حساب ایجاد شد. کد پیامکی را وارد کنید تا شماره تأیید شود و وارد شوید.', + 'signup.info.verifyExisting': + 'کد پیامکی را وارد کنید تا به این وب‌سایت بپیوندید. رمز عبور و اطلاعات مشکی شما همان می‌ماند.', + 'signup.cellExists.title': 'حساب موجود در مشکی', + 'signup.cellExists.admit': 'متوجه شدم، ادامه', 'forgot.back': 'بازگشت به ورود', 'forgot.title': 'فراموشی رمز عبور', diff --git a/apps/customer/src/pages/LoginPage.module.css b/apps/customer/src/pages/LoginPage.module.css index f225fa7..5e313ea 100644 --- a/apps/customer/src/pages/LoginPage.module.css +++ b/apps/customer/src/pages/LoginPage.module.css @@ -301,13 +301,20 @@ p.footerText { .error { font-size: 13px; - color: #b91c1c; - background: rgba(239, 68, 68, 0.08); - border: 1px solid rgba(239, 68, 68, 0.25); + line-height: 1.55; + color: #fecaca; + background: rgba(239, 68, 68, 0.16); + border: 1px solid rgba(248, 113, 113, 0.45); border-radius: var(--radius-sm); padding: 10px 12px; } +:global(html:not([data-theme='dark'])) .error { + color: #b91c1c; + background: rgba(239, 68, 68, 0.08); + border-color: rgba(239, 68, 68, 0.25); +} + .info { font-size: 13px; color: var(--text-secondary); @@ -317,6 +324,101 @@ p.footerText { padding: 10px 12px; } +.noticeOverlay { + position: fixed; + inset: 0; + z-index: 300; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + background: var(--modal-overlay-bg, rgba(0, 0, 0, 0.6)); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); +} + +.noticeModal { + width: 100%; + max-width: 420px; + padding: 22px 22px 20px; + background: var(--elevated-surface, var(--surface)); + border: 1px solid var(--glass-border); + border-radius: var(--radius); + box-shadow: var(--glass-shadow); +} + +.noticeHeader { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.noticeTitle { + font-size: 18px; + font-weight: 700; + color: var(--text-primary); + line-height: 1.35; +} + +.noticeClose { + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + border-radius: 50%; + color: var(--text-secondary); + transition: background 0.2s, color 0.2s; +} + +.noticeClose:hover { + background: rgba(148, 163, 184, 0.15); + color: var(--text-primary); +} + +.noticeBody { + font-size: 14px; + line-height: 1.75; + color: var(--text-primary); + margin-bottom: 18px; +} + +.noticeLink { + color: var(--primary); + font-weight: 600; + text-decoration: underline; + text-underline-offset: 2px; +} + +.noticeLink:hover { + color: var(--primary-dark, var(--primary)); +} + +.noticeAdmit { + width: 100%; + min-height: var(--field-height); + padding: 11px 16px; + font-size: 14px; + font-weight: 600; + color: #fff; + background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%); + border-radius: var(--radius-sm); + box-shadow: 0 4px 12px rgba(var(--primary-rgb) / 0.3); + transition: transform 0.2s, opacity 0.2s; +} + +.noticeAdmit:hover:not(:disabled) { + transform: translateY(-1px); +} + +.noticeAdmit:disabled { + opacity: 0.65; + cursor: not-allowed; +} + .domainHint { text-align: center; font-size: 12px; diff --git a/apps/customer/src/pages/LoginPage.tsx b/apps/customer/src/pages/LoginPage.tsx index 7d2644c..2244b00 100644 --- a/apps/customer/src/pages/LoginPage.tsx +++ b/apps/customer/src/pages/LoginPage.tsx @@ -1,6 +1,7 @@ -import { useState } from 'react' +import { useState, type ReactNode } from 'react' +import { createPortal } from 'react-dom' import { useNavigate, useSearchParams } from 'react-router-dom' -import { Eye, EyeOff, Smartphone, Lock, KeyRound, ArrowLeft, User } from 'lucide-react' +import { Eye, EyeOff, Smartphone, Lock, KeyRound, ArrowLeft, User, X } from 'lucide-react' import { useAuth, CUSTOMER_ACCESS_MESSAGE, @@ -15,7 +16,7 @@ import { resetPassword, sendOtp, } from '../services/authService' -import { LanguageSelect, PasswordStrengthMeter, isPasswordStrong } from '@meshkee/dashboard-ui' +import { LanguageSelect, PasswordStrengthMeter, isPasswordStrong, useLocale } from '@meshkee/dashboard-ui' import { useT } from '../i18n/useT' import { safeRedirectPath } from '../lib/redirect' import meshkeeLogo from '../assets/meshkee-logo.png' @@ -24,6 +25,38 @@ import styles from './LoginPage.module.css' type AuthView = 'login' | 'signup' | 'forgot' | 'otp' type SmsStep = 'phone' | 'code' +const MESHKEE_APP_MARKER = '__MESHKEE_APP__' +const MESHKEE_SITE_URL = 'https://meshkee.com' + +function parseCellExistsSites(message: string): string | null { + if (message.startsWith('CELL_EXISTS_OTHER_SITE:')) { + return message.slice('CELL_EXISTS_OTHER_SITE:'.length).trim() + } + if (message.includes('Cell number exists on another account')) { + return '' + } + return null +} + +function renderWithMeshkeeLink(message: string, linkClassName: string): ReactNode { + const idx = message.indexOf(MESHKEE_APP_MARKER) + if (idx === -1) return message + return ( + <> + {message.slice(0, idx)} + + Meshkee.app + + {message.slice(idx + MESHKEE_APP_MARKER.length)} + + ) +} + export function LoginPage() { const navigate = useNavigate() const [searchParams] = useSearchParams() @@ -32,6 +65,7 @@ export function LoginPage() { const { businessName, businessNameEn, logoUrl } = useTenantBranding() const tenantDomain = getTenantDomain() const t = useT() + const { locale, dir } = useLocale() const [view, setView] = useState('login') const [smsStep, setSmsStep] = useState('phone') @@ -49,6 +83,8 @@ export function LoginPage() { const [error, setError] = useState('') const [info, setInfo] = useState('') const [isSubmitting, setIsSubmitting] = useState(false) + /** `null` closed; `''` generic notice; otherwise site names */ + const [cellExistsSites, setCellExistsSites] = useState(null) function clearMessages() { setError('') @@ -67,6 +103,7 @@ export function LoginPage() { setSmsStep('phone') setCodeSent(false) setShowPassword(false) + setCellExistsSites(null) clearMessages() } @@ -102,18 +139,6 @@ export function LoginPage() { err.message.includes('Unable to send SMS') ) { setError(t('login.error.smsProvider')) - } else if ( - err.message.includes('CELL_EXISTS_OTHER_SITE:') || - err.message.includes('Cell number exists on another account') - ) { - const sites = err.message.startsWith('CELL_EXISTS_OTHER_SITE:') - ? err.message.slice('CELL_EXISTS_OTHER_SITE:'.length).trim() - : '' - setError( - sites - ? t('signup.error.cellExists', { sites }) - : t('signup.error.cellExistsGeneric'), - ) } else { setError(err.message) } @@ -177,20 +202,7 @@ export function LoginPage() { } } - async function handleSignup(e: React.FormEvent) { - e.preventDefault() - clearMessages() - - if (password !== confirmPassword) { - setError(t('signup.error.match')) - return - } - - if (!isPasswordStrong(password)) { - setError(t('forgot.error.strength')) - return - } - + async function runSignup() { const cellNumber = resolveCellNumber() if (!cellNumber) return @@ -205,6 +217,8 @@ export function LoginPage() { domain: tenantDomain, }) + setCellExistsSites(null) + if (data.user.customerBusinesses.length === 0) { logoutRequest() setError(CUSTOMER_ACCESS_MESSAGE) @@ -213,35 +227,101 @@ export function LoginPage() { if (!data.user.cellVerifiedAt) { logoutRequest() - setView('otp') - setSmsStep('phone') - setPassword('') - setConfirmPassword('') - setSmsCode('') - setInfo(t('signup.info.verify')) - try { - const otpResult = await sendOtp(cellNumber, tenantDomain) - if (!otpResult.enabled) { - setInfo(otpResult.message) - } - setCodeSent(true) - setSmsStep('code') - startCountdown() - } catch (otpErr) { - handleApiError(otpErr, t('login.error.sendCode')) - } + await continueToSmsVerify(cellNumber) return } await login(cellNumber, password) navigate(redirectTo) } catch (err) { + if (err instanceof ApiError) { + const sites = parseCellExistsSites(err.message) + if (sites !== null) { + setCellExistsSites(sites) + return + } + } + setCellExistsSites(null) handleApiError(err, t('signup.error.create')) } finally { setIsSubmitting(false) } } + async function continueToSmsVerify(cellNumber: string) { + setView('otp') + setSmsStep('phone') + setPassword('') + setConfirmPassword('') + setSmsCode('') + setInfo(t('signup.info.verify')) + try { + const otpResult = await sendOtp(cellNumber, tenantDomain) + if (!otpResult.enabled) { + setInfo(otpResult.message) + } + setCodeSent(true) + setSmsStep('code') + startCountdown() + } catch (otpErr) { + handleApiError(otpErr, t('login.error.sendCode')) + } + } + + async function handleSignup(e: React.FormEvent) { + e.preventDefault() + clearMessages() + setCellExistsSites(null) + + if (password !== confirmPassword) { + setError(t('signup.error.match')) + return + } + + if (!isPasswordStrong(password)) { + setError(t('forgot.error.strength')) + return + } + + await runSignup() + } + + /** + * Existing Meshkee account on another site: user admits the notice, then proves + * phone ownership via SMS. login-otp links them to this website (password/data unchanged), + * so they appear in the business Customers list after verification. + */ + async function handleAdmitCellExists() { + clearMessages() + const cellNumber = resolveCellNumber() + if (!cellNumber) { + setCellExistsSites(null) + return + } + + setIsSubmitting(true) + setCellExistsSites(null) + try { + setInfo(t('signup.info.verifyExisting')) + setView('otp') + setSmsStep('phone') + setPassword('') + setConfirmPassword('') + setSmsCode('') + const otpResult = await sendOtp(cellNumber, tenantDomain) + if (!otpResult.enabled) { + setInfo(otpResult.message) + } + setCodeSent(true) + setSmsStep('code') + startCountdown() + } catch (otpErr) { + handleApiError(otpErr, t('login.error.sendCode')) + } finally { + setIsSubmitting(false) + } + } + async function handleResetPassword(e: React.FormEvent) { e.preventDefault() clearMessages() @@ -842,6 +922,56 @@ export function LoginPage() { > Powered by Meshkee.app + + {cellExistsSites !== null && + createPortal( +
!isSubmitting && setCellExistsSites(null)} + > +
e.stopPropagation()} + > +
+

+ {t('signup.cellExists.title')} +

+ +
+

+ {renderWithMeshkeeLink( + cellExistsSites + ? t('signup.error.cellExists', { sites: cellExistsSites }) + : t('signup.error.cellExistsGeneric'), + styles.noticeLink, + )} +

+ +
+
, + document.body, + )} ) } diff --git a/apps/customer/src/services/authService.ts b/apps/customer/src/services/authService.ts index 8d83194..cfcfd1d 100644 --- a/apps/customer/src/services/authService.ts +++ b/apps/customer/src/services/authService.ts @@ -31,6 +31,7 @@ export async function register(input: { lastName: string email?: string domain: string + acknowledgeExistingAccount?: boolean }) { const data = await apiRequest('/auth/register', { method: 'POST',