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:
co-authored by
Cursor
parent
e2950fa20e
commit
6d85a217e1
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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': 'فراموشی رمز عبور',
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ export type {
|
||||
OtpSendResponse,
|
||||
OtpVerifyResponse,
|
||||
RegisterResponse,
|
||||
SignupStartResponse,
|
||||
SignupVerifyResponse,
|
||||
HandoffCreateResponse,
|
||||
UserProfile,
|
||||
} from '@meshkee/dashboard-core'
|
||||
|
||||
@@ -9,6 +9,8 @@ export type {
|
||||
OtpSendResponse,
|
||||
OtpVerifyResponse,
|
||||
RegisterResponse,
|
||||
SignupStartResponse,
|
||||
SignupVerifyResponse,
|
||||
HandoffCreateResponse,
|
||||
UserProfile,
|
||||
} from './types/auth'
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user