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.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;
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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': 'فراموشی رمز عبور',
|
||||||
|
|||||||
@@ -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,155 +618,284 @@ export function LoginPage() {
|
|||||||
|
|
||||||
{view === 'signup' && (
|
{view === 'signup' && (
|
||||||
<>
|
<>
|
||||||
<form className={styles.form} onSubmit={handleSignup}>
|
{signupStep === 'details' && (
|
||||||
{error && (
|
<form className={styles.form} onSubmit={handleSignupStart}>
|
||||||
<div className={styles.error} role="alert">
|
{error && (
|
||||||
{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>
|
</div>
|
||||||
)}
|
|
||||||
{info && <div className={styles.info}>{info}</div>}
|
|
||||||
|
|
||||||
<div className={styles.fieldRow}>
|
|
||||||
<div className={styles.field}>
|
<div className={styles.field}>
|
||||||
<label htmlFor="signup-first">{t('signup.firstName')}</label>
|
<label htmlFor="signup-phone">{t('login.mobile')}</label>
|
||||||
<div className={styles.inputWrap}>
|
<div className={styles.inputWrap}>
|
||||||
<User size={18} className={styles.inputIcon} />
|
<Smartphone size={18} className={styles.inputIcon} />
|
||||||
<input
|
<input
|
||||||
id="signup-first"
|
id="signup-phone"
|
||||||
type="text"
|
type="tel"
|
||||||
placeholder={t('signup.firstName')}
|
inputMode="tel"
|
||||||
value={firstName}
|
placeholder="09123456789"
|
||||||
onChange={(e) => setFirstName(e.target.value)}
|
value={phone}
|
||||||
|
onChange={(e) => setPhone(e.target.value)}
|
||||||
required
|
required
|
||||||
minLength={2}
|
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
/>
|
/>
|
||||||
</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}>
|
<div className={styles.field}>
|
||||||
<label htmlFor="signup-last">{t('signup.lastName')}</label>
|
<label htmlFor="signup-code">{t('otp.code')}</label>
|
||||||
<div className={styles.inputWrap}>
|
<div className={styles.inputWrap}>
|
||||||
<User size={18} className={styles.inputIcon} />
|
<KeyRound size={18} className={styles.inputIcon} />
|
||||||
<input
|
<input
|
||||||
id="signup-last"
|
id="signup-code"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={t('signup.lastName')}
|
inputMode="numeric"
|
||||||
value={lastName}
|
placeholder="123456"
|
||||||
onChange={(e) => setLastName(e.target.value)}
|
maxLength={6}
|
||||||
|
value={smsCode}
|
||||||
|
onChange={(e) => setSmsCode(e.target.value.replace(/\D/g, ''))}
|
||||||
required
|
required
|
||||||
minLength={2}
|
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.field}>
|
<div className={styles.resendRow}>
|
||||||
<label htmlFor="signup-phone">{t('login.mobile')}</label>
|
{countdown > 0 ? (
|
||||||
<div className={styles.inputWrap}>
|
<span className={styles.countdown}>
|
||||||
<Smartphone size={18} className={styles.inputIcon} />
|
{t('common.resendIn', { seconds: countdown })}
|
||||||
<input
|
</span>
|
||||||
id="signup-phone"
|
) : (
|
||||||
type="tel"
|
<button
|
||||||
inputMode="tel"
|
type="button"
|
||||||
placeholder="09123456789"
|
className={styles.linkBtn}
|
||||||
value={phone}
|
onClick={() => void handleSignupResendCode()}
|
||||||
onChange={(e) => setPhone(e.target.value)}
|
disabled={isSubmitting}
|
||||||
required
|
>
|
||||||
disabled={isSubmitting}
|
{t('common.resend')}
|
||||||
/>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.field}>
|
<button
|
||||||
<label htmlFor="signup-password">{t('login.password')}</label>
|
type="submit"
|
||||||
<div className={styles.inputWrap}>
|
className={styles.submitBtn}
|
||||||
<Lock size={18} className={styles.inputIcon} />
|
disabled={isSubmitting || smsCode.length !== 6}
|
||||||
<input
|
>
|
||||||
id="signup-password"
|
{isSubmitting ? t('signup.verifying') : t('signup.verifyCode')}
|
||||||
type={showPassword ? 'text' : 'password'}
|
</button>
|
||||||
dir="ltr"
|
</form>
|
||||||
lang="en"
|
)}
|
||||||
placeholder={t('signup.passwordPlaceholder')}
|
|
||||||
value={password}
|
{signupStep === 'existing' && (
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
<div className={styles.form}>
|
||||||
required
|
{error && (
|
||||||
minLength={8}
|
<div className={styles.error} role="alert">
|
||||||
disabled={isSubmitting}
|
{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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.togglePassword}
|
className={styles.linkBtn}
|
||||||
onClick={() => setShowPassword((v) => !v)}
|
onClick={() => {
|
||||||
aria-label={showPassword ? t('login.hidePassword') : t('login.showPassword')}
|
clearMessages()
|
||||||
|
setPassword('')
|
||||||
|
setConfirmPassword('')
|
||||||
|
setSmsCode('')
|
||||||
|
setSignupStep('details')
|
||||||
|
setRegistrationToken('')
|
||||||
|
setExistingSites([])
|
||||||
|
setSmsStep('phone')
|
||||||
|
setCodeSent(false)
|
||||||
|
setView('forgot')
|
||||||
|
}}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
>
|
>
|
||||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
{t('login.forgot')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className={styles.field}>
|
{signupStep === 'password' && (
|
||||||
<label htmlFor="signup-confirm">{t('signup.confirm')}</label>
|
<form className={styles.form} onSubmit={handleSignupPassword}>
|
||||||
<div className={styles.inputWrap}>
|
{error && (
|
||||||
<Lock size={18} className={styles.inputIcon} />
|
<div className={styles.error} role="alert">
|
||||||
<input
|
{error}
|
||||||
id="signup-confirm"
|
</div>
|
||||||
type={showPassword ? 'text' : 'password'}
|
)}
|
||||||
dir="ltr"
|
{info && <div className={styles.info}>{info}</div>}
|
||||||
lang="en"
|
|
||||||
placeholder={t('signup.confirmPlaceholder')}
|
<div className={styles.field}>
|
||||||
value={confirmPassword}
|
<label htmlFor="signup-password">{t('login.password')}</label>
|
||||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
<div className={styles.inputWrap}>
|
||||||
required
|
<Lock size={18} className={styles.inputIcon} />
|
||||||
minLength={8}
|
<input
|
||||||
disabled={isSubmitting}
|
id="signup-password"
|
||||||
aria-describedby="signup-password-strength signup-password-strength-rules"
|
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>
|
</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
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className={styles.submitBtn}
|
className={styles.submitBtn}
|
||||||
disabled={
|
disabled={
|
||||||
isSubmitting || !isPasswordStrong(password) || password !== confirmPassword
|
isSubmitting || !isPasswordStrong(password) || password !== confirmPassword
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{isSubmitting ? t('signup.creating') : t('signup.create')}
|
{isSubmitting ? t('signup.creating') : t('signup.create')}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
<p className={styles.footerText}>
|
{signupStep === 'details' && (
|
||||||
<span>{t('signup.hasAccount')}</span>
|
<p className={styles.footerText}>
|
||||||
<button
|
<span>{t('signup.hasAccount')}</span>
|
||||||
type="button"
|
<button
|
||||||
className={styles.linkBtn}
|
type="button"
|
||||||
onClick={() => switchView('login')}
|
className={styles.linkBtn}
|
||||||
disabled={isSubmitting}
|
onClick={() => switchView('login')}
|
||||||
>
|
disabled={isSubmitting}
|
||||||
{t('signup.signIn')}
|
>
|
||||||
</button>
|
{t('signup.signIn')}
|
||||||
</p>
|
</button>
|
||||||
|
</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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ export type {
|
|||||||
OtpSendResponse,
|
OtpSendResponse,
|
||||||
OtpVerifyResponse,
|
OtpVerifyResponse,
|
||||||
RegisterResponse,
|
RegisterResponse,
|
||||||
|
SignupStartResponse,
|
||||||
|
SignupVerifyResponse,
|
||||||
HandoffCreateResponse,
|
HandoffCreateResponse,
|
||||||
UserProfile,
|
UserProfile,
|
||||||
} from './types/auth'
|
} from './types/auth'
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user