Add shared empty states, customer product filters, and SSO handoff across dashboards.
Standardize list empty UI, searchable country fields, customer-products filter row, password/login polish, and one-time business dashboard handoff from the customer app. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
832ac32a09
commit
f8a9a28346
@@ -81,10 +81,12 @@
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
position: static;
|
||||
margin-top: 6px;
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
inset-inline: 0;
|
||||
z-index: 120;
|
||||
max-height: min(280px, 50vh);
|
||||
overflow-y: auto;
|
||||
background: var(--elevated-surface);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
|
||||
@@ -14,6 +14,8 @@ interface CitySearchSelectProps {
|
||||
onChange: (value: string) => void
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
noResultsLabel?: string
|
||||
clearLabel?: string
|
||||
id?: string
|
||||
}
|
||||
|
||||
@@ -23,6 +25,8 @@ export function CitySearchSelect({
|
||||
onChange,
|
||||
disabled = false,
|
||||
placeholder,
|
||||
noResultsLabel,
|
||||
clearLabel,
|
||||
id,
|
||||
}: CitySearchSelectProps) {
|
||||
const t = useT()
|
||||
@@ -34,6 +38,8 @@ export function CitySearchSelect({
|
||||
const selected = options.find((option) => option.id === value)
|
||||
const selectedLabel = selected ? getLocationOptionLabel(selected, locale) : ''
|
||||
const searchPlaceholder = placeholder ?? t('myProducts.fields.searchCity')
|
||||
const emptyLabel = noResultsLabel ?? t('myProducts.fields.noCities')
|
||||
const clearAriaLabel = clearLabel ?? t('myProducts.fields.clearCity')
|
||||
|
||||
const filtered = options.filter((option) => {
|
||||
const q = query.trim().toLowerCase()
|
||||
@@ -108,7 +114,7 @@ export function CitySearchSelect({
|
||||
type="button"
|
||||
className={styles.clearBtn}
|
||||
onClick={() => onChange('')}
|
||||
aria-label={t('myProducts.fields.clearCity')}
|
||||
aria-label={clearAriaLabel}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
@@ -123,7 +129,7 @@ export function CitySearchSelect({
|
||||
{open && !disabled ? (
|
||||
<ul className={styles.dropdown} role="listbox">
|
||||
{filtered.length === 0 ? (
|
||||
<li className={styles.noResults}>{t('myProducts.fields.noCities')}</li>
|
||||
<li className={styles.noResults}>{emptyLabel}</li>
|
||||
) : (
|
||||
filtered.map((option) => (
|
||||
<li key={option.id}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useRef, useState, type MouseEvent } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { Menu, Bell, MessageSquare, ChevronDown, User, KeyRound, LogOut, LayoutDashboard } from 'lucide-react'
|
||||
import { LanguageSelect, PasswordResetModal, useLocale } from '@meshkee/dashboard-ui'
|
||||
@@ -7,7 +7,7 @@ import { useTenantBranding } from '../context/TenantBrandingContext'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { getActiveBusinessId } from '../lib/businessContext'
|
||||
import { getBusinessDashboardUrl } from '../lib/config'
|
||||
import { changePassword } from '../services/authService'
|
||||
import { changePassword, createHandoff } from '../services/authService'
|
||||
import meshkeeLogo from '../assets/meshkee-logo.png'
|
||||
import styles from './Header.module.css'
|
||||
|
||||
@@ -56,6 +56,7 @@ export function Header({
|
||||
const t = useT()
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [passwordModalOpen, setPasswordModalOpen] = useState(false)
|
||||
const [handoffBusy, setHandoffBusy] = useState(false)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const displayName = displayUserName(user, locale, t('app.role.customer'))
|
||||
@@ -117,6 +118,38 @@ export function Header({
|
||||
setPasswordModalOpen(true)
|
||||
}
|
||||
|
||||
async function openBusinessDashboard(event: MouseEvent<HTMLAnchorElement>) {
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
if (handoffBusy) return
|
||||
|
||||
const baseUrl = getBusinessDashboardUrl()
|
||||
const popup = window.open('about:blank', '_blank')
|
||||
setHandoffBusy(true)
|
||||
try {
|
||||
const { ticket } = await createHandoff()
|
||||
const url = new URL(baseUrl)
|
||||
url.searchParams.set('handoff', ticket)
|
||||
if (popup) {
|
||||
popup.opener = null
|
||||
popup.location.replace(url.toString())
|
||||
} else {
|
||||
window.open(url.toString(), '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
} catch {
|
||||
if (popup) {
|
||||
popup.location.replace(baseUrl)
|
||||
} else {
|
||||
window.open(baseUrl, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
} finally {
|
||||
setHandoffBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className={styles.header}>
|
||||
@@ -171,6 +204,10 @@ export function Header({
|
||||
rel="noopener noreferrer"
|
||||
aria-label={t('header.openBusinessDashboard')}
|
||||
title={t('header.openBusinessDashboard')}
|
||||
aria-busy={handoffBusy}
|
||||
onClick={(event) => {
|
||||
void openBusinessDashboard(event)
|
||||
}}
|
||||
>
|
||||
<LayoutDashboard size={18} />
|
||||
</a>
|
||||
|
||||
@@ -29,8 +29,9 @@
|
||||
height: 40px;
|
||||
object-fit: contain;
|
||||
flex-shrink: 0;
|
||||
background: transparent;
|
||||
border-radius: 8px;
|
||||
background: none;
|
||||
border-radius: 0;
|
||||
mix-blend-mode: multiply;
|
||||
}
|
||||
|
||||
.brandFallback {
|
||||
@@ -49,9 +50,9 @@
|
||||
}
|
||||
|
||||
.brandLogoUploaded {
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
object-fit: contain;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.brandText {
|
||||
@@ -118,7 +119,6 @@
|
||||
.navItem.active {
|
||||
background: rgba(var(--primary-rgb) / 0.1);
|
||||
color: var(--primary);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.navItem.active:hover {
|
||||
@@ -177,7 +177,6 @@
|
||||
.subNavActive {
|
||||
background: rgba(var(--primary-rgb) / 0.1);
|
||||
color: var(--primary);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.subNavActive:hover {
|
||||
|
||||
@@ -231,6 +231,9 @@ const en = {
|
||||
'myProducts.fields.location': 'Location',
|
||||
'myProducts.fields.country': 'Country',
|
||||
'myProducts.fields.selectCountry': 'Select country',
|
||||
'myProducts.fields.searchCountry': 'Search country…',
|
||||
'myProducts.fields.clearCountry': 'Clear country',
|
||||
'myProducts.fields.noCountries': 'No countries found',
|
||||
'myProducts.fields.province': 'Province',
|
||||
'myProducts.fields.selectProvince': 'Select province',
|
||||
'myProducts.fields.city': 'City',
|
||||
@@ -316,6 +319,7 @@ const en = {
|
||||
'login.error.sendCode': 'Unable to send verification code.',
|
||||
'login.error.cellEnglishDigits': 'Please use English digits for the mobile number.',
|
||||
'login.error.smsProvider': 'SMS service is temporarily unavailable. Please try again later.',
|
||||
'login.error.invalidCredentials': 'Invalid mobile number or password.',
|
||||
|
||||
'signup.title': 'Create account',
|
||||
'signup.subtitle': 'Register as a customer of {domain}',
|
||||
@@ -368,7 +372,7 @@ const en = {
|
||||
'password.rule.length': 'At least 8 characters',
|
||||
'password.rule.upper': 'One uppercase letter',
|
||||
'password.rule.number': 'One number',
|
||||
'password.rule.special': 'One special character',
|
||||
'password.rule.match': 'Passwords match',
|
||||
|
||||
'otp.back': 'Back to sign in',
|
||||
'otp.title': 'One-time login',
|
||||
@@ -620,6 +624,9 @@ const fa: Record<MessageKey, string> = {
|
||||
'myProducts.fields.location': 'موقعیت',
|
||||
'myProducts.fields.country': 'کشور',
|
||||
'myProducts.fields.selectCountry': 'انتخاب کشور',
|
||||
'myProducts.fields.searchCountry': 'جستجوی کشور…',
|
||||
'myProducts.fields.clearCountry': 'پاک کردن کشور',
|
||||
'myProducts.fields.noCountries': 'کشوری پیدا نشد',
|
||||
'myProducts.fields.province': 'استان',
|
||||
'myProducts.fields.selectProvince': 'انتخاب استان',
|
||||
'myProducts.fields.city': 'شهر',
|
||||
@@ -705,6 +712,7 @@ const fa: Record<MessageKey, string> = {
|
||||
'login.error.sendCode': 'ارسال کد تأیید ممکن نشد.',
|
||||
'login.error.cellEnglishDigits': 'خواهشمندیم از زبان انگلیسی برای شماره همراه استفاده کنید',
|
||||
'login.error.smsProvider': 'سرویس پیامک موقتاً در دسترس نیست. لطفاً بعداً دوباره تلاش کنید.',
|
||||
'login.error.invalidCredentials': 'شماره موبایل یا رمز عبور نادرست است.',
|
||||
|
||||
'signup.title': 'ایجاد حساب',
|
||||
'signup.subtitle': 'بهعنوان مشتری {domain} ثبتنام کنید',
|
||||
@@ -757,7 +765,7 @@ const fa: Record<MessageKey, string> = {
|
||||
'password.rule.length': 'حداقل ۸ کاراکتر',
|
||||
'password.rule.upper': 'یک حرف بزرگ انگلیسی',
|
||||
'password.rule.number': 'یک عدد',
|
||||
'password.rule.special': 'یک کاراکتر ویژه',
|
||||
'password.rule.match': 'یکسان بودن رمز عبور',
|
||||
|
||||
'otp.back': 'بازگشت به ورود',
|
||||
'otp.title': 'ورود یکبارمصرف',
|
||||
|
||||
@@ -2,8 +2,6 @@ import { useEffect, useRef, useState } from 'react'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import { Check, ClipboardList, Images, SlidersHorizontal } from 'lucide-react'
|
||||
import {
|
||||
getLocationOptionLabel,
|
||||
useLocale,
|
||||
useToast,
|
||||
type CityOption,
|
||||
} from '@meshkee/dashboard-ui'
|
||||
@@ -94,7 +92,6 @@ function isPriceCurrency(
|
||||
|
||||
export function AddMyProductPage() {
|
||||
const t = useT()
|
||||
const { locale } = useLocale()
|
||||
const navigate = useNavigate()
|
||||
const { id } = useParams<{ id?: string }>()
|
||||
const isEdit = Boolean(id)
|
||||
@@ -117,7 +114,6 @@ export function AddMyProductPage() {
|
||||
|
||||
const [countries, setCountries] = useState<CityOption[]>([])
|
||||
const [cities, setCities] = useState<CityOption[]>([])
|
||||
const [countrySlug, setCountrySlug] = useState('')
|
||||
const [countryId, setCountryId] = useState('')
|
||||
const [cityId, setCityId] = useState('')
|
||||
const [deliveryNote, setDeliveryNote] = useState('')
|
||||
@@ -218,7 +214,6 @@ export function AddMyProductPage() {
|
||||
: 'IRT',
|
||||
)
|
||||
setPriceByExpert(product.priceByExpert === true)
|
||||
setCountrySlug(product.countrySlug)
|
||||
setCountryId(product.countryId)
|
||||
setCityId(product.cityId)
|
||||
setDeliveryNote(product.deliveryNote ?? '')
|
||||
@@ -280,9 +275,10 @@ export function AddMyProductPage() {
|
||||
return () => controller.abort()
|
||||
}, [categoryId, t])
|
||||
|
||||
async function handleCountryChange(nextSlug: string) {
|
||||
setCountrySlug(nextSlug)
|
||||
setCountryId(countries.find((item) => item.slug === nextSlug)?.id ?? '')
|
||||
async function handleCountryChange(nextCountryId: string) {
|
||||
setCountryId(nextCountryId)
|
||||
const country = countries.find((item) => item.id === nextCountryId)
|
||||
const nextSlug = country?.slug ?? ''
|
||||
setCityId('')
|
||||
setCities([])
|
||||
if (!nextSlug) return
|
||||
@@ -657,19 +653,16 @@ export function AddMyProductPage() {
|
||||
<div className={styles.locationRow}>
|
||||
<div className={`${styles.field} ${styles.col2}`}>
|
||||
<label htmlFor="add-country">{t('myProducts.fields.country')}</label>
|
||||
<select
|
||||
<CitySearchSelect
|
||||
id="add-country"
|
||||
value={countrySlug}
|
||||
options={countries}
|
||||
value={countryId}
|
||||
onChange={(nextId) => void handleCountryChange(nextId)}
|
||||
disabled={loadingLocations}
|
||||
onChange={(e) => void handleCountryChange(e.target.value)}
|
||||
>
|
||||
<option value="">{t('myProducts.fields.selectCountry')}</option>
|
||||
{countries.map((country) => (
|
||||
<option key={country.id} value={country.slug}>
|
||||
{getLocationOptionLabel(country, locale)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
placeholder={t('myProducts.fields.searchCountry')}
|
||||
noResultsLabel={t('myProducts.fields.noCountries')}
|
||||
clearLabel={t('myProducts.fields.clearCountry')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.field} ${styles.col4}`}>
|
||||
@@ -679,7 +672,7 @@ export function AddMyProductPage() {
|
||||
options={cities}
|
||||
value={cityId}
|
||||
onChange={setCityId}
|
||||
disabled={!countrySlug || loadingLocations}
|
||||
disabled={!countryId || loadingLocations}
|
||||
placeholder={t('myProducts.fields.searchCity')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
position: relative;
|
||||
gap: 20px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.card {
|
||||
@@ -14,7 +14,7 @@
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
padding: 36px 32px 32px;
|
||||
padding: 24px 32px 28px;
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
@@ -26,40 +26,64 @@
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.cardHeader {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.heading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.langSelect {
|
||||
flex-shrink: 0;
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: block;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
object-fit: contain;
|
||||
background: transparent;
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.brandText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.domain {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.2;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.appName {
|
||||
font-size: 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
margin-top: 2px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
}
|
||||
|
||||
.poweredBy {
|
||||
@@ -77,18 +101,20 @@
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 24px;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
text-align: center;
|
||||
margin-bottom: 6px;
|
||||
text-align: start;
|
||||
margin: 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
margin-bottom: 28px;
|
||||
text-align: start;
|
||||
margin: 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.backBtn {
|
||||
@@ -98,7 +124,7 @@
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 16px;
|
||||
margin-bottom: 12px;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
|
||||
@@ -140,6 +140,10 @@ export function LoginPage() {
|
||||
err.message.includes('Unable to send SMS')
|
||||
) {
|
||||
setError(t('login.error.smsProvider'))
|
||||
} else if (
|
||||
err.message.includes('Invalid cell number or password')
|
||||
) {
|
||||
setError(t('login.error.invalidCredentials'))
|
||||
} else {
|
||||
setError(err.message)
|
||||
}
|
||||
@@ -371,15 +375,51 @@ export function LoginPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const heading =
|
||||
view === 'login'
|
||||
? { title: t('login.welcome'), subtitle: t('login.subtitle') }
|
||||
: view === 'signup'
|
||||
? { title: t('signup.title'), subtitle: null }
|
||||
: view === 'forgot'
|
||||
? {
|
||||
title: t('forgot.title'),
|
||||
subtitle: smsStep === 'phone' ? t('forgot.subtitlePhone') : t('forgot.subtitleCode'),
|
||||
}
|
||||
: {
|
||||
title: t('otp.title'),
|
||||
subtitle: smsStep === 'phone' ? t('otp.subtitlePhone') : t('otp.subtitleCode'),
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<BackgroundOrbs />
|
||||
<div className={styles.brand}>
|
||||
<img src={logoUrl || meshkeeLogo} alt="" className={styles.logo} />
|
||||
<div className={styles.brandText}>
|
||||
<span className={styles.domain} lang={locale === 'fa' ? 'fa' : undefined}>
|
||||
{businessName || tenantDomain}
|
||||
</span>
|
||||
<span className={styles.appName} lang="en" dir="ltr">
|
||||
{businessNameEn || tenantDomain}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.card}>
|
||||
<div className={styles.brand}>
|
||||
<img src={logoUrl || meshkeeLogo} alt="" className={styles.logo} />
|
||||
<div className={styles.brandText}>
|
||||
<span className={styles.domain}>{businessName || tenantDomain}</span>
|
||||
<span className={styles.appName}>{businessNameEn || tenantDomain}</span>
|
||||
{(view === 'forgot' || view === 'otp') && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.backBtn}
|
||||
onClick={() => switchView('login')}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<ArrowLeft size={18} />
|
||||
{view === 'forgot' ? t('forgot.back') : t('otp.back')}
|
||||
</button>
|
||||
)}
|
||||
<div className={styles.cardHeader}>
|
||||
<div className={styles.heading}>
|
||||
<h1 className={styles.title}>{heading.title}</h1>
|
||||
{heading.subtitle ? <p className={styles.subtitle}>{heading.subtitle}</p> : null}
|
||||
</div>
|
||||
<div className={styles.langSelect}>
|
||||
<LanguageSelect />
|
||||
@@ -388,9 +428,6 @@ export function LoginPage() {
|
||||
|
||||
{view === 'login' && (
|
||||
<>
|
||||
<h1 className={styles.title}>{t('login.welcome')}</h1>
|
||||
<p className={styles.subtitle}>{t('login.subtitle')}</p>
|
||||
|
||||
<form className={styles.form} onSubmit={handleLogin}>
|
||||
{error && (
|
||||
<div className={styles.error} role="alert">
|
||||
@@ -490,8 +527,6 @@ export function LoginPage() {
|
||||
|
||||
{view === 'signup' && (
|
||||
<>
|
||||
<h1 className={styles.title}>{t('signup.title')}</h1>
|
||||
|
||||
<form className={styles.form} onSubmit={handleSignup}>
|
||||
{error && (
|
||||
<div className={styles.error} role="alert">
|
||||
@@ -578,24 +613,6 @@ export function LoginPage() {
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
<PasswordStrengthMeter
|
||||
password={password}
|
||||
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'),
|
||||
special: t('password.rule.special'),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
@@ -613,14 +630,36 @@ export function LoginPage() {
|
||||
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>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className={styles.submitBtn}
|
||||
disabled={isSubmitting || !isPasswordStrong(password)}
|
||||
disabled={
|
||||
isSubmitting || !isPasswordStrong(password) || password !== confirmPassword
|
||||
}
|
||||
>
|
||||
{isSubmitting ? t('signup.creating') : t('signup.create')}
|
||||
</button>
|
||||
@@ -642,21 +681,6 @@ export function LoginPage() {
|
||||
|
||||
{view === 'forgot' && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.backBtn}
|
||||
onClick={() => switchView('login')}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<ArrowLeft size={18} />
|
||||
{t('forgot.back')}
|
||||
</button>
|
||||
|
||||
<h1 className={styles.title}>{t('forgot.title')}</h1>
|
||||
<p className={styles.subtitle}>
|
||||
{smsStep === 'phone' ? t('forgot.subtitlePhone') : t('forgot.subtitleCode')}
|
||||
</p>
|
||||
|
||||
<form className={styles.form} onSubmit={handleResetPassword}>
|
||||
{error && (
|
||||
<div className={styles.error} role="alert">
|
||||
@@ -746,24 +770,6 @@ export function LoginPage() {
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
<PasswordStrengthMeter
|
||||
password={newPassword}
|
||||
id="forgot-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'),
|
||||
special: t('password.rule.special'),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
@@ -781,8 +787,28 @@ export function LoginPage() {
|
||||
required
|
||||
minLength={8}
|
||||
disabled={isSubmitting}
|
||||
aria-describedby="forgot-password-strength"
|
||||
/>
|
||||
</div>
|
||||
<PasswordStrengthMeter
|
||||
password={newPassword}
|
||||
confirmPassword={confirmNewPassword}
|
||||
id="forgot-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 className={styles.resendRow}>
|
||||
@@ -803,7 +829,7 @@ export function LoginPage() {
|
||||
<button
|
||||
type="submit"
|
||||
className={styles.submitBtn}
|
||||
disabled={isSubmitting || !isPasswordStrong(newPassword)}
|
||||
disabled={isSubmitting || !isPasswordStrong(newPassword) || newPassword !== confirmNewPassword}
|
||||
>
|
||||
{isSubmitting ? t('forgot.verifying') : t('forgot.reset')}
|
||||
</button>
|
||||
@@ -815,21 +841,6 @@ export function LoginPage() {
|
||||
|
||||
{view === 'otp' && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.backBtn}
|
||||
onClick={() => switchView('login')}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<ArrowLeft size={18} />
|
||||
{t('otp.back')}
|
||||
</button>
|
||||
|
||||
<h1 className={styles.title}>{t('otp.title')}</h1>
|
||||
<p className={styles.subtitle}>
|
||||
{smsStep === 'phone' ? t('otp.subtitlePhone') : t('otp.subtitleCode')}
|
||||
</p>
|
||||
|
||||
<form className={styles.form} onSubmit={handleOtpLogin}>
|
||||
{error && (
|
||||
<div className={styles.error} role="alert">
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
OtpSendResponse,
|
||||
OtpVerifyResponse,
|
||||
RegisterResponse,
|
||||
HandoffCreateResponse,
|
||||
UserProfile,
|
||||
} from '../types/auth'
|
||||
|
||||
@@ -105,6 +106,13 @@ export async function resetPassword(cellNumber: string, code: string, newPasswor
|
||||
})
|
||||
}
|
||||
|
||||
export async function createHandoff() {
|
||||
return apiRequest<HandoffCreateResponse>('/auth/handoff', {
|
||||
method: 'POST',
|
||||
auth: true,
|
||||
})
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
clearTokens()
|
||||
clearActiveBusiness()
|
||||
|
||||
@@ -6,5 +6,6 @@ export type {
|
||||
OtpSendResponse,
|
||||
OtpVerifyResponse,
|
||||
RegisterResponse,
|
||||
HandoffCreateResponse,
|
||||
UserProfile,
|
||||
} from '@meshkee/dashboard-core'
|
||||
|
||||
Reference in New Issue
Block a user