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
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user