mirror of
https://git.meshkee.com/Meshkee/dashboards.git
synced 2026-08-11 22:30:58 +04:30
Add FA/EN locale, home activity charts, and theme-aware polish.
Ship shared LocaleProvider, business/customer i18n, branding defaultLocale, curated home tiles with dual 30-day charts, and chart/page aura tokens; refresh PROJECT_CONTEXT. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
f5b2193ba1
commit
66004a0fba
@@ -23,10 +23,12 @@ import type { Category, FlatCategory } from '../types/category'
|
||||
import { flattenCategories } from '../utils/categories'
|
||||
import pageStyles from '../components/PageContent.module.css'
|
||||
import styles from './AddNewProductPage.module.css'
|
||||
import { useT } from '../i18n/useT'
|
||||
|
||||
export function AddNewProductPage() {
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const t = useT()
|
||||
const isEdit = Boolean(id)
|
||||
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
@@ -183,12 +185,10 @@ export function AddNewProductPage() {
|
||||
<div className={pageStyles.pageHeader}>
|
||||
<div>
|
||||
<h2 className={pageStyles.pageTitle}>
|
||||
{isEdit ? 'Edit Product' : 'Add a New Product'}
|
||||
{isEdit ? t('title.editProduct') : t('products.card.new.title')}
|
||||
</h2>
|
||||
<p className={pageStyles.pageSubtitle}>
|
||||
{isEdit
|
||||
? 'Update product details and save changes.'
|
||||
: 'Create and publish a new product to your store.'}
|
||||
{isEdit ? t('products.form.edit.subtitle') : t('products.card.new.desc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -130,7 +130,7 @@
|
||||
}
|
||||
|
||||
.selectFieldFa {
|
||||
font-family: var(--font-fa), var(--font-en);
|
||||
font-family: var(--font-ui);
|
||||
direction: rtl;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@@ -1,105 +1,207 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { CalendarDays } from 'lucide-react'
|
||||
import {
|
||||
ShoppingBag,
|
||||
Store,
|
||||
Users,
|
||||
Settings,
|
||||
FileText,
|
||||
Briefcase,
|
||||
Globe,
|
||||
} from 'lucide-react'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { SectionCard } from '../components/SectionCard'
|
||||
import { DailyActivityChart } from '../components/DailyActivityChart'
|
||||
import { useT } from '../i18n/useT'
|
||||
import type { BusinessMessageKey } from '../i18n/messages'
|
||||
import { listProducts } from '../services/productService'
|
||||
import { listStoreItems } from '../services/storeItemService'
|
||||
import { listCustomers } from '../services/customerService'
|
||||
import { listBlogs } from '../services/blogService'
|
||||
import { listPortfolios } from '../services/portfolioService'
|
||||
import {
|
||||
getCustomersDailyActivity,
|
||||
getOrdersDailyActivity,
|
||||
} from '../services/dailyActivityService'
|
||||
import styles from '../components/PageContent.module.css'
|
||||
|
||||
const sections = [
|
||||
type CountKey = 'products' | 'store' | 'customers' | 'blog' | 'portfolios'
|
||||
|
||||
const sections: {
|
||||
icon: typeof ShoppingBag
|
||||
titleKey: BusinessMessageKey
|
||||
descKey: BusinessMessageKey
|
||||
linkKey: BusinessMessageKey
|
||||
countLabelKey?: BusinessMessageKey
|
||||
href: string
|
||||
countKey?: CountKey
|
||||
}[] = [
|
||||
{
|
||||
icon: ShoppingBag,
|
||||
title: 'Products',
|
||||
description: 'Manage your products, inventory and categories.',
|
||||
linkText: 'View products',
|
||||
titleKey: 'home.card.products.title',
|
||||
descKey: 'home.card.products.desc',
|
||||
linkKey: 'home.card.products.link',
|
||||
countLabelKey: 'home.card.products.count',
|
||||
href: '/products',
|
||||
countKey: 'products',
|
||||
},
|
||||
{
|
||||
icon: Store,
|
||||
title: 'Store',
|
||||
description: 'Manage your store settings, pages and themes.',
|
||||
linkText: 'View store',
|
||||
titleKey: 'home.card.store.title',
|
||||
descKey: 'home.card.store.desc',
|
||||
linkKey: 'home.card.store.link',
|
||||
countLabelKey: 'home.card.store.count',
|
||||
href: '/store',
|
||||
countKey: 'store',
|
||||
},
|
||||
{
|
||||
icon: Users,
|
||||
title: 'Customers',
|
||||
description: 'View and manage your customers and their activity.',
|
||||
linkText: 'View customers',
|
||||
titleKey: 'home.card.customers.title',
|
||||
descKey: 'home.card.customers.desc',
|
||||
linkKey: 'home.card.customers.link',
|
||||
countLabelKey: 'home.card.customers.count',
|
||||
href: '/customers',
|
||||
},
|
||||
{
|
||||
icon: Settings,
|
||||
title: 'Settings',
|
||||
description: 'Configure your store preferences and system settings.',
|
||||
linkText: 'View settings',
|
||||
href: '/settings',
|
||||
countKey: 'customers',
|
||||
},
|
||||
{
|
||||
icon: FileText,
|
||||
title: 'Blog',
|
||||
description: 'Create and manage blog posts and categories.',
|
||||
linkText: 'View blog',
|
||||
titleKey: 'home.card.blog.title',
|
||||
descKey: 'home.card.blog.desc',
|
||||
linkKey: 'home.card.blog.link',
|
||||
countLabelKey: 'home.card.blog.count',
|
||||
href: '/blog',
|
||||
countKey: 'blog',
|
||||
},
|
||||
{
|
||||
icon: Briefcase,
|
||||
title: 'Portfolios',
|
||||
description: 'Manage your portfolio items and showcase projects.',
|
||||
linkText: 'View portfolios',
|
||||
titleKey: 'home.card.portfolios.title',
|
||||
descKey: 'home.card.portfolios.desc',
|
||||
linkKey: 'home.card.portfolios.link',
|
||||
countLabelKey: 'home.card.portfolios.count',
|
||||
href: '/portfolios',
|
||||
countKey: 'portfolios',
|
||||
},
|
||||
{
|
||||
icon: Globe,
|
||||
title: 'Website',
|
||||
description: 'Manage contact forms, FAQ, badges, subscriptions, and e-payment.',
|
||||
linkText: 'View website',
|
||||
titleKey: 'home.card.website.title',
|
||||
descKey: 'home.card.website.desc',
|
||||
linkKey: 'home.card.website.link',
|
||||
href: '/website',
|
||||
},
|
||||
]
|
||||
|
||||
function getFormattedDate() {
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
type SectionCounts = Partial<Record<CountKey, number>>
|
||||
|
||||
async function loadSectionCounts(signal: AbortSignal): Promise<SectionCounts> {
|
||||
const [products, store, customers, blog, portfolios] = await Promise.all([
|
||||
listProducts(1, 1, signal).then((r) => r.total).catch(() => null),
|
||||
listStoreItems(1, 1, signal).then((r) => r.total).catch(() => null),
|
||||
listCustomers({ page: 1, pageSize: 1 }, signal).then((r) => r.total).catch(() => null),
|
||||
listBlogs(1, 1, signal).then((r) => r.total).catch(() => null),
|
||||
listPortfolios(1, 1, signal).then((r) => r.total).catch(() => null),
|
||||
])
|
||||
|
||||
const counts: SectionCounts = {}
|
||||
if (products !== null) counts.products = products
|
||||
if (store !== null) counts.store = store
|
||||
if (customers !== null) counts.customers = customers
|
||||
if (blog !== null) counts.blog = blog
|
||||
if (portfolios !== null) counts.portfolios = portfolios
|
||||
return counts
|
||||
}
|
||||
|
||||
export function HomePage() {
|
||||
const { user } = useAuth()
|
||||
const { locale } = useLocale()
|
||||
const t = useT()
|
||||
const [counts, setCounts] = useState<SectionCounts>({})
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
void loadSectionCounts(controller.signal).then((next) => {
|
||||
if (!controller.signal.aborted) setCounts(next)
|
||||
})
|
||||
return () => controller.abort()
|
||||
}, [])
|
||||
|
||||
const loadOrdersActivity = useCallback(
|
||||
(signal: AbortSignal) => getOrdersDailyActivity(30, signal),
|
||||
[],
|
||||
)
|
||||
const loadCustomersActivity = useCallback(
|
||||
(signal: AbortSignal) => getCustomersDailyActivity(30, signal),
|
||||
[],
|
||||
)
|
||||
|
||||
const firstName =
|
||||
(locale === 'en'
|
||||
? user?.firstNameEn?.trim() || user?.firstName
|
||||
: user?.firstName?.trim() || user?.firstNameEn) || t('home.welcomeFallback')
|
||||
|
||||
const formattedDate = new Intl.DateTimeFormat(locale === 'fa' ? 'fa-IR' : 'en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
weekday: 'long',
|
||||
}).format(new Date())
|
||||
}
|
||||
|
||||
export function HomePage() {
|
||||
const { user } = useAuth()
|
||||
const firstName = user?.firstName || 'there'
|
||||
|
||||
return (
|
||||
<main className={styles.content}>
|
||||
<div className={styles.pageHeader}>
|
||||
<div>
|
||||
<h2 className={styles.pageTitle}>
|
||||
Welcome back, {firstName}! <span aria-hidden="true">👋</span>
|
||||
</h2>
|
||||
<p className={styles.pageSubtitle}>
|
||||
Here's what's happening with your store today.
|
||||
</p>
|
||||
<h2 className={styles.pageTitle}>{t('home.welcome', { name: firstName })}</h2>
|
||||
<p className={styles.pageSubtitle}>{t('home.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.dateBadge}>
|
||||
<CalendarDays size={16} />
|
||||
<span>{getFormattedDate()}</span>
|
||||
<span>{formattedDate}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.gridHome}>
|
||||
{sections.map((section) => (
|
||||
<SectionCard key={section.title} {...section} />
|
||||
<SectionCard
|
||||
key={section.href}
|
||||
icon={section.icon}
|
||||
title={t(section.titleKey)}
|
||||
description={t(section.descKey)}
|
||||
linkText={t(section.linkKey)}
|
||||
href={section.href}
|
||||
count={section.countKey ? counts[section.countKey] : undefined}
|
||||
countLabel={section.countLabelKey ? t(section.countLabelKey) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={styles.grid12}>
|
||||
<div className={styles.col6}>
|
||||
<DailyActivityChart
|
||||
titleKey="home.chart.orders.title"
|
||||
subtitleKey="home.chart.orders.subtitle"
|
||||
primaryLegendKey="home.chart.orders.legend"
|
||||
secondaryLegendKey="home.chart.orders.cartLegend"
|
||||
loadingKey="home.chart.orders.loading"
|
||||
errorKey="home.chart.orders.error"
|
||||
primaryBarTitleKey="home.chart.orders.bar"
|
||||
secondaryBarTitleKey="home.chart.orders.cartBar"
|
||||
load={loadOrdersActivity}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.col6}>
|
||||
<DailyActivityChart
|
||||
titleKey="home.chart.customers.title"
|
||||
subtitleKey="home.chart.customers.subtitle"
|
||||
primaryLegendKey="home.chart.customers.legend"
|
||||
secondaryLegendKey="home.chart.customers.activeLegend"
|
||||
loadingKey="home.chart.customers.loading"
|
||||
errorKey="home.chart.customers.error"
|
||||
primaryBarTitleKey="home.chart.customers.bar"
|
||||
secondaryBarTitleKey="home.chart.customers.activeBar"
|
||||
load={loadCustomersActivity}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -24,11 +24,15 @@
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
justify-content: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.langSelect {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: block;
|
||||
width: 48px;
|
||||
@@ -112,7 +116,7 @@
|
||||
|
||||
.inputIcon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
inset-inline-start: 12px;
|
||||
color: var(--text-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -120,7 +124,8 @@
|
||||
.inputWrap input {
|
||||
width: 100%;
|
||||
min-height: var(--field-height);
|
||||
padding: var(--field-padding-y) 40px var(--field-padding-y) 38px;
|
||||
padding-block: var(--field-padding-y);
|
||||
padding-inline: 38px 40px;
|
||||
font-size: var(--field-font-size);
|
||||
line-height: 1.4;
|
||||
font-family: inherit;
|
||||
@@ -140,7 +145,7 @@
|
||||
|
||||
.togglePassword {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
inset-inline-end: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Eye, EyeOff, Smartphone, Lock, KeyRound, ArrowLeft, User } from 'lucide-react'
|
||||
import { LanguageSelect } from '@meshkee/dashboard-ui'
|
||||
import { useAuth, BUSINESS_ACCESS_MESSAGE } from '../context/AuthContext'
|
||||
import { useTenantBranding } from '../context/TenantBrandingContext'
|
||||
import { ApiError } from '../lib/api'
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
sendOtp,
|
||||
verifyOtp,
|
||||
} from '../services/authService'
|
||||
import { useT } from '../i18n/useT'
|
||||
import meshkeeLogo from '../assets/meshkee-logo.png'
|
||||
import styles from './LoginPage.module.css'
|
||||
|
||||
@@ -24,6 +26,7 @@ export function LoginPage() {
|
||||
const { login } = useAuth()
|
||||
const { businessName, logoUrl } = useTenantBranding()
|
||||
const businessDomain = getBusinessDomain()
|
||||
const t = useT()
|
||||
|
||||
const [view, setView] = useState<AuthView>('login')
|
||||
const [smsStep, setSmsStep] = useState<SmsStep>('phone')
|
||||
@@ -80,7 +83,11 @@ export function LoginPage() {
|
||||
|
||||
function handleApiError(err: unknown, fallback: string) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
if (err.message === BUSINESS_ACCESS_MESSAGE) {
|
||||
setError(t('login.error.access'))
|
||||
} else {
|
||||
setError(err.message)
|
||||
}
|
||||
} else {
|
||||
setError(fallback)
|
||||
}
|
||||
@@ -102,7 +109,7 @@ export function LoginPage() {
|
||||
setSmsStep('code')
|
||||
startCountdown()
|
||||
} catch (err) {
|
||||
handleApiError(err, 'Unable to send verification code.')
|
||||
handleApiError(err, t('login.error.sendCode'))
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
@@ -118,7 +125,7 @@ export function LoginPage() {
|
||||
await login(cellNumber, password)
|
||||
navigate('/')
|
||||
} catch (err) {
|
||||
handleApiError(err, 'Unable to sign in. Check your connection and try again.')
|
||||
handleApiError(err, t('login.error.signIn'))
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
@@ -129,12 +136,12 @@ export function LoginPage() {
|
||||
clearMessages()
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match.')
|
||||
setError(t('signup.error.match'))
|
||||
return
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
setError('Password must be at least 8 characters.')
|
||||
setError(t('signup.error.length'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -152,16 +159,14 @@ export function LoginPage() {
|
||||
|
||||
if (data.user.dashboard !== 'business' || data.user.businesses.length === 0) {
|
||||
logoutRequest()
|
||||
setError(
|
||||
`${BUSINESS_ACCESS_MESSAGE} Customer registration on ${businessDomain} does not grant dashboard access.`,
|
||||
)
|
||||
setError(t('login.error.access'))
|
||||
return
|
||||
}
|
||||
|
||||
setActiveBusiness(data.user)
|
||||
navigate('/')
|
||||
} catch (err) {
|
||||
handleApiError(err, 'Unable to create account.')
|
||||
handleApiError(err, t('signup.error.create'))
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
@@ -172,7 +177,7 @@ export function LoginPage() {
|
||||
clearMessages()
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
setError('Password must be at least 8 characters.')
|
||||
setError(t('forgot.error.length'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -181,12 +186,10 @@ export function LoginPage() {
|
||||
try {
|
||||
const cellNumber = toE164CellNumber(phone)
|
||||
await verifyOtp(cellNumber, smsCode)
|
||||
setInfo(
|
||||
'Phone number verified. Full password reset via SMS is not available yet — please contact your administrator or sign in if you remember your password.',
|
||||
)
|
||||
setInfo(t('forgot.info.partial'))
|
||||
setTimeout(() => switchView('login'), 2500)
|
||||
} catch (err) {
|
||||
handleApiError(err, 'Unable to verify code.')
|
||||
handleApiError(err, t('forgot.error.verify'))
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
@@ -202,14 +205,14 @@ export function LoginPage() {
|
||||
await verifyOtp(cellNumber, smsCode)
|
||||
|
||||
if (!password) {
|
||||
setError('Enter your account password to complete sign-in after SMS verification.')
|
||||
setError(t('otp.error.password'))
|
||||
return
|
||||
}
|
||||
|
||||
await login(cellNumber, password)
|
||||
navigate('/')
|
||||
} catch (err) {
|
||||
handleApiError(err, 'Unable to sign in with SMS verification.')
|
||||
handleApiError(err, t('otp.error.signIn'))
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
@@ -222,14 +225,17 @@ export function LoginPage() {
|
||||
<img src={logoUrl || meshkeeLogo} alt="" className={styles.logo} />
|
||||
<div className={styles.brandText}>
|
||||
<span className={styles.businessName}>{businessName || businessDomain}</span>
|
||||
<span className={styles.appName}>powered by Meshkee.app</span>
|
||||
<span className={styles.appName}>{t('app.poweredBy')}</span>
|
||||
</div>
|
||||
<div className={styles.langSelect}>
|
||||
<LanguageSelect />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{view === 'login' && (
|
||||
<>
|
||||
<h1 className={styles.title}>Welcome back</h1>
|
||||
<p className={styles.subtitle}>Sign in with your mobile number</p>
|
||||
<h1 className={styles.title}>{t('login.welcome')}</h1>
|
||||
<p className={styles.subtitle}>{t('login.subtitle')}</p>
|
||||
|
||||
<form className={styles.form} onSubmit={handleLogin}>
|
||||
{error && (
|
||||
@@ -240,7 +246,7 @@ export function LoginPage() {
|
||||
{info && <div className={styles.info}>{info}</div>}
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="login-phone">Mobile number</label>
|
||||
<label htmlFor="login-phone">{t('login.mobile')}</label>
|
||||
<div className={styles.inputWrap}>
|
||||
<Smartphone size={18} className={styles.inputIcon} />
|
||||
<input
|
||||
@@ -257,13 +263,13 @@ export function LoginPage() {
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="login-password">Password</label>
|
||||
<label htmlFor="login-password">{t('login.password')}</label>
|
||||
<div className={styles.inputWrap}>
|
||||
<Lock size={18} className={styles.inputIcon} />
|
||||
<input
|
||||
id="login-password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="Enter your password"
|
||||
placeholder={t('login.passwordPlaceholder')}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
@@ -274,7 +280,7 @@ export function LoginPage() {
|
||||
type="button"
|
||||
className={styles.togglePassword}
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||
aria-label={showPassword ? t('login.hidePassword') : t('login.showPassword')}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
@@ -289,17 +295,17 @@ export function LoginPage() {
|
||||
onClick={() => switchView('forgot')}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Forgot password?
|
||||
{t('login.forgot')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Signing in...' : 'Sign in'}
|
||||
{isSubmitting ? t('login.signingIn') : t('login.signIn')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className={styles.divider}>
|
||||
<span>or</span>
|
||||
<span>{t('login.or')}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -309,18 +315,18 @@ export function LoginPage() {
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<KeyRound size={18} />
|
||||
One-time login with SMS
|
||||
{t('login.otp')}
|
||||
</button>
|
||||
|
||||
<p className={styles.footerText}>
|
||||
Don't have an account?{' '}
|
||||
{t('login.noAccount')}{' '}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.linkBtn}
|
||||
onClick={() => switchView('signup')}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Sign up
|
||||
{t('login.signUp')}
|
||||
</button>
|
||||
</p>
|
||||
</>
|
||||
@@ -328,8 +334,8 @@ export function LoginPage() {
|
||||
|
||||
{view === 'signup' && (
|
||||
<>
|
||||
<h1 className={styles.title}>Create account</h1>
|
||||
<p className={styles.subtitle}>Staff accounts are invited by the business owner</p>
|
||||
<h1 className={styles.title}>{t('signup.title')}</h1>
|
||||
<p className={styles.subtitle}>{t('signup.subtitle', { domain: businessDomain })}</p>
|
||||
|
||||
<form className={styles.form} onSubmit={handleSignup}>
|
||||
{error && (
|
||||
@@ -340,13 +346,13 @@ export function LoginPage() {
|
||||
|
||||
<div className={styles.fieldRow}>
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="signup-first">First name</label>
|
||||
<label htmlFor="signup-first">{t('signup.firstName')}</label>
|
||||
<div className={styles.inputWrap}>
|
||||
<User size={18} className={styles.inputIcon} />
|
||||
<input
|
||||
id="signup-first"
|
||||
type="text"
|
||||
placeholder="First name"
|
||||
placeholder={t('signup.firstName')}
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
required
|
||||
@@ -356,13 +362,13 @@ export function LoginPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="signup-last">Last name</label>
|
||||
<label htmlFor="signup-last">{t('signup.lastName')}</label>
|
||||
<div className={styles.inputWrap}>
|
||||
<User size={18} className={styles.inputIcon} />
|
||||
<input
|
||||
id="signup-last"
|
||||
type="text"
|
||||
placeholder="Last name"
|
||||
placeholder={t('signup.lastName')}
|
||||
value={lastName}
|
||||
onChange={(e) => setLastName(e.target.value)}
|
||||
required
|
||||
@@ -374,7 +380,7 @@ export function LoginPage() {
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="signup-phone">Mobile number</label>
|
||||
<label htmlFor="signup-phone">{t('login.mobile')}</label>
|
||||
<div className={styles.inputWrap}>
|
||||
<Smartphone size={18} className={styles.inputIcon} />
|
||||
<input
|
||||
@@ -391,13 +397,13 @@ export function LoginPage() {
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="signup-password">Password</label>
|
||||
<label htmlFor="signup-password">{t('login.password')}</label>
|
||||
<div className={styles.inputWrap}>
|
||||
<Lock size={18} className={styles.inputIcon} />
|
||||
<input
|
||||
id="signup-password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="Choose a password"
|
||||
placeholder={t('signup.passwordPlaceholder')}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
@@ -408,7 +414,7 @@ export function LoginPage() {
|
||||
type="button"
|
||||
className={styles.togglePassword}
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||
aria-label={showPassword ? t('login.hidePassword') : t('login.showPassword')}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
@@ -417,13 +423,13 @@ export function LoginPage() {
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="signup-confirm">Confirm password</label>
|
||||
<label htmlFor="signup-confirm">{t('signup.confirm')}</label>
|
||||
<div className={styles.inputWrap}>
|
||||
<Lock size={18} className={styles.inputIcon} />
|
||||
<input
|
||||
id="signup-confirm"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="Repeat your password"
|
||||
placeholder={t('signup.confirmPlaceholder')}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
@@ -434,19 +440,19 @@ export function LoginPage() {
|
||||
</div>
|
||||
|
||||
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Creating account...' : 'Create account'}
|
||||
{isSubmitting ? t('signup.creating') : t('signup.create')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className={styles.footerText}>
|
||||
Already have an account?{' '}
|
||||
{t('signup.hasAccount')}{' '}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.linkBtn}
|
||||
onClick={() => switchView('login')}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Sign in
|
||||
{t('signup.signIn')}
|
||||
</button>
|
||||
</p>
|
||||
</>
|
||||
@@ -461,14 +467,12 @@ export function LoginPage() {
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<ArrowLeft size={18} />
|
||||
Back to sign in
|
||||
{t('forgot.back')}
|
||||
</button>
|
||||
|
||||
<h1 className={styles.title}>Forgot password</h1>
|
||||
<h1 className={styles.title}>{t('forgot.title')}</h1>
|
||||
<p className={styles.subtitle}>
|
||||
{smsStep === 'phone'
|
||||
? 'We will send a verification code via SMS'
|
||||
: 'Enter the code and your new password'}
|
||||
{smsStep === 'phone' ? t('forgot.subtitlePhone') : t('forgot.subtitleCode')}
|
||||
</p>
|
||||
|
||||
<form className={styles.form} onSubmit={handleResetPassword}>
|
||||
@@ -482,7 +486,7 @@ export function LoginPage() {
|
||||
{smsStep === 'phone' ? (
|
||||
<>
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="forgot-phone">Mobile number</label>
|
||||
<label htmlFor="forgot-phone">{t('login.mobile')}</label>
|
||||
<div className={styles.inputWrap}>
|
||||
<Smartphone size={18} className={styles.inputIcon} />
|
||||
<input
|
||||
@@ -504,19 +508,17 @@ export function LoginPage() {
|
||||
onClick={() => void handleSendCode()}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? 'Sending...' : 'Send SMS code'}
|
||||
{isSubmitting ? t('forgot.sending') : t('forgot.sendCode')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{codeSent && (
|
||||
<p className={styles.codeHint}>
|
||||
Verification code sent to <strong>{phone}</strong>
|
||||
</p>
|
||||
<p className={styles.codeHint}>{t('common.codeSent', { phone })}</p>
|
||||
)}
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="forgot-code">SMS verification code</label>
|
||||
<label htmlFor="forgot-code">{t('forgot.code')}</label>
|
||||
<div className={styles.inputWrap}>
|
||||
<KeyRound size={18} className={styles.inputIcon} />
|
||||
<input
|
||||
@@ -534,13 +536,13 @@ export function LoginPage() {
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="forgot-new-password">New password</label>
|
||||
<label htmlFor="forgot-new-password">{t('forgot.newPassword')}</label>
|
||||
<div className={styles.inputWrap}>
|
||||
<Lock size={18} className={styles.inputIcon} />
|
||||
<input
|
||||
id="forgot-new-password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="Enter new password"
|
||||
placeholder={t('forgot.newPasswordPlaceholder')}
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
required
|
||||
@@ -552,7 +554,9 @@ export function LoginPage() {
|
||||
|
||||
<div className={styles.resendRow}>
|
||||
{countdown > 0 ? (
|
||||
<span className={styles.countdown}>Resend code in {countdown}s</span>
|
||||
<span className={styles.countdown}>
|
||||
{t('common.resendIn', { seconds: countdown })}
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
@@ -560,13 +564,13 @@ export function LoginPage() {
|
||||
onClick={() => void handleSendCode()}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Resend SMS code
|
||||
{t('common.resend')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Verifying...' : 'Reset password'}
|
||||
{isSubmitting ? t('forgot.verifying') : t('forgot.reset')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
@@ -583,14 +587,12 @@ export function LoginPage() {
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<ArrowLeft size={18} />
|
||||
Back to sign in
|
||||
{t('otp.back')}
|
||||
</button>
|
||||
|
||||
<h1 className={styles.title}>One-time login</h1>
|
||||
<h1 className={styles.title}>{t('otp.title')}</h1>
|
||||
<p className={styles.subtitle}>
|
||||
{smsStep === 'phone'
|
||||
? 'Sign in with a one-time SMS code'
|
||||
: 'Enter the SMS code and your password'}
|
||||
{smsStep === 'phone' ? t('otp.subtitlePhone') : t('otp.subtitleCode')}
|
||||
</p>
|
||||
|
||||
<form className={styles.form} onSubmit={handleOtpLogin}>
|
||||
@@ -603,7 +605,7 @@ export function LoginPage() {
|
||||
{smsStep === 'phone' ? (
|
||||
<>
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="otp-phone">Mobile number</label>
|
||||
<label htmlFor="otp-phone">{t('login.mobile')}</label>
|
||||
<div className={styles.inputWrap}>
|
||||
<Smartphone size={18} className={styles.inputIcon} />
|
||||
<input
|
||||
@@ -625,19 +627,17 @@ export function LoginPage() {
|
||||
onClick={() => void handleSendCode()}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? 'Sending...' : 'Send SMS code'}
|
||||
{isSubmitting ? t('otp.sending') : t('otp.sendCode')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{codeSent && (
|
||||
<p className={styles.codeHint}>
|
||||
Verification code sent to <strong>{phone}</strong>
|
||||
</p>
|
||||
<p className={styles.codeHint}>{t('common.codeSent', { phone })}</p>
|
||||
)}
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="otp-code">SMS verification code</label>
|
||||
<label htmlFor="otp-code">{t('otp.code')}</label>
|
||||
<div className={styles.inputWrap}>
|
||||
<KeyRound size={18} className={styles.inputIcon} />
|
||||
<input
|
||||
@@ -655,13 +655,13 @@ export function LoginPage() {
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="otp-password">Password</label>
|
||||
<label htmlFor="otp-password">{t('otp.password')}</label>
|
||||
<div className={styles.inputWrap}>
|
||||
<Lock size={18} className={styles.inputIcon} />
|
||||
<input
|
||||
id="otp-password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="Your account password"
|
||||
placeholder={t('otp.passwordPlaceholder')}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
@@ -673,7 +673,9 @@ export function LoginPage() {
|
||||
|
||||
<div className={styles.resendRow}>
|
||||
{countdown > 0 ? (
|
||||
<span className={styles.countdown}>Resend code in {countdown}s</span>
|
||||
<span className={styles.countdown}>
|
||||
{t('common.resendIn', { seconds: countdown })}
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
@@ -681,13 +683,13 @@ export function LoginPage() {
|
||||
onClick={() => void handleSendCode()}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Resend SMS code
|
||||
{t('common.resend')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Signing in...' : 'Sign in'}
|
||||
{isSubmitting ? t('otp.signingIn') : t('otp.signIn')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
PORTFOLIOS_PER_PAGE,
|
||||
deletePortfolio,
|
||||
listPortfolios,
|
||||
updatePortfolio,
|
||||
} from '../services/portfolioService'
|
||||
import type { Portfolio } from '../types/portfolio'
|
||||
import pageStyles from '../components/PageContent.module.css'
|
||||
@@ -25,6 +26,7 @@ export function PortfolioListPage() {
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [movingUpId, setMovingUpId] = useState<string | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const [commentCounts, setCommentCounts] = useState<Record<string, number>>({})
|
||||
const [deleteTarget, setDeleteTarget] = useState<{ id: string; title: string } | null>(null)
|
||||
@@ -81,6 +83,43 @@ export function PortfolioListPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMoveUp(id: string) {
|
||||
const index = portfolios.findIndex((item) => item.id === id)
|
||||
if (index < 0) return
|
||||
if (index === 0 && currentPage === 1) return
|
||||
|
||||
const current = portfolios[index]
|
||||
setMovingUpId(id)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
if (index === 0) {
|
||||
// First on this page, but not global first — bump above the current band.
|
||||
await updatePortfolio(current.id, { sortOrder: current.sortOrder - 1 })
|
||||
} else {
|
||||
const previous = portfolios[index - 1]
|
||||
if (current.sortOrder === previous.sortOrder) {
|
||||
await updatePortfolio(current.id, { sortOrder: previous.sortOrder - 1 })
|
||||
} else {
|
||||
await Promise.all([
|
||||
updatePortfolio(current.id, { sortOrder: previous.sortOrder }),
|
||||
updatePortfolio(previous.id, { sortOrder: current.sortOrder }),
|
||||
])
|
||||
}
|
||||
}
|
||||
showToast('Portfolio moved up.', 'success')
|
||||
await loadPortfolios(currentPage)
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to move portfolio.')
|
||||
}
|
||||
} finally {
|
||||
setMovingUpId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!deleteTarget) return
|
||||
|
||||
@@ -152,11 +191,14 @@ export function PortfolioListPage() {
|
||||
) : (
|
||||
<>
|
||||
<div className={pageStyles.gridCols4}>
|
||||
{portfolios.map((portfolio) => (
|
||||
{portfolios.map((portfolio, index) => (
|
||||
<PortfolioCard
|
||||
key={portfolio.id}
|
||||
portfolio={portfolio}
|
||||
commentCount={commentCounts[portfolio.id] ?? portfolio.commentCount}
|
||||
canMoveUp={!(index === 0 && currentPage === 1)}
|
||||
isMovingUp={movingUpId === portfolio.id}
|
||||
onMoveUp={handleMoveUp}
|
||||
onEdit={handleEdit}
|
||||
onComments={handleComments}
|
||||
onRemove={handleRemoveRequest}
|
||||
|
||||
@@ -139,7 +139,7 @@
|
||||
}
|
||||
|
||||
.description {
|
||||
font-family: var(--font-fa), var(--font-en);
|
||||
font-family: var(--font-ui);
|
||||
font-size: 14px;
|
||||
font-weight: 300; /* IRANYekan Light */
|
||||
line-height: 1.7;
|
||||
@@ -149,7 +149,7 @@
|
||||
|
||||
.description:global(.faText),
|
||||
.description:global(.faText) :where(*) {
|
||||
font-family: var(--font-fa), var(--font-en);
|
||||
font-family: var(--font-ui);
|
||||
font-weight: 300;
|
||||
text-align: justify;
|
||||
}
|
||||
|
||||
@@ -2,47 +2,51 @@ import { FolderTree, PlusCircle, Package, Settings, Tag } from 'lucide-react'
|
||||
import { SectionCard } from '../components/SectionCard'
|
||||
import { ProductActivityChart } from '../components/ProductActivityChart'
|
||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { useT } from '../i18n/useT'
|
||||
import type { BusinessMessageKey } from '../i18n/messages'
|
||||
import styles from '../components/PageContent.module.css'
|
||||
|
||||
const productSections = [
|
||||
const productSections: {
|
||||
icon: typeof Package
|
||||
titleKey: BusinessMessageKey
|
||||
descKey: BusinessMessageKey
|
||||
href: string
|
||||
}[] = [
|
||||
{
|
||||
icon: Package,
|
||||
title: 'My Products',
|
||||
description: 'View, edit and manage all your existing products.',
|
||||
linkText: 'View products',
|
||||
titleKey: 'nav.products.list',
|
||||
descKey: 'products.card.list.desc',
|
||||
href: '/products/list',
|
||||
},
|
||||
{
|
||||
icon: PlusCircle,
|
||||
title: 'Add a New Product',
|
||||
description: 'Create and publish a new product to your store.',
|
||||
linkText: 'Add product',
|
||||
titleKey: 'products.card.new.title',
|
||||
descKey: 'products.card.new.desc',
|
||||
href: '/products/new',
|
||||
},
|
||||
{
|
||||
icon: FolderTree,
|
||||
title: 'Categories',
|
||||
description: 'Organize your products into categories and subcategories.',
|
||||
linkText: 'View categories',
|
||||
titleKey: 'nav.products.categories',
|
||||
descKey: 'products.card.categories.desc',
|
||||
href: '/products/categories',
|
||||
},
|
||||
{
|
||||
icon: Tag,
|
||||
title: 'Brands',
|
||||
description: 'Manage product brands and assign them when creating products.',
|
||||
linkText: 'View brands',
|
||||
titleKey: 'nav.products.brands',
|
||||
descKey: 'products.card.brands.desc',
|
||||
href: '/products/brands',
|
||||
},
|
||||
{
|
||||
icon: Settings,
|
||||
title: 'Settings',
|
||||
description: 'Configure product defaults, variants and display options.',
|
||||
linkText: 'View settings',
|
||||
titleKey: 'nav.products.settings',
|
||||
descKey: 'products.card.settings.desc',
|
||||
href: '/products/settings',
|
||||
},
|
||||
]
|
||||
|
||||
export function ProductsPage() {
|
||||
const t = useT()
|
||||
|
||||
return (
|
||||
<main className={styles.content}>
|
||||
<Breadcrumbs
|
||||
@@ -53,16 +57,20 @@ export function ProductsPage() {
|
||||
/>
|
||||
<div className={styles.pageHeader}>
|
||||
<div>
|
||||
<h2 className={styles.pageTitle}>Products</h2>
|
||||
<p className={styles.pageSubtitle}>
|
||||
Manage your products, inventory and categories.
|
||||
</p>
|
||||
<h2 className={styles.pageTitle}>{t('title.products')}</h2>
|
||||
<p className={styles.pageSubtitle}>{t('products.overview.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.gridHome}>
|
||||
{productSections.map((section) => (
|
||||
<SectionCard key={section.title} {...section} />
|
||||
<SectionCard
|
||||
key={section.href}
|
||||
icon={section.icon}
|
||||
title={t(section.titleKey)}
|
||||
description={t(section.descKey)}
|
||||
href={section.href}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -55,6 +55,10 @@
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.stepInputFa {
|
||||
font-family: var(--font-fa);
|
||||
}
|
||||
|
||||
.stepInput:focus {
|
||||
outline: none;
|
||||
border-color: rgba(var(--primary-rgb) / 0.5);
|
||||
|
||||
@@ -34,6 +34,7 @@ function normalizeSteps(steps: OrderProcessStep[]) {
|
||||
return steps.map((step, index) => ({
|
||||
id: step.id,
|
||||
label: step.label.trim(),
|
||||
labelFa: (step.labelFa ?? '').trim(),
|
||||
color: normalizeStepColor(step.color, defaultStepColorForId(step.id, index)),
|
||||
}))
|
||||
}
|
||||
@@ -43,7 +44,10 @@ function stepsAreEqual(a: OrderProcessStep[], b: OrderProcessStep[]) {
|
||||
return a.every((step, index) => {
|
||||
const other = b[index]
|
||||
return (
|
||||
step.id === other.id && step.label === other.label && step.color === other.color
|
||||
step.id === other.id &&
|
||||
step.label === other.label &&
|
||||
step.labelFa === other.labelFa &&
|
||||
step.color === other.color
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -109,6 +113,12 @@ export function StoreSettingsPage() {
|
||||
)
|
||||
}
|
||||
|
||||
function updateStepLabelFa(id: string, labelFa: string) {
|
||||
setDraftSteps((current) =>
|
||||
current.map((step) => (step.id === id ? { ...step, labelFa } : step)),
|
||||
)
|
||||
}
|
||||
|
||||
function updateStepColor(id: string, color: StepColorPreset) {
|
||||
setDraftSteps((current) =>
|
||||
current.map((step) => (step.id === id ? { ...step, color } : step)),
|
||||
@@ -123,6 +133,7 @@ export function StoreSettingsPage() {
|
||||
{
|
||||
id,
|
||||
label: '',
|
||||
labelFa: '',
|
||||
color: defaultStepColorForId(id, current.length),
|
||||
},
|
||||
]
|
||||
@@ -150,13 +161,13 @@ export function StoreSettingsPage() {
|
||||
|
||||
async function handleSaveSteps() {
|
||||
const normalized = normalizeSteps(draftSteps)
|
||||
const hasEmptyLabel = normalized.some((step) => !step.label)
|
||||
const hasEmptyLabel = normalized.some((step) => !step.label || !step.labelFa)
|
||||
if (!normalized.length) {
|
||||
setError('Add at least one order process step.')
|
||||
return
|
||||
}
|
||||
if (hasEmptyLabel) {
|
||||
setError('Every order step needs a label.')
|
||||
setError('Every order step needs an English and Farsi label.')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -186,7 +197,9 @@ export function StoreSettingsPage() {
|
||||
const canSaveSteps =
|
||||
stepsDirty &&
|
||||
draftSteps.length > 0 &&
|
||||
draftSteps.every((step) => step.label.trim().length > 0)
|
||||
draftSteps.every(
|
||||
(step) => step.label.trim().length > 0 && step.labelFa.trim().length > 0,
|
||||
)
|
||||
|
||||
return (
|
||||
<main className={pageStyles.content}>
|
||||
@@ -270,10 +283,22 @@ export function StoreSettingsPage() {
|
||||
type="text"
|
||||
className={styles.stepInput}
|
||||
value={step.label}
|
||||
placeholder="Step label"
|
||||
aria-label={`Order step ${index + 1}`}
|
||||
placeholder="Label (EN)"
|
||||
aria-label={`Order step ${index + 1} English label`}
|
||||
dir="ltr"
|
||||
lang="en"
|
||||
onChange={(e) => updateStepLabel(step.id, e.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className={`${styles.stepInput} ${styles.stepInputFa}`}
|
||||
value={step.labelFa ?? ''}
|
||||
placeholder="عنوان (فارسی)"
|
||||
aria-label={`Order step ${index + 1} Farsi label`}
|
||||
dir="rtl"
|
||||
lang="fa"
|
||||
onChange={(e) => updateStepLabelFa(step.id, e.target.value)}
|
||||
/>
|
||||
<div className={styles.stepControls}>
|
||||
<Tooltip label="Move step up">
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user