diff --git a/apps/business/src/i18n/messages.ts b/apps/business/src/i18n/messages.ts index dd4c198..3801be1 100644 --- a/apps/business/src/i18n/messages.ts +++ b/apps/business/src/i18n/messages.ts @@ -679,6 +679,12 @@ const en = { 'products.form.imagesAdd': 'Add images', 'products.form.imagesHint': 'Upload multiple product images. Click + to add more.', 'products.form.imagesRemove': 'Remove image {index}', + 'products.form.catalog': 'Product catalog (PDF)', + 'products.form.catalogHint': 'Optional. PDF only, maximum 2MB.', + 'products.form.catalogView': 'View catalog', + 'products.form.catalogRemove': 'Remove catalog file', + 'products.form.catalog.errorType': 'Catalog file must be a PDF.', + 'products.form.catalog.errorSize': 'Catalog PDF must be 2MB or smaller.', 'products.form.tags': 'Tags', 'products.form.tagsPlaceholder': 'Type a tag and press Enter', 'products.form.cancel': 'Cancel', @@ -3440,6 +3446,12 @@ const fa: Record = { 'products.form.imagesAdd': 'افزودن تصاویر', 'products.form.imagesHint': 'چند تصویر محصول آپلود کنید. برای افزودن بیشتر روی + کلیک کنید.', 'products.form.imagesRemove': 'حذف تصویر {index}', + 'products.form.catalog': 'کاتالوگ محصول (PDF)', + 'products.form.catalogHint': 'اختیاری. فقط PDF، حداکثر ۲ مگابایت.', + 'products.form.catalogView': 'مشاهده کاتالوگ', + 'products.form.catalogRemove': 'حذف فایل کاتالوگ', + 'products.form.catalog.errorType': 'فایل کاتالوگ باید PDF باشد.', + 'products.form.catalog.errorSize': 'حجم PDF کاتالوگ باید حداکثر ۲ مگابایت باشد.', 'products.form.tags': 'برچسب‌ها', 'products.form.tagsPlaceholder': 'برچسب را بنویسید و Enter بزنید', 'products.form.cancel': 'انصراف', diff --git a/apps/business/src/pages/AddNewProductPage.module.css b/apps/business/src/pages/AddNewProductPage.module.css index 8e205c5..6c987cf 100644 --- a/apps/business/src/pages/AddNewProductPage.module.css +++ b/apps/business/src/pages/AddNewProductPage.module.css @@ -87,6 +87,64 @@ color: var(--text-muted); } +.catalogHint { + margin: 0 0 8px; + font-size: 12px; + color: var(--text-muted); +} + +.catalogRow { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; +} + +.catalogInput { + max-width: 100%; + font-size: 13px; +} + +.catalogSelected { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + border-radius: var(--radius-sm); + background: rgba(148, 163, 184, 0.12); + font-size: 13px; + color: var(--text-secondary); + min-width: 0; +} + +.catalogSelected span, +.catalogLink { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 280px; +} + +.catalogLink { + color: var(--primary); + text-decoration: underline; +} + +.catalogClear { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 2px; + border-radius: 4px; + color: var(--text-muted); + flex-shrink: 0; +} + +.catalogClear:hover { + color: var(--text); + background: rgba(148, 163, 184, 0.2); +} + .summaryField textarea { min-height: calc((var(--field-height) + 8px) * 2); padding-inline-end: 12px; diff --git a/apps/business/src/pages/AddNewProductPage.tsx b/apps/business/src/pages/AddNewProductPage.tsx index 4d4d2b4..ae82802 100644 --- a/apps/business/src/pages/AddNewProductPage.tsx +++ b/apps/business/src/pages/AddNewProductPage.tsx @@ -1,6 +1,6 @@ -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { Link, useParams, useNavigate } from 'react-router-dom' -import { Sparkles } from 'lucide-react' +import { FileText, Sparkles, X } from 'lucide-react' import { CategoryTreeSelect } from '../components/CategoryTreeSelect' import { SearchableSelect } from '../components/SearchableSelect' import { ImageCropper } from '../components/ImageCropper' @@ -18,6 +18,7 @@ import { galleryMediaIdsAfterChange, resolveDataUrlToMediaId, resolveDataUrlsToMediaIds, + uploadCatalogPdf, } from '../services/mediaService' import { createProduct, @@ -34,12 +35,15 @@ import aiStyles from '../styles/ai.module.css' import styles from './AddNewProductPage.module.css' import { useT } from '../i18n/useT' +const CATALOG_PDF_MAX_BYTES = 2 * 1024 * 1024 + export function AddNewProductPage() { const { id } = useParams() const navigate = useNavigate() const t = useT() const { showToast } = useToast() const isEdit = Boolean(id) + const catalogInputRef = useRef(null) const [categories, setCategories] = useState([]) const [brandOptions, setBrandOptions] = useState([]) @@ -54,6 +58,11 @@ export function AddNewProductPage() { const [tags, setTags] = useState([]) const [thumbnailMediaId, setThumbnailMediaId] = useState(null) const [galleryMediaIds, setGalleryMediaIds] = useState([]) + const [catalogMediaId, setCatalogMediaId] = useState(null) + const [catalogUrl, setCatalogUrl] = useState(null) + const [catalogFileName, setCatalogFileName] = useState(null) + const [catalogFile, setCatalogFile] = useState(null) + const [catalogRemoved, setCatalogRemoved] = useState(false) const [loaded, setLoaded] = useState(!isEdit) const [isSubmitting, setIsSubmitting] = useState(false) const [error, setError] = useState('') @@ -172,6 +181,11 @@ export function AddNewProductPage() { setTags(form.tags) setThumbnailMediaId(form.thumbnailMediaId) setGalleryMediaIds(form.galleryMediaIds) + setCatalogMediaId(form.catalogMediaId) + setCatalogUrl(form.catalogUrl) + setCatalogFileName(form.catalogFileName) + setCatalogFile(null) + setCatalogRemoved(false) setLoaded(true) } catch (err) { if (err instanceof DOMException && err.name === 'AbortError') return @@ -185,6 +199,39 @@ export function AddNewProductPage() { if (!loaded) return null + function clearCatalogFile() { + setCatalogFile(null) + setCatalogMediaId(null) + setCatalogUrl(null) + setCatalogFileName(null) + setCatalogRemoved(true) + if (catalogInputRef.current) { + catalogInputRef.current.value = '' + } + } + + function handleCatalogFileChange(e: React.ChangeEvent) { + 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) { e.preventDefault() setIsSubmitting(true) @@ -208,6 +255,14 @@ export function AddNewProductPage() { galleryMediaIds, ) + let nextCatalogMediaId: string | null = catalogMediaId + if (catalogFile) { + const uploaded = await uploadCatalogPdf(catalogFile) + nextCatalogMediaId = uploaded.id + } else if (catalogRemoved) { + nextCatalogMediaId = null + } + const payload = { title: nameEn.trim(), nameFa: nameFa.trim(), @@ -217,6 +272,7 @@ export function AddNewProductPage() { brandId: brandId || undefined, featuredMediaId: featuredMediaId ?? undefined, galleryMediaIds: nextGalleryMediaIds, + catalogMediaId: nextCatalogMediaId ?? undefined, tags, status: 'published' as const, } @@ -227,6 +283,7 @@ export function AddNewProductPage() { categoryId: categoryId || null, brandId: brandId || null, featuredMediaId, + catalogMediaId: nextCatalogMediaId, }) } else { await createProduct(payload) @@ -434,6 +491,48 @@ export function AddNewProductPage() { /> +
+ +

{t('products.form.catalogHint')}

+
+ + {(catalogFileName || catalogUrl) && ( +
+ + {catalogUrl && !catalogFile ? ( + + {catalogFileName || t('products.form.catalogView')} + + ) : ( + {catalogFileName} + )} + +
+ )} +
+
+
CATALOG_PDF_MAX_BYTES) { + throw new Error('Catalog PDF must be 2MB or smaller') + } + const uploaded = await uploadMediaFiles([file], signal) + const item = uploaded[0] + if (!item) { + throw new Error('Catalog upload failed') + } + return item +} + export async function resolveDataUrlToMediaId( value: string | null, filename: string, diff --git a/apps/business/src/services/productService.ts b/apps/business/src/services/productService.ts index 1d8663f..1658117 100644 --- a/apps/business/src/services/productService.ts +++ b/apps/business/src/services/productService.ts @@ -31,6 +31,9 @@ export interface ProductApi { seoMetaDescription?: string | null thumbnailUrl: string | null thumbnailMediaId: string | null + catalogMediaId: string | null + catalogUrl: string | null + catalogFileName: string | null image: string thumbnail: string images: { mediaId: string; url: string }[] @@ -58,6 +61,7 @@ export interface CreateProductPayload { brandId?: string status?: 'draft' | 'published' | 'archived' featuredMediaId?: string + catalogMediaId?: string galleryMediaIds?: string[] tags?: string[] seoMetaTitle?: string | null @@ -73,6 +77,7 @@ export interface UpdateProductPayload { brandId?: string | null status?: 'draft' | 'published' | 'archived' featuredMediaId?: string | null + catalogMediaId?: string | null galleryMediaIds?: string[] tags?: string[] seoMetaTitle?: string | null @@ -155,6 +160,9 @@ export function mapProductApiToFormState(product: ProductApi) { seoMetaDescription: product.seoMetaDescription ?? '', thumbnailMediaId: product.thumbnailMediaId, galleryMediaIds: product.galleryMediaIds, + catalogMediaId: product.catalogMediaId, + catalogUrl: product.catalogUrl, + catalogFileName: product.catalogFileName, } } diff --git a/apps/customer/src/context/AuthContext.tsx b/apps/customer/src/context/AuthContext.tsx index 4a1d235..7381c82 100644 --- a/apps/customer/src/context/AuthContext.tsx +++ b/apps/customer/src/context/AuthContext.tsx @@ -27,6 +27,8 @@ interface AuthContextValue { cellNumber: string, code: string, ) => Promise<{ passwordUpdated?: boolean }> + /** Apply an already-authenticated user (tokens must already be stored). */ + establishSession: (user: AuthUser) => Promise logout: () => void setUser: (user: AuthUser) => void } @@ -136,9 +138,14 @@ export function AuthProvider({ children }: { children: ReactNode }) { return { passwordUpdated: data.passwordUpdated } }, []) + const establishSession = useCallback(async (nextUser: AuthUser) => { + await assertCustomerAccess(nextUser) + setUser(nextUser) + }, []) + const value = useMemo( - () => ({ user, isLoading, login, loginWithOtp, logout, setUser }), - [user, isLoading, login, loginWithOtp, logout], + () => ({ user, isLoading, login, loginWithOtp, establishSession, logout, setUser }), + [user, isLoading, login, loginWithOtp, establishSession, logout], ) return {children} diff --git a/apps/customer/src/i18n/messages.ts b/apps/customer/src/i18n/messages.ts index 104c690..6ed3ba4 100644 --- a/apps/customer/src/i18n/messages.ts +++ b/apps/customer/src/i18n/messages.ts @@ -330,31 +330,42 @@ const en = { 'signup.title': 'Create account', 'signup.subtitle': 'Register as a customer of {domain}', + 'signup.subtitleDetails': 'Enter your name and mobile number', + 'signup.subtitleOtp': 'Enter the SMS verification code', + 'signup.subtitlePassword': 'Choose a password for your Meshkee account', + 'signup.subtitleExisting': 'You already have a Meshkee account', 'signup.firstName': 'First name', 'signup.lastName': 'Last name', 'signup.passwordPlaceholder': 'Choose a password', 'signup.confirm': 'Confirm password', 'signup.confirmPlaceholder': 'Repeat your password', + 'signup.continue': 'Continue', + 'signup.sendingCode': 'Sending code...', + 'signup.verifyCode': 'Verify code', + 'signup.verifying': 'Verifying...', 'signup.create': 'Create account', 'signup.creating': 'Creating account...', + 'signup.joinContinue': 'Continue and join', + 'signup.back': 'Back', 'signup.hasAccount': 'Already have an account?', 'signup.signIn': 'Sign in', 'signup.error.match': 'Passwords do not match.', 'signup.error.length': 'Password must be at least 8 characters.', 'signup.error.create': 'Unable to create account.', - 'signup.error.cellExists': - 'This mobile number already has an account in Meshkee Unified Management System (__MESHKEE_APP__) on {sites}. Your profile stays the same; the password you enter here will replace your Meshkee password. Continue to verify by SMS and join this website too.', - 'signup.error.cellExistsGeneric': - 'This mobile number already has an account in Meshkee Unified Management System (__MESHKEE_APP__) on another website. Your profile stays the same; the password you enter here will replace your Meshkee password. Continue to verify by SMS and join this website too.', - 'signup.info.verify': 'Account created. Enter the SMS code to verify your number and sign in.', - 'signup.info.verifyExisting': - 'Enter the SMS code to join this website. Your Meshkee password and profile stay the same.', - 'signup.info.verifyExistingPasswordUpdated': - 'Enter the SMS code to finish joining. Your Meshkee password will then be updated to the one you just entered.', + 'signup.error.alreadyOnSite': + 'This mobile number is already registered on this website. Please sign in instead.', + 'signup.error.sessionExpired': 'Registration session expired. Please start again.', + 'signup.info.verify': 'Enter the SMS code we sent to verify your number.', + 'signup.info.choosePassword': 'Phone verified. Choose a password to finish creating your account.', + 'signup.info.existingAccount': + 'This mobile number already has an account in Meshkee Unified Management System (__MESHKEE_APP__) on {sites}. Your password stays the same as on those websites.', + 'signup.info.existingAccountGeneric': + 'This mobile number already has an account in Meshkee Unified Management System (__MESHKEE_APP__). Your password stays the same as on your other Meshkee websites.', + 'signup.info.existingPasswordHint': + 'Continue to join this website with that same password. If you forgot it, use Forgot password.', + 'signup.toast.linkedExisting': 'Joined this website with your existing Meshkee account.', 'signup.toast.passwordUpdated': 'Your Meshkee password was updated to the one you entered on this website.', - 'signup.cellExists.title': 'Existing Meshkee account', - 'signup.cellExists.admit': 'I understand, continue', 'forgot.back': 'Back to sign in', 'forgot.title': 'Forgot password', @@ -741,31 +752,42 @@ const fa: Record = { 'signup.title': 'ایجاد حساب', 'signup.subtitle': 'به‌عنوان مشتری {domain} ثبت‌نام کنید', + 'signup.subtitleDetails': 'نام و شماره موبایل خود را وارد کنید', + 'signup.subtitleOtp': 'کد تأیید پیامکی را وارد کنید', + 'signup.subtitlePassword': 'برای حساب مشکی خود رمز عبور انتخاب کنید', + 'signup.subtitleExisting': 'قبلاً در مشکی حساب دارید', 'signup.firstName': 'نام', 'signup.lastName': 'نام خانوادگی', 'signup.passwordPlaceholder': 'یک رمز عبور انتخاب کنید', 'signup.confirm': 'تأیید رمز عبور', 'signup.confirmPlaceholder': 'رمز عبور را تکرار کنید', + 'signup.continue': 'ادامه', + 'signup.sendingCode': 'در حال ارسال کد...', + 'signup.verifyCode': 'تأیید کد', + 'signup.verifying': 'در حال تأیید...', 'signup.create': 'ایجاد حساب', 'signup.creating': 'در حال ایجاد حساب...', + 'signup.joinContinue': 'ادامه و عضویت', + 'signup.back': 'بازگشت', 'signup.hasAccount': 'قبلاً حساب دارید؟', 'signup.signIn': 'ورود', 'signup.error.match': 'رمزهای عبور یکسان نیستند.', 'signup.error.length': 'رمز عبور باید حداقل ۸ کاراکتر باشد.', 'signup.error.create': 'ایجاد حساب ممکن نشد.', - 'signup.error.cellExists': - 'این شماره موبایل قبلاً در سیستم مدیریت یکپارچه ی مشکی (__MESHKEE_APP__) حساب دارد ({sites}). اطلاعات شما همان می‌ماند؛ رمزی که اینجا وارد کردید جایگزین رمز مشکی می‌شود. با ادامه، تأیید پیامکی را انجام دهید تا وارد این وب‌سایت هم شوید.', - 'signup.error.cellExistsGeneric': - 'این شماره موبایل قبلاً در سیستم مدیریت یکپارچه ی مشکی (__MESHKEE_APP__) (وب‌سایت دیگری) حساب دارد. اطلاعات شما همان می‌ماند؛ رمزی که اینجا وارد کردید جایگزین رمز مشکی می‌شود. با ادامه، تأیید پیامکی را انجام دهید تا وارد این وب‌سایت هم شوید.', - 'signup.info.verify': 'حساب ایجاد شد. کد پیامکی را وارد کنید تا شماره تأیید شود و وارد شوید.', - 'signup.info.verifyExisting': - 'کد پیامکی را وارد کنید تا به این وب‌سایت بپیوندید. رمز عبور و اطلاعات مشکی شما همان می‌ماند.', - 'signup.info.verifyExistingPasswordUpdated': - 'کد پیامکی را وارد کنید تا عضویت تمام شود. پس از تأیید، رمز مشکی شما به رمزی که همین الان وارد کردید به‌روز می‌شود.', + 'signup.error.alreadyOnSite': + 'این شماره موبایل قبلاً در این وب‌سایت ثبت شده است. لطفاً وارد شوید.', + 'signup.error.sessionExpired': 'نشست ثبت‌نام منقضی شد. لطفاً دوباره شروع کنید.', + 'signup.info.verify': 'کد پیامکی ارسال‌شده را وارد کنید تا شماره تأیید شود.', + 'signup.info.choosePassword': 'شماره تأیید شد. برای تکمیل ایجاد حساب، رمز عبور انتخاب کنید.', + 'signup.info.existingAccount': + 'این شماره موبایل قبلاً در سیستم مدیریت یکپارچه ی مشکی (__MESHKEE_APP__) حساب دارد ({sites}). رمز عبور شما همان رمز وب‌سایت‌های دیگر مشکی است.', + 'signup.info.existingAccountGeneric': + 'این شماره موبایل قبلاً در سیستم مدیریت یکپارچه ی مشکی (__MESHKEE_APP__) حساب دارد. رمز عبور شما همان رمز وب‌سایت‌های دیگر مشکی است.', + 'signup.info.existingPasswordHint': + 'با ادامه، با همان رمز به این وب‌سایت می‌پیوندید. اگر رمز را فراموش کرده‌اید، از فراموشی رمز عبور استفاده کنید.', + 'signup.toast.linkedExisting': 'با حساب موجود مشکی به این وب‌سایت پیوستید.', 'signup.toast.passwordUpdated': 'رمز مشکی شما به رمزی که در این وب‌سایت وارد کردید به‌روز شد.', - 'signup.cellExists.title': 'حساب موجود در مشکی', - 'signup.cellExists.admit': 'متوجه شدم، ادامه', 'forgot.back': 'بازگشت به ورود', 'forgot.title': 'فراموشی رمز عبور', diff --git a/apps/customer/src/pages/LoginPage.tsx b/apps/customer/src/pages/LoginPage.tsx index 4575392..93a5dc7 100644 --- a/apps/customer/src/pages/LoginPage.tsx +++ b/apps/customer/src/pages/LoginPage.tsx @@ -1,20 +1,17 @@ import { useState, type ReactNode } from 'react' -import { createPortal } from 'react-dom' import { useNavigate, useSearchParams } from 'react-router-dom' -import { Eye, EyeOff, Smartphone, Lock, KeyRound, ArrowLeft, User, X } from 'lucide-react' -import { - useAuth, - CUSTOMER_ACCESS_MESSAGE, -} from '../context/AuthContext' +import { Eye, EyeOff, Smartphone, Lock, KeyRound, ArrowLeft, User } from 'lucide-react' +import { useAuth } from '../context/AuthContext' import { useTenantBranding } from '../context/TenantBrandingContext' import { ApiError } from '../lib/api' import { toE164CellNumber } from '../lib/cellNumber' import { getTenantDomain } from '../lib/config' import { - logout as logoutRequest, - register, + completeSignup, resetPassword, sendOtp, + startSignup, + verifySignupOtp, } from '../services/authService' import { LanguageSelect, PasswordStrengthMeter, isPasswordStrong, useLocale, useToast } from '@meshkee/dashboard-ui' import { useT } from '../i18n/useT' @@ -24,20 +21,11 @@ import styles from './LoginPage.module.css' type AuthView = 'login' | 'signup' | 'forgot' | 'otp' type SmsStep = 'phone' | 'code' +type SignupStep = 'details' | 'otp' | 'password' | 'existing' const MESHKEE_APP_MARKER = '__MESHKEE_APP__' const MESHKEE_SITE_URL = 'https://meshkee.com' -function parseCellExistsSites(message: string): string | null { - if (message.startsWith('CELL_EXISTS_OTHER_SITE:')) { - return message.slice('CELL_EXISTS_OTHER_SITE:'.length).trim() - } - if (message.includes('Cell number exists on another account')) { - return '' - } - return null -} - function renderWithMeshkeeLink(message: string, linkClassName: string): ReactNode { const idx = message.indexOf(MESHKEE_APP_MARKER) if (idx === -1) return message @@ -61,15 +49,16 @@ export function LoginPage() { const navigate = useNavigate() const [searchParams] = useSearchParams() const redirectTo = safeRedirectPath(searchParams.get('redirect')) - const { login, loginWithOtp } = useAuth() + const { login, loginWithOtp, establishSession } = useAuth() const { businessName, businessNameEn, logoUrl } = useTenantBranding() const tenantDomain = getTenantDomain() const t = useT() const { showToast } = useToast() - const { locale, dir } = useLocale() + const { locale } = useLocale() const [view, setView] = useState('login') const [smsStep, setSmsStep] = useState('phone') + const [signupStep, setSignupStep] = useState('details') const [showPassword, setShowPassword] = useState(false) const [phone, setPhone] = useState('') const [password, setPassword] = useState('') @@ -84,8 +73,8 @@ export function LoginPage() { const [error, setError] = useState('') const [info, setInfo] = useState('') const [isSubmitting, setIsSubmitting] = useState(false) - /** `null` closed; `''` generic notice; otherwise site names */ - const [cellExistsSites, setCellExistsSites] = useState(null) + const [registrationToken, setRegistrationToken] = useState('') + const [existingSites, setExistingSites] = useState([]) function clearMessages() { setError('') @@ -102,9 +91,11 @@ export function LoginPage() { setNewPassword('') setConfirmNewPassword('') setSmsStep('phone') + setSignupStep('details') setCodeSent(false) setShowPassword(false) - setCellExistsSites(null) + setRegistrationToken('') + setExistingSites([]) clearMessages() } @@ -212,116 +203,196 @@ export function LoginPage() { } } - async function runSignup(acknowledgeExistingAccount = false) { + function advanceAfterSignupVerify(result: { + registrationToken: string + existingAccount: boolean + sites?: string[] + }) { + setRegistrationToken(result.registrationToken) + setExistingSites(result.sites ?? []) + if (result.existingAccount) { + setSignupStep('existing') + setInfo('') + } else { + setPassword('') + setConfirmPassword('') + setSignupStep('password') + setInfo(t('signup.info.choosePassword')) + } + } + + async function handleSignupStart(e: React.FormEvent) { + e.preventDefault() + clearMessages() + + const cellNumber = resolveCellNumber() + if (!cellNumber) return + if (firstName.trim().length < 2 || lastName.trim().length < 2) { + setError(t('signup.error.create')) + return + } + + setIsSubmitting(true) + try { + const result = await startSignup({ + cellNumber, + firstName: firstName.trim(), + lastName: lastName.trim(), + domain: tenantDomain, + }) + + if (!result.requiresOtp && result.registrationToken) { + advanceAfterSignupVerify({ + registrationToken: result.registrationToken, + existingAccount: Boolean(result.existingAccount), + sites: result.sites, + }) + return + } + + setCodeSent(true) + setSignupStep('otp') + setSmsCode('') + setInfo(result.enabled ? t('signup.info.verify') : result.message) + startCountdown() + } catch (err) { + if ( + err instanceof ApiError && + err.message.includes('already registered on this website') + ) { + setError(t('signup.error.alreadyOnSite')) + } else { + handleApiError(err, t('signup.error.create')) + } + } finally { + setIsSubmitting(false) + } + } + + async function handleSignupResendCode() { + clearMessages() const cellNumber = resolveCellNumber() if (!cellNumber) return setIsSubmitting(true) - try { - const data = await register({ + const result = await startSignup({ cellNumber, - password, firstName: firstName.trim(), lastName: lastName.trim(), domain: tenantDomain, - ...(acknowledgeExistingAccount ? { acknowledgeExistingAccount: true } : {}), }) - setCellExistsSites(null) - - // Password is applied only after OTP when passwordPending; toast then. - if (data.passwordUpdated) { - showToast(t('signup.toast.passwordUpdated'), 'info') - } - - if (data.requiresOtp || data.passwordPending || !data.user.cellVerifiedAt) { - if (data.accessToken) { - logoutRequest() - } - await continueToSmsVerify( - cellNumber, - data.passwordPending - ? t('signup.info.verifyExistingPasswordUpdated') - : t('signup.info.verify'), - ) + if (!result.requiresOtp && result.registrationToken) { + advanceAfterSignupVerify({ + registrationToken: result.registrationToken, + existingAccount: Boolean(result.existingAccount), + sites: result.sites, + }) return } - if (data.user.customerBusinesses.length === 0) { - logoutRequest() - setError(CUSTOMER_ACCESS_MESSAGE) - return - } + setCodeSent(true) + setInfo(result.enabled ? t('signup.info.verify') : result.message) + startCountdown() + } catch (err) { + handleApiError(err, t('login.error.sendCode')) + } finally { + setIsSubmitting(false) + } + } - await login(cellNumber, password) + async function handleSignupVerifyOtp(e: React.FormEvent) { + e.preventDefault() + clearMessages() + const cellNumber = resolveCellNumber() + if (!cellNumber) return + + setIsSubmitting(true) + try { + const result = await verifySignupOtp(cellNumber, smsCode) + advanceAfterSignupVerify(result) + } catch (err) { + if ( + err instanceof ApiError && + err.message.includes('already registered on this website') + ) { + setError(t('signup.error.alreadyOnSite')) + } else { + handleApiError(err, t('otp.error.signIn')) + } + } finally { + setIsSubmitting(false) + } + } + + async function finishSignup(token: string, nextPassword?: string) { + setIsSubmitting(true) + try { + const data = await completeSignup({ + registrationToken: token, + ...(nextPassword ? { password: nextPassword } : {}), + }) + await establishSession(data.user) + if (data.existingAccount) { + showToast(t('signup.toast.linkedExisting'), 'info') + } navigate(redirectTo) } catch (err) { - if (!acknowledgeExistingAccount && err instanceof ApiError) { - const sites = parseCellExistsSites(err.message) - if (sites !== null) { - setCellExistsSites(sites) - return - } - } - setCellExistsSites(null) handleApiError(err, t('signup.error.create')) } finally { setIsSubmitting(false) } } - async function continueToSmsVerify(cellNumber: string, infoMessage?: string) { - setView('otp') - setSmsStep('phone') - setPassword('') - setConfirmPassword('') - setSmsCode('') - setInfo(infoMessage ?? t('signup.info.verify')) - try { - const otpResult = await sendOtp(cellNumber, tenantDomain) - if (!otpResult.enabled) { - setInfo(otpResult.message) - } - setCodeSent(true) - setSmsStep('code') - startCountdown() - } catch (otpErr) { - handleApiError(otpErr, t('login.error.sendCode')) - } - } - - async function handleSignup(e: React.FormEvent) { + async function handleSignupPassword(e: React.FormEvent) { e.preventDefault() clearMessages() - setCellExistsSites(null) if (password !== confirmPassword) { setError(t('signup.error.match')) return } - if (!isPasswordStrong(password)) { setError(t('forgot.error.strength')) return } - - await runSignup() - } - - /** - * Existing Meshkee account on another site: user admits the notice, then we link - * them with the new password and prove phone ownership via SMS. - */ - async function handleAdmitCellExists() { - clearMessages() - const cellNumber = resolveCellNumber() - if (!cellNumber) { - setCellExistsSites(null) + if (!registrationToken) { + setError(t('signup.error.sessionExpired')) + setSignupStep('details') return } - await runSignup(true) + await finishSignup(registrationToken, password) + } + + async function handleSignupLinkExisting() { + clearMessages() + if (!registrationToken) { + setError(t('signup.error.sessionExpired')) + setSignupStep('details') + return + } + await finishSignup(registrationToken) + } + + function handleSignupBack() { + clearMessages() + if (signupStep === 'otp') { + setSignupStep('details') + setSmsCode('') + setCodeSent(false) + return + } + if (signupStep === 'password' || signupStep === 'existing') { + setSignupStep('details') + setRegistrationToken('') + setExistingSites([]) + setPassword('') + setConfirmPassword('') + return + } + switchView('login') } async function handleResetPassword(e: React.FormEvent) { @@ -379,7 +450,17 @@ export function LoginPage() { view === 'login' ? { title: t('login.welcome'), subtitle: t('login.subtitle') } : view === 'signup' - ? { title: t('signup.title'), subtitle: null } + ? { + title: t('signup.title'), + subtitle: + signupStep === 'details' + ? t('signup.subtitleDetails') + : signupStep === 'otp' + ? t('signup.subtitleOtp') + : signupStep === 'existing' + ? t('signup.subtitleExisting') + : t('signup.subtitlePassword'), + } : view === 'forgot' ? { title: t('forgot.title'), @@ -405,15 +486,25 @@ export function LoginPage() {
- {(view === 'forgot' || view === 'otp') && ( + {(view === 'forgot' || view === 'otp' || (view === 'signup' && signupStep !== 'details')) && ( )}
@@ -527,155 +618,284 @@ export function LoginPage() { {view === 'signup' && ( <> -
- {error && ( -
- {error} + {signupStep === 'details' && ( + + {error && ( +
+ {error} +
+ )} + {info &&
{info}
} + +
+
+ +
+ + setFirstName(e.target.value)} + required + minLength={2} + disabled={isSubmitting} + /> +
+
+
+ +
+ + setLastName(e.target.value)} + required + minLength={2} + disabled={isSubmitting} + /> +
+
- )} - {info &&
{info}
} -
- +
- + setFirstName(e.target.value)} + id="signup-phone" + type="tel" + inputMode="tel" + placeholder="09123456789" + value={phone} + onChange={(e) => setPhone(e.target.value)} required - minLength={2} disabled={isSubmitting} />
+ + + + )} + + {signupStep === 'otp' && ( +
+ {error && ( +
+ {error} +
+ )} + {info &&
{info}
} + + {codeSent && ( +

+ {t('common.codeSent', { phone })} +

+ )} +
- +
- + setLastName(e.target.value)} + inputMode="numeric" + placeholder="123456" + maxLength={6} + value={smsCode} + onChange={(e) => setSmsCode(e.target.value.replace(/\D/g, ''))} required - minLength={2} disabled={isSubmitting} />
-
-
- -
- - setPhone(e.target.value)} - required - disabled={isSubmitting} - /> +
+ {countdown > 0 ? ( + + {t('common.resendIn', { seconds: countdown })} + + ) : ( + + )}
-
-
- -
- - setPassword(e.target.value)} - required - minLength={8} - disabled={isSubmitting} - /> + + + )} + + {signupStep === 'existing' && ( +
+ {error && ( +
+ {error} +
+ )} +
+ {renderWithMeshkeeLink( + existingSites.length > 0 + ? t('signup.info.existingAccount', { + sites: existingSites.join(', '), + }) + : t('signup.info.existingAccountGeneric'), + styles.noticeLink, + )} +
+

{t('signup.info.existingPasswordHint')}

+ +

-

+

+ )} -
- -
- - setConfirmPassword(e.target.value)} - required - minLength={8} - disabled={isSubmitting} - aria-describedby="signup-password-strength signup-password-strength-rules" + {signupStep === 'password' && ( +
+ {error && ( +
+ {error} +
+ )} + {info &&
{info}
} + +
+ +
+ + setPassword(e.target.value)} + required + minLength={8} + disabled={isSubmitting} + /> + +
+
+ +
+ +
+ + setConfirmPassword(e.target.value)} + required + minLength={8} + disabled={isSubmitting} + aria-describedby="signup-password-strength signup-password-strength-rules" + /> +
+
- -
- - + + + )} -

- {t('signup.hasAccount')} - -

+ {signupStep === 'details' && ( +

+ {t('signup.hasAccount')} + +

+ )} )} @@ -935,56 +1155,6 @@ export function LoginPage() { > Powered by Meshkee.app - - {cellExistsSites !== null && - createPortal( -
!isSubmitting && setCellExistsSites(null)} - > -
e.stopPropagation()} - > -
-

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

- -
-

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

- -
-
, - document.body, - )}
) } diff --git a/apps/customer/src/services/authService.ts b/apps/customer/src/services/authService.ts index 48370e5..37f310a 100644 --- a/apps/customer/src/services/authService.ts +++ b/apps/customer/src/services/authService.ts @@ -7,6 +7,8 @@ import type { OtpSendResponse, OtpVerifyResponse, RegisterResponse, + SignupStartResponse, + SignupVerifyResponse, HandoffCreateResponse, UserProfile, } from '../types/auth' @@ -45,6 +47,40 @@ export async function register(input: { return data } +export async function startSignup(input: { + cellNumber: string + firstName: string + lastName: string + domain: string +}) { + return apiRequest('/auth/signup/start', { + method: 'POST', + body: input, + }) +} + +export async function verifySignupOtp(cellNumber: string, code: string) { + return apiRequest('/auth/signup/verify-otp', { + method: 'POST', + body: { cellNumber, code }, + }) +} + +export async function completeSignup(input: { + registrationToken: string + password?: string +}) { + const data = await apiRequest('/auth/signup/complete', { + method: 'POST', + body: input, + }) + + if (data.accessToken && data.refreshToken) { + setTokens(data.accessToken, data.refreshToken) + } + return data +} + export async function fetchCurrentUser(signal?: AbortSignal) { return apiRequest('/auth/me', { auth: true, signal }) } diff --git a/apps/customer/src/types/auth.ts b/apps/customer/src/types/auth.ts index 7ec25b8..25e3382 100644 --- a/apps/customer/src/types/auth.ts +++ b/apps/customer/src/types/auth.ts @@ -6,6 +6,8 @@ export type { OtpSendResponse, OtpVerifyResponse, RegisterResponse, + SignupStartResponse, + SignupVerifyResponse, HandoffCreateResponse, UserProfile, } from '@meshkee/dashboard-core' diff --git a/packages/dashboard-core/src/index.ts b/packages/dashboard-core/src/index.ts index 88f0c6d..a438d68 100644 --- a/packages/dashboard-core/src/index.ts +++ b/packages/dashboard-core/src/index.ts @@ -9,6 +9,8 @@ export type { OtpSendResponse, OtpVerifyResponse, RegisterResponse, + SignupStartResponse, + SignupVerifyResponse, HandoffCreateResponse, UserProfile, } from './types/auth' diff --git a/packages/dashboard-core/src/types/auth.ts b/packages/dashboard-core/src/types/auth.ts index 79a8768..cbef40e 100644 --- a/packages/dashboard-core/src/types/auth.ts +++ b/packages/dashboard-core/src/types/auth.ts @@ -63,9 +63,30 @@ export interface RegisterResponse { passwordPending?: boolean /** True when an existing Meshkee account password was already replaced. */ passwordUpdated?: boolean + /** True when this signup linked an existing Meshkee account. */ + existingAccount?: boolean registeredBusiness?: { id: string; name: string; slug: string } } +export interface SignupStartResponse { + enabled: boolean + requiresOtp: boolean + message: string + expiresInSeconds?: number + /** Present when SMS is disabled — skip OTP and continue with complete. */ + registrationToken?: string + existingAccount?: boolean + sites?: string[] +} + +export interface SignupVerifyResponse { + verified: boolean + existingAccount: boolean + sites: string[] + registrationToken: string + message: string +} + export interface OtpSendResponse { enabled: boolean message: string