Add OTP-first customer signup and product catalog PDF field.

Registration collects name and phone, verifies SMS, then asks for a password only for new Meshkee accounts. Product add/edit supports an optional catalog PDF.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-27 08:57:08 +03:30
co-authored by Cursor
parent e2950fa20e
commit 6d85a217e1
12 changed files with 746 additions and 292 deletions
+12
View File
@@ -679,6 +679,12 @@ const en = {
'products.form.imagesAdd': 'Add images',
'products.form.imagesHint': 'Upload multiple product images. Click + to add more.',
'products.form.imagesRemove': 'Remove image {index}',
'products.form.catalog': 'Product catalog (PDF)',
'products.form.catalogHint': 'Optional. PDF only, maximum 2MB.',
'products.form.catalogView': 'View catalog',
'products.form.catalogRemove': 'Remove catalog file',
'products.form.catalog.errorType': 'Catalog file must be a PDF.',
'products.form.catalog.errorSize': 'Catalog PDF must be 2MB or smaller.',
'products.form.tags': 'Tags',
'products.form.tagsPlaceholder': 'Type a tag and press Enter',
'products.form.cancel': 'Cancel',
@@ -3440,6 +3446,12 @@ const fa: Record<MessageKey, string> = {
'products.form.imagesAdd': 'افزودن تصاویر',
'products.form.imagesHint': 'چند تصویر محصول آپلود کنید. برای افزودن بیشتر روی + کلیک کنید.',
'products.form.imagesRemove': 'حذف تصویر {index}',
'products.form.catalog': 'کاتالوگ محصول (PDF)',
'products.form.catalogHint': 'اختیاری. فقط PDF، حداکثر ۲ مگابایت.',
'products.form.catalogView': 'مشاهده کاتالوگ',
'products.form.catalogRemove': 'حذف فایل کاتالوگ',
'products.form.catalog.errorType': 'فایل کاتالوگ باید PDF باشد.',
'products.form.catalog.errorSize': 'حجم PDF کاتالوگ باید حداکثر ۲ مگابایت باشد.',
'products.form.tags': 'برچسب‌ها',
'products.form.tagsPlaceholder': 'برچسب را بنویسید و Enter بزنید',
'products.form.cancel': 'انصراف',
@@ -87,6 +87,64 @@
color: var(--text-muted);
}
.catalogHint {
margin: 0 0 8px;
font-size: 12px;
color: var(--text-muted);
}
.catalogRow {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
}
.catalogInput {
max-width: 100%;
font-size: 13px;
}
.catalogSelected {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: var(--radius-sm);
background: rgba(148, 163, 184, 0.12);
font-size: 13px;
color: var(--text-secondary);
min-width: 0;
}
.catalogSelected span,
.catalogLink {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 280px;
}
.catalogLink {
color: var(--primary);
text-decoration: underline;
}
.catalogClear {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px;
border-radius: 4px;
color: var(--text-muted);
flex-shrink: 0;
}
.catalogClear:hover {
color: var(--text);
background: rgba(148, 163, 184, 0.2);
}
.summaryField textarea {
min-height: calc((var(--field-height) + 8px) * 2);
padding-inline-end: 12px;
+101 -2
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Link, useParams, useNavigate } from 'react-router-dom'
import { Sparkles } from 'lucide-react'
import { FileText, Sparkles, X } from 'lucide-react'
import { CategoryTreeSelect } from '../components/CategoryTreeSelect'
import { SearchableSelect } from '../components/SearchableSelect'
import { ImageCropper } from '../components/ImageCropper'
@@ -18,6 +18,7 @@ import {
galleryMediaIdsAfterChange,
resolveDataUrlToMediaId,
resolveDataUrlsToMediaIds,
uploadCatalogPdf,
} from '../services/mediaService'
import {
createProduct,
@@ -34,12 +35,15 @@ import aiStyles from '../styles/ai.module.css'
import styles from './AddNewProductPage.module.css'
import { useT } from '../i18n/useT'
const CATALOG_PDF_MAX_BYTES = 2 * 1024 * 1024
export function AddNewProductPage() {
const { id } = useParams()
const navigate = useNavigate()
const t = useT()
const { showToast } = useToast()
const isEdit = Boolean(id)
const catalogInputRef = useRef<HTMLInputElement>(null)
const [categories, setCategories] = useState<Category[]>([])
const [brandOptions, setBrandOptions] = useState<FlatCategory[]>([])
@@ -54,6 +58,11 @@ export function AddNewProductPage() {
const [tags, setTags] = useState<string[]>([])
const [thumbnailMediaId, setThumbnailMediaId] = useState<string | null>(null)
const [galleryMediaIds, setGalleryMediaIds] = useState<string[]>([])
const [catalogMediaId, setCatalogMediaId] = useState<string | null>(null)
const [catalogUrl, setCatalogUrl] = useState<string | null>(null)
const [catalogFileName, setCatalogFileName] = useState<string | null>(null)
const [catalogFile, setCatalogFile] = useState<File | null>(null)
const [catalogRemoved, setCatalogRemoved] = useState(false)
const [loaded, setLoaded] = useState(!isEdit)
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState('')
@@ -172,6 +181,11 @@ export function AddNewProductPage() {
setTags(form.tags)
setThumbnailMediaId(form.thumbnailMediaId)
setGalleryMediaIds(form.galleryMediaIds)
setCatalogMediaId(form.catalogMediaId)
setCatalogUrl(form.catalogUrl)
setCatalogFileName(form.catalogFileName)
setCatalogFile(null)
setCatalogRemoved(false)
setLoaded(true)
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
@@ -185,6 +199,39 @@ export function AddNewProductPage() {
if (!loaded) return null
function clearCatalogFile() {
setCatalogFile(null)
setCatalogMediaId(null)
setCatalogUrl(null)
setCatalogFileName(null)
setCatalogRemoved(true)
if (catalogInputRef.current) {
catalogInputRef.current.value = ''
}
}
function handleCatalogFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0] ?? null
if (!file) return
if (file.type !== 'application/pdf' && !file.name.toLowerCase().endsWith('.pdf')) {
setError(t('products.form.catalog.errorType'))
e.target.value = ''
return
}
if (file.size > CATALOG_PDF_MAX_BYTES) {
setError(t('products.form.catalog.errorSize'))
e.target.value = ''
return
}
setError('')
setCatalogFile(file)
setCatalogFileName(file.name)
setCatalogUrl(null)
setCatalogRemoved(false)
}
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setIsSubmitting(true)
@@ -208,6 +255,14 @@ export function AddNewProductPage() {
galleryMediaIds,
)
let nextCatalogMediaId: string | null = catalogMediaId
if (catalogFile) {
const uploaded = await uploadCatalogPdf(catalogFile)
nextCatalogMediaId = uploaded.id
} else if (catalogRemoved) {
nextCatalogMediaId = null
}
const payload = {
title: nameEn.trim(),
nameFa: nameFa.trim(),
@@ -217,6 +272,7 @@ export function AddNewProductPage() {
brandId: brandId || undefined,
featuredMediaId: featuredMediaId ?? undefined,
galleryMediaIds: nextGalleryMediaIds,
catalogMediaId: nextCatalogMediaId ?? undefined,
tags,
status: 'published' as const,
}
@@ -227,6 +283,7 @@ export function AddNewProductPage() {
categoryId: categoryId || null,
brandId: brandId || null,
featuredMediaId,
catalogMediaId: nextCatalogMediaId,
})
} else {
await createProduct(payload)
@@ -434,6 +491,48 @@ export function AddNewProductPage() {
/>
</div>
<div className={`${styles.field} ${styles.col12}`}>
<label htmlFor="product-catalog">{t('products.form.catalog')}</label>
<p className={styles.catalogHint}>{t('products.form.catalogHint')}</p>
<div className={styles.catalogRow}>
<input
ref={catalogInputRef}
id="product-catalog"
type="file"
accept="application/pdf,.pdf"
onChange={handleCatalogFileChange}
disabled={isSubmitting}
className={styles.catalogInput}
/>
{(catalogFileName || catalogUrl) && (
<div className={styles.catalogSelected}>
<FileText size={16} aria-hidden />
{catalogUrl && !catalogFile ? (
<a
href={catalogUrl}
target="_blank"
rel="noopener noreferrer"
className={styles.catalogLink}
>
{catalogFileName || t('products.form.catalogView')}
</a>
) : (
<span>{catalogFileName}</span>
)}
<button
type="button"
className={styles.catalogClear}
onClick={clearCatalogFile}
disabled={isSubmitting}
aria-label={t('products.form.catalogRemove')}
>
<X size={16} />
</button>
</div>
)}
</div>
</div>
<div className={`${styles.field} ${styles.col12}`}>
<label>{t('products.form.tags')}</label>
<TagInput
@@ -108,6 +108,23 @@ export async function uploadMediaFiles(
}))
}
const CATALOG_PDF_MAX_BYTES = 2 * 1024 * 1024
export async function uploadCatalogPdf(file: File, signal?: AbortSignal) {
if (file.type !== 'application/pdf' && !file.name.toLowerCase().endsWith('.pdf')) {
throw new Error('Catalog file must be a PDF')
}
if (file.size > CATALOG_PDF_MAX_BYTES) {
throw new Error('Catalog PDF must be 2MB or smaller')
}
const uploaded = await uploadMediaFiles([file], signal)
const item = uploaded[0]
if (!item) {
throw new Error('Catalog upload failed')
}
return item
}
export async function resolveDataUrlToMediaId(
value: string | null,
filename: string,
@@ -31,6 +31,9 @@ export interface ProductApi {
seoMetaDescription?: string | null
thumbnailUrl: string | null
thumbnailMediaId: string | null
catalogMediaId: string | null
catalogUrl: string | null
catalogFileName: string | null
image: string
thumbnail: string
images: { mediaId: string; url: string }[]
@@ -58,6 +61,7 @@ export interface CreateProductPayload {
brandId?: string
status?: 'draft' | 'published' | 'archived'
featuredMediaId?: string
catalogMediaId?: string
galleryMediaIds?: string[]
tags?: string[]
seoMetaTitle?: string | null
@@ -73,6 +77,7 @@ export interface UpdateProductPayload {
brandId?: string | null
status?: 'draft' | 'published' | 'archived'
featuredMediaId?: string | null
catalogMediaId?: string | null
galleryMediaIds?: string[]
tags?: string[]
seoMetaTitle?: string | null
@@ -155,6 +160,9 @@ export function mapProductApiToFormState(product: ProductApi) {
seoMetaDescription: product.seoMetaDescription ?? '',
thumbnailMediaId: product.thumbnailMediaId,
galleryMediaIds: product.galleryMediaIds,
catalogMediaId: product.catalogMediaId,
catalogUrl: product.catalogUrl,
catalogFileName: product.catalogFileName,
}
}
+9 -2
View File
@@ -27,6 +27,8 @@ interface AuthContextValue {
cellNumber: string,
code: string,
) => Promise<{ passwordUpdated?: boolean }>
/** Apply an already-authenticated user (tokens must already be stored). */
establishSession: (user: AuthUser) => Promise<void>
logout: () => void
setUser: (user: AuthUser) => void
}
@@ -136,9 +138,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return { passwordUpdated: data.passwordUpdated }
}, [])
const establishSession = useCallback(async (nextUser: AuthUser) => {
await assertCustomerAccess(nextUser)
setUser(nextUser)
}, [])
const value = useMemo(
() => ({ user, isLoading, login, loginWithOtp, logout, setUser }),
[user, isLoading, login, loginWithOtp, logout],
() => ({ user, isLoading, login, loginWithOtp, establishSession, logout, setUser }),
[user, isLoading, login, loginWithOtp, establishSession, logout],
)
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
+44 -22
View File
@@ -330,31 +330,42 @@ const en = {
'signup.title': 'Create account',
'signup.subtitle': 'Register as a customer of {domain}',
'signup.subtitleDetails': 'Enter your name and mobile number',
'signup.subtitleOtp': 'Enter the SMS verification code',
'signup.subtitlePassword': 'Choose a password for your Meshkee account',
'signup.subtitleExisting': 'You already have a Meshkee account',
'signup.firstName': 'First name',
'signup.lastName': 'Last name',
'signup.passwordPlaceholder': 'Choose a password',
'signup.confirm': 'Confirm password',
'signup.confirmPlaceholder': 'Repeat your password',
'signup.continue': 'Continue',
'signup.sendingCode': 'Sending code...',
'signup.verifyCode': 'Verify code',
'signup.verifying': 'Verifying...',
'signup.create': 'Create account',
'signup.creating': 'Creating account...',
'signup.joinContinue': 'Continue and join',
'signup.back': 'Back',
'signup.hasAccount': 'Already have an account?',
'signup.signIn': 'Sign in',
'signup.error.match': 'Passwords do not match.',
'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 an account in Meshkee Unified Management System (__MESHKEE_APP__) on {sites}. Your profile stays the same; the password you enter here will replace your Meshkee password. Continue to verify by SMS and join this website too.',
'signup.error.cellExistsGeneric':
'This mobile number already has an account in Meshkee Unified Management System (__MESHKEE_APP__) on another website. Your profile stays the same; the password you enter here will replace your Meshkee password. 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.info.verifyExistingPasswordUpdated':
'Enter the SMS code to finish joining. Your Meshkee password will then be updated to the one you just entered.',
'signup.error.alreadyOnSite':
'This mobile number is already registered on this website. Please sign in instead.',
'signup.error.sessionExpired': 'Registration session expired. Please start again.',
'signup.info.verify': 'Enter the SMS code we sent to verify your number.',
'signup.info.choosePassword': 'Phone verified. Choose a password to finish creating your account.',
'signup.info.existingAccount':
'This mobile number already has an account in Meshkee Unified Management System (__MESHKEE_APP__) on {sites}. Your password stays the same as on those websites.',
'signup.info.existingAccountGeneric':
'This mobile number already has an account in Meshkee Unified Management System (__MESHKEE_APP__). Your password stays the same as on your other Meshkee websites.',
'signup.info.existingPasswordHint':
'Continue to join this website with that same password. If you forgot it, use Forgot password.',
'signup.toast.linkedExisting': 'Joined this website with your existing Meshkee account.',
'signup.toast.passwordUpdated':
'Your Meshkee password was updated to the one you entered on this website.',
'signup.cellExists.title': 'Existing Meshkee account',
'signup.cellExists.admit': 'I understand, continue',
'forgot.back': 'Back to sign in',
'forgot.title': 'Forgot password',
@@ -741,31 +752,42 @@ const fa: Record<MessageKey, string> = {
'signup.title': 'ایجاد حساب',
'signup.subtitle': 'به‌عنوان مشتری {domain} ثبت‌نام کنید',
'signup.subtitleDetails': 'نام و شماره موبایل خود را وارد کنید',
'signup.subtitleOtp': 'کد تأیید پیامکی را وارد کنید',
'signup.subtitlePassword': 'برای حساب مشکی خود رمز عبور انتخاب کنید',
'signup.subtitleExisting': 'قبلاً در مشکی حساب دارید',
'signup.firstName': 'نام',
'signup.lastName': 'نام خانوادگی',
'signup.passwordPlaceholder': 'یک رمز عبور انتخاب کنید',
'signup.confirm': 'تأیید رمز عبور',
'signup.confirmPlaceholder': 'رمز عبور را تکرار کنید',
'signup.continue': 'ادامه',
'signup.sendingCode': 'در حال ارسال کد...',
'signup.verifyCode': 'تأیید کد',
'signup.verifying': 'در حال تأیید...',
'signup.create': 'ایجاد حساب',
'signup.creating': 'در حال ایجاد حساب...',
'signup.joinContinue': 'ادامه و عضویت',
'signup.back': 'بازگشت',
'signup.hasAccount': 'قبلاً حساب دارید؟',
'signup.signIn': 'ورود',
'signup.error.match': 'رمزهای عبور یکسان نیستند.',
'signup.error.length': 'رمز عبور باید حداقل ۸ کاراکتر باشد.',
'signup.error.create': 'ایجاد حساب ممکن نشد.',
'signup.error.cellExists':
'این شماره موبایل قبلاً در سیستم مدیریت یکپارچه ی مشکی (__MESHKEE_APP__) حساب دارد ({sites}). اطلاعات شما همان می‌ماند؛ رمزی که اینجا وارد کردید جایگزین رمز مشکی می‌شود. با ادامه، تأیید پیامکی را انجام دهید تا وارد این وب‌سایت هم شوید.',
'signup.error.cellExistsGeneric':
'این شماره موبایل قبلاً در سیستم مدیریت یکپارچه ی مشکی (__MESHKEE_APP__) (وب‌سایت دیگری) حساب دارد. اطلاعات شما همان می‌ماند؛ رمزی که اینجا وارد کردید جایگزین رمز مشکی می‌شود. با ادامه، تأیید پیامکی را انجام دهید تا وارد این وب‌سایت هم شوید.',
'signup.info.verify': 'حساب ایجاد شد. کد پیامکی را وارد کنید تا شماره تأیید شود و وارد شوید.',
'signup.info.verifyExisting':
'کد پیامکی را وارد کنید تا به این وب‌سایت بپیوندید. رمز عبور و اطلاعات مشکی شما همان می‌ماند.',
'signup.info.verifyExistingPasswordUpdated':
'کد پیامکی را وارد کنید تا عضویت تمام شود. پس از تأیید، رمز مشکی شما به رمزی که همین الان وارد کردید به‌روز می‌شود.',
'signup.error.alreadyOnSite':
'این شماره موبایل قبلاً در این وب‌سایت ثبت شده است. لطفاً وارد شوید.',
'signup.error.sessionExpired': 'نشست ثبت‌نام منقضی شد. لطفاً دوباره شروع کنید.',
'signup.info.verify': 'کد پیامکی ارسال‌شده را وارد کنید تا شماره تأیید شود.',
'signup.info.choosePassword': 'شماره تأیید شد. برای تکمیل ایجاد حساب، رمز عبور انتخاب کنید.',
'signup.info.existingAccount':
'این شماره موبایل قبلاً در سیستم مدیریت یکپارچه ی مشکی (__MESHKEE_APP__) حساب دارد ({sites}). رمز عبور شما همان رمز وب‌سایت‌های دیگر مشکی است.',
'signup.info.existingAccountGeneric':
'این شماره موبایل قبلاً در سیستم مدیریت یکپارچه ی مشکی (__MESHKEE_APP__) حساب دارد. رمز عبور شما همان رمز وب‌سایت‌های دیگر مشکی است.',
'signup.info.existingPasswordHint':
'با ادامه، با همان رمز به این وب‌سایت می‌پیوندید. اگر رمز را فراموش کرده‌اید، از فراموشی رمز عبور استفاده کنید.',
'signup.toast.linkedExisting': 'با حساب موجود مشکی به این وب‌سایت پیوستید.',
'signup.toast.passwordUpdated':
'رمز مشکی شما به رمزی که در این وب‌سایت وارد کردید به‌روز شد.',
'signup.cellExists.title': 'حساب موجود در مشکی',
'signup.cellExists.admit': 'متوجه شدم، ادامه',
'forgot.back': 'بازگشت به ورود',
'forgot.title': 'فراموشی رمز عبور',
+436 -266
View File
@@ -1,20 +1,17 @@
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, X } from 'lucide-react'
import {
useAuth,
CUSTOMER_ACCESS_MESSAGE,
} from '../context/AuthContext'
import { Eye, EyeOff, Smartphone, Lock, KeyRound, ArrowLeft, User } from 'lucide-react'
import { useAuth } from '../context/AuthContext'
import { useTenantBranding } from '../context/TenantBrandingContext'
import { ApiError } from '../lib/api'
import { toE164CellNumber } from '../lib/cellNumber'
import { getTenantDomain } from '../lib/config'
import {
logout as logoutRequest,
register,
completeSignup,
resetPassword,
sendOtp,
startSignup,
verifySignupOtp,
} from '../services/authService'
import { LanguageSelect, PasswordStrengthMeter, isPasswordStrong, useLocale, useToast } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
@@ -24,20 +21,11 @@ import styles from './LoginPage.module.css'
type AuthView = 'login' | 'signup' | 'forgot' | 'otp'
type SmsStep = 'phone' | 'code'
type SignupStep = 'details' | 'otp' | 'password' | 'existing'
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
@@ -61,15 +49,16 @@ export function LoginPage() {
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const redirectTo = safeRedirectPath(searchParams.get('redirect'))
const { login, loginWithOtp } = useAuth()
const { login, loginWithOtp, establishSession } = useAuth()
const { businessName, businessNameEn, logoUrl } = useTenantBranding()
const tenantDomain = getTenantDomain()
const t = useT()
const { showToast } = useToast()
const { locale, dir } = useLocale()
const { locale } = useLocale()
const [view, setView] = useState<AuthView>('login')
const [smsStep, setSmsStep] = useState<SmsStep>('phone')
const [signupStep, setSignupStep] = useState<SignupStep>('details')
const [showPassword, setShowPassword] = useState(false)
const [phone, setPhone] = useState('')
const [password, setPassword] = useState('')
@@ -84,8 +73,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<string | null>(null)
const [registrationToken, setRegistrationToken] = useState('')
const [existingSites, setExistingSites] = useState<string[]>([])
function clearMessages() {
setError('')
@@ -102,9 +91,11 @@ export function LoginPage() {
setNewPassword('')
setConfirmNewPassword('')
setSmsStep('phone')
setSignupStep('details')
setCodeSent(false)
setShowPassword(false)
setCellExistsSites(null)
setRegistrationToken('')
setExistingSites([])
clearMessages()
}
@@ -212,116 +203,196 @@ export function LoginPage() {
}
}
async function runSignup(acknowledgeExistingAccount = false) {
function advanceAfterSignupVerify(result: {
registrationToken: string
existingAccount: boolean
sites?: string[]
}) {
setRegistrationToken(result.registrationToken)
setExistingSites(result.sites ?? [])
if (result.existingAccount) {
setSignupStep('existing')
setInfo('')
} else {
setPassword('')
setConfirmPassword('')
setSignupStep('password')
setInfo(t('signup.info.choosePassword'))
}
}
async function handleSignupStart(e: React.FormEvent) {
e.preventDefault()
clearMessages()
const cellNumber = resolveCellNumber()
if (!cellNumber) return
if (firstName.trim().length < 2 || lastName.trim().length < 2) {
setError(t('signup.error.create'))
return
}
setIsSubmitting(true)
try {
const result = await startSignup({
cellNumber,
firstName: firstName.trim(),
lastName: lastName.trim(),
domain: tenantDomain,
})
if (!result.requiresOtp && result.registrationToken) {
advanceAfterSignupVerify({
registrationToken: result.registrationToken,
existingAccount: Boolean(result.existingAccount),
sites: result.sites,
})
return
}
setCodeSent(true)
setSignupStep('otp')
setSmsCode('')
setInfo(result.enabled ? t('signup.info.verify') : result.message)
startCountdown()
} catch (err) {
if (
err instanceof ApiError &&
err.message.includes('already registered on this website')
) {
setError(t('signup.error.alreadyOnSite'))
} else {
handleApiError(err, t('signup.error.create'))
}
} finally {
setIsSubmitting(false)
}
}
async function handleSignupResendCode() {
clearMessages()
const cellNumber = resolveCellNumber()
if (!cellNumber) return
setIsSubmitting(true)
try {
const data = await register({
const result = await startSignup({
cellNumber,
password,
firstName: firstName.trim(),
lastName: lastName.trim(),
domain: tenantDomain,
...(acknowledgeExistingAccount ? { acknowledgeExistingAccount: true } : {}),
})
setCellExistsSites(null)
// Password is applied only after OTP when passwordPending; toast then.
if (data.passwordUpdated) {
showToast(t('signup.toast.passwordUpdated'), 'info')
}
if (data.requiresOtp || data.passwordPending || !data.user.cellVerifiedAt) {
if (data.accessToken) {
logoutRequest()
}
await continueToSmsVerify(
cellNumber,
data.passwordPending
? t('signup.info.verifyExistingPasswordUpdated')
: t('signup.info.verify'),
)
if (!result.requiresOtp && result.registrationToken) {
advanceAfterSignupVerify({
registrationToken: result.registrationToken,
existingAccount: Boolean(result.existingAccount),
sites: result.sites,
})
return
}
if (data.user.customerBusinesses.length === 0) {
logoutRequest()
setError(CUSTOMER_ACCESS_MESSAGE)
return
}
setCodeSent(true)
setInfo(result.enabled ? t('signup.info.verify') : result.message)
startCountdown()
} catch (err) {
handleApiError(err, t('login.error.sendCode'))
} finally {
setIsSubmitting(false)
}
}
await login(cellNumber, password)
async function handleSignupVerifyOtp(e: React.FormEvent) {
e.preventDefault()
clearMessages()
const cellNumber = resolveCellNumber()
if (!cellNumber) return
setIsSubmitting(true)
try {
const result = await verifySignupOtp(cellNumber, smsCode)
advanceAfterSignupVerify(result)
} catch (err) {
if (
err instanceof ApiError &&
err.message.includes('already registered on this website')
) {
setError(t('signup.error.alreadyOnSite'))
} else {
handleApiError(err, t('otp.error.signIn'))
}
} finally {
setIsSubmitting(false)
}
}
async function finishSignup(token: string, nextPassword?: string) {
setIsSubmitting(true)
try {
const data = await completeSignup({
registrationToken: token,
...(nextPassword ? { password: nextPassword } : {}),
})
await establishSession(data.user)
if (data.existingAccount) {
showToast(t('signup.toast.linkedExisting'), 'info')
}
navigate(redirectTo)
} catch (err) {
if (!acknowledgeExistingAccount && 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, infoMessage?: string) {
setView('otp')
setSmsStep('phone')
setPassword('')
setConfirmPassword('')
setSmsCode('')
setInfo(infoMessage ?? 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) {
async function handleSignupPassword(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 we link
* them with the new password and prove phone ownership via SMS.
*/
async function handleAdmitCellExists() {
clearMessages()
const cellNumber = resolveCellNumber()
if (!cellNumber) {
setCellExistsSites(null)
if (!registrationToken) {
setError(t('signup.error.sessionExpired'))
setSignupStep('details')
return
}
await runSignup(true)
await finishSignup(registrationToken, password)
}
async function handleSignupLinkExisting() {
clearMessages()
if (!registrationToken) {
setError(t('signup.error.sessionExpired'))
setSignupStep('details')
return
}
await finishSignup(registrationToken)
}
function handleSignupBack() {
clearMessages()
if (signupStep === 'otp') {
setSignupStep('details')
setSmsCode('')
setCodeSent(false)
return
}
if (signupStep === 'password' || signupStep === 'existing') {
setSignupStep('details')
setRegistrationToken('')
setExistingSites([])
setPassword('')
setConfirmPassword('')
return
}
switchView('login')
}
async function handleResetPassword(e: React.FormEvent) {
@@ -379,7 +450,17 @@ export function LoginPage() {
view === 'login'
? { title: t('login.welcome'), subtitle: t('login.subtitle') }
: view === 'signup'
? { title: t('signup.title'), subtitle: null }
? {
title: t('signup.title'),
subtitle:
signupStep === 'details'
? t('signup.subtitleDetails')
: signupStep === 'otp'
? t('signup.subtitleOtp')
: signupStep === 'existing'
? t('signup.subtitleExisting')
: t('signup.subtitlePassword'),
}
: view === 'forgot'
? {
title: t('forgot.title'),
@@ -405,15 +486,25 @@ export function LoginPage() {
</div>
</div>
<div className={styles.card}>
{(view === 'forgot' || view === 'otp') && (
{(view === 'forgot' || view === 'otp' || (view === 'signup' && signupStep !== 'details')) && (
<button
type="button"
className={styles.backBtn}
onClick={() => switchView('login')}
onClick={() => {
if (view === 'signup') {
handleSignupBack()
return
}
switchView('login')
}}
disabled={isSubmitting}
>
<ArrowLeft size={18} />
{view === 'forgot' ? t('forgot.back') : t('otp.back')}
{view === 'forgot'
? t('forgot.back')
: view === 'signup'
? t('signup.back')
: t('otp.back')}
</button>
)}
<div className={styles.cardHeader}>
@@ -527,155 +618,284 @@ export function LoginPage() {
{view === 'signup' && (
<>
<form className={styles.form} onSubmit={handleSignup}>
{error && (
<div className={styles.error} role="alert">
{error}
{signupStep === 'details' && (
<form className={styles.form} onSubmit={handleSignupStart}>
{error && (
<div className={styles.error} role="alert">
{error}
</div>
)}
{info && <div className={styles.info}>{info}</div>}
<div className={styles.fieldRow}>
<div className={styles.field}>
<label htmlFor="signup-first">{t('signup.firstName')}</label>
<div className={styles.inputWrap}>
<User size={18} className={styles.inputIcon} />
<input
id="signup-first"
type="text"
placeholder={t('signup.firstName')}
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
required
minLength={2}
disabled={isSubmitting}
/>
</div>
</div>
<div className={styles.field}>
<label htmlFor="signup-last">{t('signup.lastName')}</label>
<div className={styles.inputWrap}>
<User size={18} className={styles.inputIcon} />
<input
id="signup-last"
type="text"
placeholder={t('signup.lastName')}
value={lastName}
onChange={(e) => setLastName(e.target.value)}
required
minLength={2}
disabled={isSubmitting}
/>
</div>
</div>
</div>
)}
{info && <div className={styles.info}>{info}</div>}
<div className={styles.fieldRow}>
<div className={styles.field}>
<label htmlFor="signup-first">{t('signup.firstName')}</label>
<label htmlFor="signup-phone">{t('login.mobile')}</label>
<div className={styles.inputWrap}>
<User size={18} className={styles.inputIcon} />
<Smartphone size={18} className={styles.inputIcon} />
<input
id="signup-first"
type="text"
placeholder={t('signup.firstName')}
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
id="signup-phone"
type="tel"
inputMode="tel"
placeholder="09123456789"
value={phone}
onChange={(e) => setPhone(e.target.value)}
required
minLength={2}
disabled={isSubmitting}
/>
</div>
</div>
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}>
{isSubmitting ? t('signup.sendingCode') : t('signup.continue')}
</button>
</form>
)}
{signupStep === 'otp' && (
<form className={styles.form} onSubmit={handleSignupVerifyOtp}>
{error && (
<div className={styles.error} role="alert">
{error}
</div>
)}
{info && <div className={styles.info}>{info}</div>}
{codeSent && (
<p className={styles.codeHint}>
{t('common.codeSent', { phone })}
</p>
)}
<div className={styles.field}>
<label htmlFor="signup-last">{t('signup.lastName')}</label>
<label htmlFor="signup-code">{t('otp.code')}</label>
<div className={styles.inputWrap}>
<User size={18} className={styles.inputIcon} />
<KeyRound size={18} className={styles.inputIcon} />
<input
id="signup-last"
id="signup-code"
type="text"
placeholder={t('signup.lastName')}
value={lastName}
onChange={(e) => setLastName(e.target.value)}
inputMode="numeric"
placeholder="123456"
maxLength={6}
value={smsCode}
onChange={(e) => setSmsCode(e.target.value.replace(/\D/g, ''))}
required
minLength={2}
disabled={isSubmitting}
/>
</div>
</div>
</div>
<div className={styles.field}>
<label htmlFor="signup-phone">{t('login.mobile')}</label>
<div className={styles.inputWrap}>
<Smartphone size={18} className={styles.inputIcon} />
<input
id="signup-phone"
type="tel"
inputMode="tel"
placeholder="09123456789"
value={phone}
onChange={(e) => setPhone(e.target.value)}
required
disabled={isSubmitting}
/>
<div className={styles.resendRow}>
{countdown > 0 ? (
<span className={styles.countdown}>
{t('common.resendIn', { seconds: countdown })}
</span>
) : (
<button
type="button"
className={styles.linkBtn}
onClick={() => void handleSignupResendCode()}
disabled={isSubmitting}
>
{t('common.resend')}
</button>
)}
</div>
</div>
<div className={styles.field}>
<label htmlFor="signup-password">{t('login.password')}</label>
<div className={styles.inputWrap}>
<Lock size={18} className={styles.inputIcon} />
<input
id="signup-password"
type={showPassword ? 'text' : 'password'}
dir="ltr"
lang="en"
placeholder={t('signup.passwordPlaceholder')}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
disabled={isSubmitting}
/>
<button
type="submit"
className={styles.submitBtn}
disabled={isSubmitting || smsCode.length !== 6}
>
{isSubmitting ? t('signup.verifying') : t('signup.verifyCode')}
</button>
</form>
)}
{signupStep === 'existing' && (
<div className={styles.form}>
{error && (
<div className={styles.error} role="alert">
{error}
</div>
)}
<div className={styles.info}>
{renderWithMeshkeeLink(
existingSites.length > 0
? t('signup.info.existingAccount', {
sites: existingSites.join(', '),
})
: t('signup.info.existingAccountGeneric'),
styles.noticeLink,
)}
</div>
<p className={styles.codeHint}>{t('signup.info.existingPasswordHint')}</p>
<button
type="button"
className={styles.submitBtn}
onClick={() => void handleSignupLinkExisting()}
disabled={isSubmitting}
>
{isSubmitting ? t('signup.creating') : t('signup.joinContinue')}
</button>
<p className={styles.footerText}>
<button
type="button"
className={styles.togglePassword}
onClick={() => setShowPassword((v) => !v)}
aria-label={showPassword ? t('login.hidePassword') : t('login.showPassword')}
className={styles.linkBtn}
onClick={() => {
clearMessages()
setPassword('')
setConfirmPassword('')
setSmsCode('')
setSignupStep('details')
setRegistrationToken('')
setExistingSites([])
setSmsStep('phone')
setCodeSent(false)
setView('forgot')
}}
disabled={isSubmitting}
>
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
{t('login.forgot')}
</button>
</div>
</p>
</div>
)}
<div className={styles.field}>
<label htmlFor="signup-confirm">{t('signup.confirm')}</label>
<div className={styles.inputWrap}>
<Lock size={18} className={styles.inputIcon} />
<input
id="signup-confirm"
type={showPassword ? 'text' : 'password'}
dir="ltr"
lang="en"
placeholder={t('signup.confirmPlaceholder')}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
minLength={8}
disabled={isSubmitting}
aria-describedby="signup-password-strength signup-password-strength-rules"
{signupStep === 'password' && (
<form className={styles.form} onSubmit={handleSignupPassword}>
{error && (
<div className={styles.error} role="alert">
{error}
</div>
)}
{info && <div className={styles.info}>{info}</div>}
<div className={styles.field}>
<label htmlFor="signup-password">{t('login.password')}</label>
<div className={styles.inputWrap}>
<Lock size={18} className={styles.inputIcon} />
<input
id="signup-password"
type={showPassword ? 'text' : 'password'}
dir="ltr"
lang="en"
placeholder={t('signup.passwordPlaceholder')}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
disabled={isSubmitting}
/>
<button
type="button"
className={styles.togglePassword}
onClick={() => setShowPassword((v) => !v)}
aria-label={showPassword ? t('login.hidePassword') : t('login.showPassword')}
disabled={isSubmitting}
>
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
</div>
<div className={styles.field}>
<label htmlFor="signup-confirm">{t('signup.confirm')}</label>
<div className={styles.inputWrap}>
<Lock size={18} className={styles.inputIcon} />
<input
id="signup-confirm"
type={showPassword ? 'text' : 'password'}
dir="ltr"
lang="en"
placeholder={t('signup.confirmPlaceholder')}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
minLength={8}
disabled={isSubmitting}
aria-describedby="signup-password-strength signup-password-strength-rules"
/>
</div>
<PasswordStrengthMeter
password={password}
confirmPassword={confirmPassword}
id="signup-password-strength"
labels={{
title: t('password.strength'),
empty: t('password.strength.empty'),
weak: t('password.strength.weak'),
fair: t('password.strength.fair'),
good: t('password.strength.good'),
strong: t('password.strength.strong'),
rules: {
length: t('password.rule.length'),
upper: t('password.rule.upper'),
number: t('password.rule.number'),
match: t('password.rule.match'),
},
}}
/>
</div>
<PasswordStrengthMeter
password={password}
confirmPassword={confirmPassword}
id="signup-password-strength"
labels={{
title: t('password.strength'),
empty: t('password.strength.empty'),
weak: t('password.strength.weak'),
fair: t('password.strength.fair'),
good: t('password.strength.good'),
strong: t('password.strength.strong'),
rules: {
length: t('password.rule.length'),
upper: t('password.rule.upper'),
number: t('password.rule.number'),
match: t('password.rule.match'),
},
}}
/>
</div>
<button
type="submit"
className={styles.submitBtn}
disabled={
isSubmitting || !isPasswordStrong(password) || password !== confirmPassword
}
>
{isSubmitting ? t('signup.creating') : t('signup.create')}
</button>
</form>
<button
type="submit"
className={styles.submitBtn}
disabled={
isSubmitting || !isPasswordStrong(password) || password !== confirmPassword
}
>
{isSubmitting ? t('signup.creating') : t('signup.create')}
</button>
</form>
)}
<p className={styles.footerText}>
<span>{t('signup.hasAccount')}</span>
<button
type="button"
className={styles.linkBtn}
onClick={() => switchView('login')}
disabled={isSubmitting}
>
{t('signup.signIn')}
</button>
</p>
{signupStep === 'details' && (
<p className={styles.footerText}>
<span>{t('signup.hasAccount')}</span>
<button
type="button"
className={styles.linkBtn}
onClick={() => switchView('login')}
disabled={isSubmitting}
>
{t('signup.signIn')}
</button>
</p>
)}
</>
)}
@@ -935,56 +1155,6 @@ export function LoginPage() {
>
Powered by Meshkee.app
</a>
{cellExistsSites !== null &&
createPortal(
<div
className={styles.noticeOverlay}
onClick={() => !isSubmitting && setCellExistsSites(null)}
>
<div
className={styles.noticeModal}
role="dialog"
aria-modal="true"
aria-labelledby="cell-exists-title"
lang={locale}
dir={dir}
onClick={(e) => e.stopPropagation()}
>
<div className={styles.noticeHeader}>
<h2 id="cell-exists-title" className={styles.noticeTitle}>
{t('signup.cellExists.title')}
</h2>
<button
type="button"
className={styles.noticeClose}
onClick={() => setCellExistsSites(null)}
disabled={isSubmitting}
aria-label={t('common.close')}
>
<X size={20} />
</button>
</div>
<p className={styles.noticeBody}>
{renderWithMeshkeeLink(
cellExistsSites
? t('signup.error.cellExists', { sites: cellExistsSites })
: t('signup.error.cellExistsGeneric'),
styles.noticeLink,
)}
</p>
<button
type="button"
className={styles.noticeAdmit}
onClick={() => void handleAdmitCellExists()}
disabled={isSubmitting}
>
{isSubmitting ? t('signup.creating') : t('signup.cellExists.admit')}
</button>
</div>
</div>,
document.body,
)}
</div>
)
}
+36
View File
@@ -7,6 +7,8 @@ import type {
OtpSendResponse,
OtpVerifyResponse,
RegisterResponse,
SignupStartResponse,
SignupVerifyResponse,
HandoffCreateResponse,
UserProfile,
} from '../types/auth'
@@ -45,6 +47,40 @@ export async function register(input: {
return data
}
export async function startSignup(input: {
cellNumber: string
firstName: string
lastName: string
domain: string
}) {
return apiRequest<SignupStartResponse>('/auth/signup/start', {
method: 'POST',
body: input,
})
}
export async function verifySignupOtp(cellNumber: string, code: string) {
return apiRequest<SignupVerifyResponse>('/auth/signup/verify-otp', {
method: 'POST',
body: { cellNumber, code },
})
}
export async function completeSignup(input: {
registrationToken: string
password?: string
}) {
const data = await apiRequest<RegisterResponse>('/auth/signup/complete', {
method: 'POST',
body: input,
})
if (data.accessToken && data.refreshToken) {
setTokens(data.accessToken, data.refreshToken)
}
return data
}
export async function fetchCurrentUser(signal?: AbortSignal) {
return apiRequest<MeResponse>('/auth/me', { auth: true, signal })
}
+2
View File
@@ -6,6 +6,8 @@ export type {
OtpSendResponse,
OtpVerifyResponse,
RegisterResponse,
SignupStartResponse,
SignupVerifyResponse,
HandoffCreateResponse,
UserProfile,
} from '@meshkee/dashboard-core'
+2
View File
@@ -9,6 +9,8 @@ export type {
OtpSendResponse,
OtpVerifyResponse,
RegisterResponse,
SignupStartResponse,
SignupVerifyResponse,
HandoffCreateResponse,
UserProfile,
} from './types/auth'
+21
View File
@@ -63,9 +63,30 @@ export interface RegisterResponse {
passwordPending?: boolean
/** True when an existing Meshkee account password was already replaced. */
passwordUpdated?: boolean
/** True when this signup linked an existing Meshkee account. */
existingAccount?: boolean
registeredBusiness?: { id: string; name: string; slug: string }
}
export interface SignupStartResponse {
enabled: boolean
requiresOtp: boolean
message: string
expiresInSeconds?: number
/** Present when SMS is disabled — skip OTP and continue with complete. */
registrationToken?: string
existingAccount?: boolean
sites?: string[]
}
export interface SignupVerifyResponse {
verified: boolean
existingAccount: boolean
sites: string[]
registrationToken: string
message: string
}
export interface OtpSendResponse {
enabled: boolean
message: string