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.imagesAdd': 'Add images',
'products.form.imagesHint': 'Upload multiple product images. Click + to add more.', 'products.form.imagesHint': 'Upload multiple product images. Click + to add more.',
'products.form.imagesRemove': 'Remove image {index}', '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.tags': 'Tags',
'products.form.tagsPlaceholder': 'Type a tag and press Enter', 'products.form.tagsPlaceholder': 'Type a tag and press Enter',
'products.form.cancel': 'Cancel', 'products.form.cancel': 'Cancel',
@@ -3440,6 +3446,12 @@ const fa: Record<MessageKey, string> = {
'products.form.imagesAdd': 'افزودن تصاویر', 'products.form.imagesAdd': 'افزودن تصاویر',
'products.form.imagesHint': 'چند تصویر محصول آپلود کنید. برای افزودن بیشتر روی + کلیک کنید.', 'products.form.imagesHint': 'چند تصویر محصول آپلود کنید. برای افزودن بیشتر روی + کلیک کنید.',
'products.form.imagesRemove': 'حذف تصویر {index}', '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.tags': 'برچسب‌ها',
'products.form.tagsPlaceholder': 'برچسب را بنویسید و Enter بزنید', 'products.form.tagsPlaceholder': 'برچسب را بنویسید و Enter بزنید',
'products.form.cancel': 'انصراف', 'products.form.cancel': 'انصراف',
@@ -87,6 +87,64 @@
color: var(--text-muted); 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 { .summaryField textarea {
min-height: calc((var(--field-height) + 8px) * 2); min-height: calc((var(--field-height) + 8px) * 2);
padding-inline-end: 12px; 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 { 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 { CategoryTreeSelect } from '../components/CategoryTreeSelect'
import { SearchableSelect } from '../components/SearchableSelect' import { SearchableSelect } from '../components/SearchableSelect'
import { ImageCropper } from '../components/ImageCropper' import { ImageCropper } from '../components/ImageCropper'
@@ -18,6 +18,7 @@ import {
galleryMediaIdsAfterChange, galleryMediaIdsAfterChange,
resolveDataUrlToMediaId, resolveDataUrlToMediaId,
resolveDataUrlsToMediaIds, resolveDataUrlsToMediaIds,
uploadCatalogPdf,
} from '../services/mediaService' } from '../services/mediaService'
import { import {
createProduct, createProduct,
@@ -34,12 +35,15 @@ import aiStyles from '../styles/ai.module.css'
import styles from './AddNewProductPage.module.css' import styles from './AddNewProductPage.module.css'
import { useT } from '../i18n/useT' import { useT } from '../i18n/useT'
const CATALOG_PDF_MAX_BYTES = 2 * 1024 * 1024
export function AddNewProductPage() { export function AddNewProductPage() {
const { id } = useParams() const { id } = useParams()
const navigate = useNavigate() const navigate = useNavigate()
const t = useT() const t = useT()
const { showToast } = useToast() const { showToast } = useToast()
const isEdit = Boolean(id) const isEdit = Boolean(id)
const catalogInputRef = useRef<HTMLInputElement>(null)
const [categories, setCategories] = useState<Category[]>([]) const [categories, setCategories] = useState<Category[]>([])
const [brandOptions, setBrandOptions] = useState<FlatCategory[]>([]) const [brandOptions, setBrandOptions] = useState<FlatCategory[]>([])
@@ -54,6 +58,11 @@ export function AddNewProductPage() {
const [tags, setTags] = useState<string[]>([]) const [tags, setTags] = useState<string[]>([])
const [thumbnailMediaId, setThumbnailMediaId] = useState<string | null>(null) const [thumbnailMediaId, setThumbnailMediaId] = useState<string | null>(null)
const [galleryMediaIds, setGalleryMediaIds] = useState<string[]>([]) 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 [loaded, setLoaded] = useState(!isEdit)
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
@@ -172,6 +181,11 @@ export function AddNewProductPage() {
setTags(form.tags) setTags(form.tags)
setThumbnailMediaId(form.thumbnailMediaId) setThumbnailMediaId(form.thumbnailMediaId)
setGalleryMediaIds(form.galleryMediaIds) setGalleryMediaIds(form.galleryMediaIds)
setCatalogMediaId(form.catalogMediaId)
setCatalogUrl(form.catalogUrl)
setCatalogFileName(form.catalogFileName)
setCatalogFile(null)
setCatalogRemoved(false)
setLoaded(true) setLoaded(true)
} catch (err) { } catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return if (err instanceof DOMException && err.name === 'AbortError') return
@@ -185,6 +199,39 @@ export function AddNewProductPage() {
if (!loaded) return null 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>) { async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault() e.preventDefault()
setIsSubmitting(true) setIsSubmitting(true)
@@ -208,6 +255,14 @@ export function AddNewProductPage() {
galleryMediaIds, galleryMediaIds,
) )
let nextCatalogMediaId: string | null = catalogMediaId
if (catalogFile) {
const uploaded = await uploadCatalogPdf(catalogFile)
nextCatalogMediaId = uploaded.id
} else if (catalogRemoved) {
nextCatalogMediaId = null
}
const payload = { const payload = {
title: nameEn.trim(), title: nameEn.trim(),
nameFa: nameFa.trim(), nameFa: nameFa.trim(),
@@ -217,6 +272,7 @@ export function AddNewProductPage() {
brandId: brandId || undefined, brandId: brandId || undefined,
featuredMediaId: featuredMediaId ?? undefined, featuredMediaId: featuredMediaId ?? undefined,
galleryMediaIds: nextGalleryMediaIds, galleryMediaIds: nextGalleryMediaIds,
catalogMediaId: nextCatalogMediaId ?? undefined,
tags, tags,
status: 'published' as const, status: 'published' as const,
} }
@@ -227,6 +283,7 @@ export function AddNewProductPage() {
categoryId: categoryId || null, categoryId: categoryId || null,
brandId: brandId || null, brandId: brandId || null,
featuredMediaId, featuredMediaId,
catalogMediaId: nextCatalogMediaId,
}) })
} else { } else {
await createProduct(payload) await createProduct(payload)
@@ -434,6 +491,48 @@ export function AddNewProductPage() {
/> />
</div> </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}`}> <div className={`${styles.field} ${styles.col12}`}>
<label>{t('products.form.tags')}</label> <label>{t('products.form.tags')}</label>
<TagInput <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( export async function resolveDataUrlToMediaId(
value: string | null, value: string | null,
filename: string, filename: string,
@@ -31,6 +31,9 @@ export interface ProductApi {
seoMetaDescription?: string | null seoMetaDescription?: string | null
thumbnailUrl: string | null thumbnailUrl: string | null
thumbnailMediaId: string | null thumbnailMediaId: string | null
catalogMediaId: string | null
catalogUrl: string | null
catalogFileName: string | null
image: string image: string
thumbnail: string thumbnail: string
images: { mediaId: string; url: string }[] images: { mediaId: string; url: string }[]
@@ -58,6 +61,7 @@ export interface CreateProductPayload {
brandId?: string brandId?: string
status?: 'draft' | 'published' | 'archived' status?: 'draft' | 'published' | 'archived'
featuredMediaId?: string featuredMediaId?: string
catalogMediaId?: string
galleryMediaIds?: string[] galleryMediaIds?: string[]
tags?: string[] tags?: string[]
seoMetaTitle?: string | null seoMetaTitle?: string | null
@@ -73,6 +77,7 @@ export interface UpdateProductPayload {
brandId?: string | null brandId?: string | null
status?: 'draft' | 'published' | 'archived' status?: 'draft' | 'published' | 'archived'
featuredMediaId?: string | null featuredMediaId?: string | null
catalogMediaId?: string | null
galleryMediaIds?: string[] galleryMediaIds?: string[]
tags?: string[] tags?: string[]
seoMetaTitle?: string | null seoMetaTitle?: string | null
@@ -155,6 +160,9 @@ export function mapProductApiToFormState(product: ProductApi) {
seoMetaDescription: product.seoMetaDescription ?? '', seoMetaDescription: product.seoMetaDescription ?? '',
thumbnailMediaId: product.thumbnailMediaId, thumbnailMediaId: product.thumbnailMediaId,
galleryMediaIds: product.galleryMediaIds, galleryMediaIds: product.galleryMediaIds,
catalogMediaId: product.catalogMediaId,
catalogUrl: product.catalogUrl,
catalogFileName: product.catalogFileName,
} }
} }
+9 -2
View File
@@ -27,6 +27,8 @@ interface AuthContextValue {
cellNumber: string, cellNumber: string,
code: string, code: string,
) => Promise<{ passwordUpdated?: boolean }> ) => Promise<{ passwordUpdated?: boolean }>
/** Apply an already-authenticated user (tokens must already be stored). */
establishSession: (user: AuthUser) => Promise<void>
logout: () => void logout: () => void
setUser: (user: AuthUser) => void setUser: (user: AuthUser) => void
} }
@@ -136,9 +138,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return { passwordUpdated: data.passwordUpdated } return { passwordUpdated: data.passwordUpdated }
}, []) }, [])
const establishSession = useCallback(async (nextUser: AuthUser) => {
await assertCustomerAccess(nextUser)
setUser(nextUser)
}, [])
const value = useMemo( const value = useMemo(
() => ({ user, isLoading, login, loginWithOtp, logout, setUser }), () => ({ user, isLoading, login, loginWithOtp, establishSession, logout, setUser }),
[user, isLoading, login, loginWithOtp, logout], [user, isLoading, login, loginWithOtp, establishSession, logout],
) )
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider> return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
+44 -22
View File
@@ -330,31 +330,42 @@ const en = {
'signup.title': 'Create account', 'signup.title': 'Create account',
'signup.subtitle': 'Register as a customer of {domain}', '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.firstName': 'First name',
'signup.lastName': 'Last name', 'signup.lastName': 'Last name',
'signup.passwordPlaceholder': 'Choose a password', 'signup.passwordPlaceholder': 'Choose a password',
'signup.confirm': 'Confirm password', 'signup.confirm': 'Confirm password',
'signup.confirmPlaceholder': 'Repeat your 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.create': 'Create account',
'signup.creating': 'Creating account...', 'signup.creating': 'Creating account...',
'signup.joinContinue': 'Continue and join',
'signup.back': 'Back',
'signup.hasAccount': 'Already have an account?', 'signup.hasAccount': 'Already have an account?',
'signup.signIn': 'Sign in', 'signup.signIn': 'Sign in',
'signup.error.match': 'Passwords do not match.', 'signup.error.match': 'Passwords do not match.',
'signup.error.length': 'Password must be at least 8 characters.', 'signup.error.length': 'Password must be at least 8 characters.',
'signup.error.create': 'Unable to create account.', 'signup.error.create': 'Unable to create account.',
'signup.error.cellExists': 'signup.error.alreadyOnSite':
'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.', 'This mobile number is already registered on this website. Please sign in instead.',
'signup.error.cellExistsGeneric': 'signup.error.sessionExpired': 'Registration session expired. Please start again.',
'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': 'Enter the SMS code we sent to verify your number.',
'signup.info.verify': 'Account created. Enter the SMS code to verify your number and sign in.', 'signup.info.choosePassword': 'Phone verified. Choose a password to finish creating your account.',
'signup.info.verifyExisting': 'signup.info.existingAccount':
'Enter the SMS code to join this website. Your Meshkee password and profile stay the same.', '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.verifyExistingPasswordUpdated': 'signup.info.existingAccountGeneric':
'Enter the SMS code to finish joining. Your Meshkee password will then be updated to the one you just entered.', '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': 'signup.toast.passwordUpdated':
'Your Meshkee password was updated to the one you entered on this website.', '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.back': 'Back to sign in',
'forgot.title': 'Forgot password', 'forgot.title': 'Forgot password',
@@ -741,31 +752,42 @@ const fa: Record<MessageKey, string> = {
'signup.title': 'ایجاد حساب', 'signup.title': 'ایجاد حساب',
'signup.subtitle': 'به‌عنوان مشتری {domain} ثبت‌نام کنید', 'signup.subtitle': 'به‌عنوان مشتری {domain} ثبت‌نام کنید',
'signup.subtitleDetails': 'نام و شماره موبایل خود را وارد کنید',
'signup.subtitleOtp': 'کد تأیید پیامکی را وارد کنید',
'signup.subtitlePassword': 'برای حساب مشکی خود رمز عبور انتخاب کنید',
'signup.subtitleExisting': 'قبلاً در مشکی حساب دارید',
'signup.firstName': 'نام', 'signup.firstName': 'نام',
'signup.lastName': 'نام خانوادگی', 'signup.lastName': 'نام خانوادگی',
'signup.passwordPlaceholder': 'یک رمز عبور انتخاب کنید', 'signup.passwordPlaceholder': 'یک رمز عبور انتخاب کنید',
'signup.confirm': 'تأیید رمز عبور', 'signup.confirm': 'تأیید رمز عبور',
'signup.confirmPlaceholder': 'رمز عبور را تکرار کنید', 'signup.confirmPlaceholder': 'رمز عبور را تکرار کنید',
'signup.continue': 'ادامه',
'signup.sendingCode': 'در حال ارسال کد...',
'signup.verifyCode': 'تأیید کد',
'signup.verifying': 'در حال تأیید...',
'signup.create': 'ایجاد حساب', 'signup.create': 'ایجاد حساب',
'signup.creating': 'در حال ایجاد حساب...', 'signup.creating': 'در حال ایجاد حساب...',
'signup.joinContinue': 'ادامه و عضویت',
'signup.back': 'بازگشت',
'signup.hasAccount': 'قبلاً حساب دارید؟', 'signup.hasAccount': 'قبلاً حساب دارید؟',
'signup.signIn': 'ورود', 'signup.signIn': 'ورود',
'signup.error.match': 'رمزهای عبور یکسان نیستند.', 'signup.error.match': 'رمزهای عبور یکسان نیستند.',
'signup.error.length': 'رمز عبور باید حداقل ۸ کاراکتر باشد.', 'signup.error.length': 'رمز عبور باید حداقل ۸ کاراکتر باشد.',
'signup.error.create': 'ایجاد حساب ممکن نشد.', 'signup.error.create': 'ایجاد حساب ممکن نشد.',
'signup.error.cellExists': 'signup.error.alreadyOnSite':
'این شماره موبایل قبلاً در سیستم مدیریت یکپارچه ی مشکی (__MESHKEE_APP__) حساب دارد ({sites}). اطلاعات شما همان می‌ماند؛ رمزی که اینجا وارد کردید جایگزین رمز مشکی می‌شود. با ادامه، تأیید پیامکی را انجام دهید تا وارد این وب‌سایت هم شوید.', 'این شماره موبایل قبلاً در این وب‌سایت ثبت شده است. لطفاً وارد شوید.',
'signup.error.cellExistsGeneric': 'signup.error.sessionExpired': 'نشست ثبت‌نام منقضی شد. لطفاً دوباره شروع کنید.',
'این شماره موبایل قبلاً در سیستم مدیریت یکپارچه ی مشکی (__MESHKEE_APP__) (وب‌سایت دیگری) حساب دارد. اطلاعات شما همان می‌ماند؛ رمزی که اینجا وارد کردید جایگزین رمز مشکی می‌شود. با ادامه، تأیید پیامکی را انجام دهید تا وارد این وب‌سایت هم شوید.', 'signup.info.verify': 'کد پیامکی ارسال‌شده را وارد کنید تا شماره تأیید شود.',
'signup.info.verify': 'حساب ایجاد شد. کد پیامکی را وارد کنید تا شماره تأیید شود و وارد شوید.', 'signup.info.choosePassword': 'شماره تأیید شد. برای تکمیل ایجاد حساب، رمز عبور انتخاب کنید.',
'signup.info.verifyExisting': 'signup.info.existingAccount':
'کد پیامکی را وارد کنید تا به این وب‌سایت بپیوندید. رمز عبور و اطلاعات مشکی شما همان می‌ماند.', 'این شماره موبایل قبلاً در سیستم مدیریت یکپارچه ی مشکی (__MESHKEE_APP__) حساب دارد ({sites}). رمز عبور شما همان رمز وب‌سایت‌های دیگر مشکی است.',
'signup.info.verifyExistingPasswordUpdated': 'signup.info.existingAccountGeneric':
'کد پیامکی را وارد کنید تا عضویت تمام شود. پس از تأیید، رمز مشکی شما به رمزی که همین الان وارد کردید به‌روز می‌شود.', 'این شماره موبایل قبلاً در سیستم مدیریت یکپارچه ی مشکی (__MESHKEE_APP__) حساب دارد. رمز عبور شما همان رمز وب‌سایت‌های دیگر مشکی است.',
'signup.info.existingPasswordHint':
'با ادامه، با همان رمز به این وب‌سایت می‌پیوندید. اگر رمز را فراموش کرده‌اید، از فراموشی رمز عبور استفاده کنید.',
'signup.toast.linkedExisting': 'با حساب موجود مشکی به این وب‌سایت پیوستید.',
'signup.toast.passwordUpdated': 'signup.toast.passwordUpdated':
'رمز مشکی شما به رمزی که در این وب‌سایت وارد کردید به‌روز شد.', 'رمز مشکی شما به رمزی که در این وب‌سایت وارد کردید به‌روز شد.',
'signup.cellExists.title': 'حساب موجود در مشکی',
'signup.cellExists.admit': 'متوجه شدم، ادامه',
'forgot.back': 'بازگشت به ورود', 'forgot.back': 'بازگشت به ورود',
'forgot.title': 'فراموشی رمز عبور', 'forgot.title': 'فراموشی رمز عبور',
+320 -150
View File
@@ -1,20 +1,17 @@
import { useState, type ReactNode } from 'react' import { useState, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
import { useNavigate, useSearchParams } from 'react-router-dom' import { useNavigate, useSearchParams } from 'react-router-dom'
import { Eye, EyeOff, Smartphone, Lock, KeyRound, ArrowLeft, User, X } from 'lucide-react' import { Eye, EyeOff, Smartphone, Lock, KeyRound, ArrowLeft, User } from 'lucide-react'
import { import { useAuth } from '../context/AuthContext'
useAuth,
CUSTOMER_ACCESS_MESSAGE,
} from '../context/AuthContext'
import { useTenantBranding } from '../context/TenantBrandingContext' import { useTenantBranding } from '../context/TenantBrandingContext'
import { ApiError } from '../lib/api' import { ApiError } from '../lib/api'
import { toE164CellNumber } from '../lib/cellNumber' import { toE164CellNumber } from '../lib/cellNumber'
import { getTenantDomain } from '../lib/config' import { getTenantDomain } from '../lib/config'
import { import {
logout as logoutRequest, completeSignup,
register,
resetPassword, resetPassword,
sendOtp, sendOtp,
startSignup,
verifySignupOtp,
} from '../services/authService' } from '../services/authService'
import { LanguageSelect, PasswordStrengthMeter, isPasswordStrong, useLocale, useToast } from '@meshkee/dashboard-ui' import { LanguageSelect, PasswordStrengthMeter, isPasswordStrong, useLocale, useToast } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT' import { useT } from '../i18n/useT'
@@ -24,20 +21,11 @@ import styles from './LoginPage.module.css'
type AuthView = 'login' | 'signup' | 'forgot' | 'otp' type AuthView = 'login' | 'signup' | 'forgot' | 'otp'
type SmsStep = 'phone' | 'code' type SmsStep = 'phone' | 'code'
type SignupStep = 'details' | 'otp' | 'password' | 'existing'
const MESHKEE_APP_MARKER = '__MESHKEE_APP__' const MESHKEE_APP_MARKER = '__MESHKEE_APP__'
const MESHKEE_SITE_URL = 'https://meshkee.com' 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 { function renderWithMeshkeeLink(message: string, linkClassName: string): ReactNode {
const idx = message.indexOf(MESHKEE_APP_MARKER) const idx = message.indexOf(MESHKEE_APP_MARKER)
if (idx === -1) return message if (idx === -1) return message
@@ -61,15 +49,16 @@ export function LoginPage() {
const navigate = useNavigate() const navigate = useNavigate()
const [searchParams] = useSearchParams() const [searchParams] = useSearchParams()
const redirectTo = safeRedirectPath(searchParams.get('redirect')) const redirectTo = safeRedirectPath(searchParams.get('redirect'))
const { login, loginWithOtp } = useAuth() const { login, loginWithOtp, establishSession } = useAuth()
const { businessName, businessNameEn, logoUrl } = useTenantBranding() const { businessName, businessNameEn, logoUrl } = useTenantBranding()
const tenantDomain = getTenantDomain() const tenantDomain = getTenantDomain()
const t = useT() const t = useT()
const { showToast } = useToast() const { showToast } = useToast()
const { locale, dir } = useLocale() const { locale } = useLocale()
const [view, setView] = useState<AuthView>('login') const [view, setView] = useState<AuthView>('login')
const [smsStep, setSmsStep] = useState<SmsStep>('phone') const [smsStep, setSmsStep] = useState<SmsStep>('phone')
const [signupStep, setSignupStep] = useState<SignupStep>('details')
const [showPassword, setShowPassword] = useState(false) const [showPassword, setShowPassword] = useState(false)
const [phone, setPhone] = useState('') const [phone, setPhone] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
@@ -84,8 +73,8 @@ export function LoginPage() {
const [error, setError] = useState('') const [error, setError] = useState('')
const [info, setInfo] = useState('') const [info, setInfo] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
/** `null` closed; `''` generic notice; otherwise site names */ const [registrationToken, setRegistrationToken] = useState('')
const [cellExistsSites, setCellExistsSites] = useState<string | null>(null) const [existingSites, setExistingSites] = useState<string[]>([])
function clearMessages() { function clearMessages() {
setError('') setError('')
@@ -102,9 +91,11 @@ export function LoginPage() {
setNewPassword('') setNewPassword('')
setConfirmNewPassword('') setConfirmNewPassword('')
setSmsStep('phone') setSmsStep('phone')
setSignupStep('details')
setCodeSent(false) setCodeSent(false)
setShowPassword(false) setShowPassword(false)
setCellExistsSites(null) setRegistrationToken('')
setExistingSites([])
clearMessages() 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() const cellNumber = resolveCellNumber()
if (!cellNumber) return if (!cellNumber) return
setIsSubmitting(true) setIsSubmitting(true)
try { try {
const data = await register({ const result = await startSignup({
cellNumber, cellNumber,
password,
firstName: firstName.trim(), firstName: firstName.trim(),
lastName: lastName.trim(), lastName: lastName.trim(),
domain: tenantDomain, domain: tenantDomain,
...(acknowledgeExistingAccount ? { acknowledgeExistingAccount: true } : {}),
}) })
setCellExistsSites(null) if (!result.requiresOtp && result.registrationToken) {
advanceAfterSignupVerify({
// Password is applied only after OTP when passwordPending; toast then. registrationToken: result.registrationToken,
if (data.passwordUpdated) { existingAccount: Boolean(result.existingAccount),
showToast(t('signup.toast.passwordUpdated'), 'info') sites: result.sites,
} })
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'),
)
return return
} }
if (data.user.customerBusinesses.length === 0) { setCodeSent(true)
logoutRequest() setInfo(result.enabled ? t('signup.info.verify') : result.message)
setError(CUSTOMER_ACCESS_MESSAGE) startCountdown()
return } 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) navigate(redirectTo)
} catch (err) { } 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')) handleApiError(err, t('signup.error.create'))
} finally { } finally {
setIsSubmitting(false) setIsSubmitting(false)
} }
} }
async function continueToSmsVerify(cellNumber: string, infoMessage?: string) { async function handleSignupPassword(e: React.FormEvent) {
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) {
e.preventDefault() e.preventDefault()
clearMessages() clearMessages()
setCellExistsSites(null)
if (password !== confirmPassword) { if (password !== confirmPassword) {
setError(t('signup.error.match')) setError(t('signup.error.match'))
return return
} }
if (!isPasswordStrong(password)) { if (!isPasswordStrong(password)) {
setError(t('forgot.error.strength')) setError(t('forgot.error.strength'))
return return
} }
if (!registrationToken) {
await runSignup() setError(t('signup.error.sessionExpired'))
} setSignupStep('details')
/**
* 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)
return 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) { async function handleResetPassword(e: React.FormEvent) {
@@ -379,7 +450,17 @@ export function LoginPage() {
view === 'login' view === 'login'
? { title: t('login.welcome'), subtitle: t('login.subtitle') } ? { title: t('login.welcome'), subtitle: t('login.subtitle') }
: view === 'signup' : 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' : view === 'forgot'
? { ? {
title: t('forgot.title'), title: t('forgot.title'),
@@ -405,15 +486,25 @@ export function LoginPage() {
</div> </div>
</div> </div>
<div className={styles.card}> <div className={styles.card}>
{(view === 'forgot' || view === 'otp') && ( {(view === 'forgot' || view === 'otp' || (view === 'signup' && signupStep !== 'details')) && (
<button <button
type="button" type="button"
className={styles.backBtn} className={styles.backBtn}
onClick={() => switchView('login')} onClick={() => {
if (view === 'signup') {
handleSignupBack()
return
}
switchView('login')
}}
disabled={isSubmitting} disabled={isSubmitting}
> >
<ArrowLeft size={18} /> <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> </button>
)} )}
<div className={styles.cardHeader}> <div className={styles.cardHeader}>
@@ -527,7 +618,8 @@ export function LoginPage() {
{view === 'signup' && ( {view === 'signup' && (
<> <>
<form className={styles.form} onSubmit={handleSignup}> {signupStep === 'details' && (
<form className={styles.form} onSubmit={handleSignupStart}>
{error && ( {error && (
<div className={styles.error} role="alert"> <div className={styles.error} role="alert">
{error} {error}
@@ -587,6 +679,131 @@ export function LoginPage() {
</div> </div>
</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-code">{t('otp.code')}</label>
<div className={styles.inputWrap}>
<KeyRound size={18} className={styles.inputIcon} />
<input
id="signup-code"
type="text"
inputMode="numeric"
placeholder="123456"
maxLength={6}
value={smsCode}
onChange={(e) => setSmsCode(e.target.value.replace(/\D/g, ''))}
required
disabled={isSubmitting}
/>
</div>
</div>
<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>
<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.linkBtn}
onClick={() => {
clearMessages()
setPassword('')
setConfirmPassword('')
setSmsCode('')
setSignupStep('details')
setRegistrationToken('')
setExistingSites([])
setSmsStep('phone')
setCodeSent(false)
setView('forgot')
}}
disabled={isSubmitting}
>
{t('login.forgot')}
</button>
</p>
</div>
)}
{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}> <div className={styles.field}>
<label htmlFor="signup-password">{t('login.password')}</label> <label htmlFor="signup-password">{t('login.password')}</label>
<div className={styles.inputWrap}> <div className={styles.inputWrap}>
@@ -664,7 +881,9 @@ export function LoginPage() {
{isSubmitting ? t('signup.creating') : t('signup.create')} {isSubmitting ? t('signup.creating') : t('signup.create')}
</button> </button>
</form> </form>
)}
{signupStep === 'details' && (
<p className={styles.footerText}> <p className={styles.footerText}>
<span>{t('signup.hasAccount')}</span> <span>{t('signup.hasAccount')}</span>
<button <button
@@ -676,6 +895,7 @@ export function LoginPage() {
{t('signup.signIn')} {t('signup.signIn')}
</button> </button>
</p> </p>
)}
</> </>
)} )}
@@ -935,56 +1155,6 @@ export function LoginPage() {
> >
Powered by Meshkee.app Powered by Meshkee.app
</a> </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> </div>
) )
} }
+36
View File
@@ -7,6 +7,8 @@ import type {
OtpSendResponse, OtpSendResponse,
OtpVerifyResponse, OtpVerifyResponse,
RegisterResponse, RegisterResponse,
SignupStartResponse,
SignupVerifyResponse,
HandoffCreateResponse, HandoffCreateResponse,
UserProfile, UserProfile,
} from '../types/auth' } from '../types/auth'
@@ -45,6 +47,40 @@ export async function register(input: {
return data 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) { export async function fetchCurrentUser(signal?: AbortSignal) {
return apiRequest<MeResponse>('/auth/me', { auth: true, signal }) return apiRequest<MeResponse>('/auth/me', { auth: true, signal })
} }
+2
View File
@@ -6,6 +6,8 @@ export type {
OtpSendResponse, OtpSendResponse,
OtpVerifyResponse, OtpVerifyResponse,
RegisterResponse, RegisterResponse,
SignupStartResponse,
SignupVerifyResponse,
HandoffCreateResponse, HandoffCreateResponse,
UserProfile, UserProfile,
} from '@meshkee/dashboard-core' } from '@meshkee/dashboard-core'
+2
View File
@@ -9,6 +9,8 @@ export type {
OtpSendResponse, OtpSendResponse,
OtpVerifyResponse, OtpVerifyResponse,
RegisterResponse, RegisterResponse,
SignupStartResponse,
SignupVerifyResponse,
HandoffCreateResponse, HandoffCreateResponse,
UserProfile, UserProfile,
} from './types/auth' } from './types/auth'
+21
View File
@@ -63,9 +63,30 @@ export interface RegisterResponse {
passwordPending?: boolean passwordPending?: boolean
/** True when an existing Meshkee account password was already replaced. */ /** True when an existing Meshkee account password was already replaced. */
passwordUpdated?: boolean passwordUpdated?: boolean
/** True when this signup linked an existing Meshkee account. */
existingAccount?: boolean
registeredBusiness?: { id: string; name: string; slug: string } 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 { export interface OtpSendResponse {
enabled: boolean enabled: boolean
message: string message: string