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:
Alireza Hassani
2026-08-19 19:46:57 +03:30
co-authored by Cursor
parent 832ac32a09
commit f8a9a28346
55 changed files with 1264 additions and 907 deletions
@@ -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}>
+39 -2
View File
@@ -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 {
+10 -2
View File
@@ -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': 'ورود یک‌بارمصرف',
+13 -20
View File
@@ -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>
+42 -16
View File
@@ -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;
}
+89 -78
View File
@@ -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()
+1
View File
@@ -6,5 +6,6 @@ export type {
OtpSendResponse,
OtpVerifyResponse,
RegisterResponse,
HandoffCreateResponse,
UserProfile,
} from '@meshkee/dashboard-core'