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:
Alireza Hassani
2026-08-01 09:35:27 +03:30
co-authored by Cursor
parent f5b2193ba1
commit 66004a0fba
112 changed files with 4338 additions and 1061 deletions
+13 -1
View File
@@ -7,7 +7,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Montserrat:wght@400;500;600&display=swap"
href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<link
@@ -15,6 +15,18 @@
rel="stylesheet"
/>
<title>Customer Dashboard</title>
<script>
try {
var l = localStorage.getItem('meshkee.dashboard.locale')
if (l === 'en') {
document.documentElement.lang = 'en'
document.documentElement.dir = 'ltr'
} else {
document.documentElement.lang = 'fa'
document.documentElement.dir = 'rtl'
}
} catch (e) {}
</script>
</head>
<body>
<div id="root"></div>
+3 -1
View File
@@ -2,7 +2,7 @@ import { BrowserRouter, Routes, Route } from 'react-router-dom'
import { AuthProvider } from './context/AuthContext'
import { CustomerThemeProvider } from './context/CustomerThemeContext'
import { TenantBrandingProvider } from './context/TenantBrandingContext'
import { ToastProvider } from '@meshkee/dashboard-ui'
import { LocaleProvider, ToastProvider } from '@meshkee/dashboard-ui'
import { CustomerDomainGuard } from './components/CustomerDomainGuard'
import { DashboardDocumentTitle } from './components/DashboardDocumentTitle'
import { ProtectedRoute } from './components/ProtectedRoute'
@@ -26,6 +26,7 @@ import { CheckoutFailedStep } from './pages/checkout/CheckoutFailedStep'
function App() {
return (
<CustomerDomainGuard>
<LocaleProvider>
<CustomerThemeProvider>
<BrowserRouter>
<TenantBrandingProvider>
@@ -64,6 +65,7 @@ function App() {
</TenantBrandingProvider>
</BrowserRouter>
</CustomerThemeProvider>
</LocaleProvider>
</CustomerDomainGuard>
)
}
@@ -0,0 +1,15 @@
.modal {
max-width: 720px;
}
.overlay {
z-index: 1000;
}
.modal :global(select) {
background-position: right var(--select-arrow-offset) center;
}
:global([dir='rtl']) .modal :global(select) {
background-position: left var(--select-arrow-offset) center;
}
@@ -0,0 +1,357 @@
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
import {
getLocationOptionLabel,
matchCityByName,
matchProvinceByName,
useLocale,
useToast,
} from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api'
import {
createAddress,
updateAddress,
type UserAddress,
} from '../services/addressService'
import {
listCitiesByProvinceSlug,
listIranProvinces,
type CityOption,
} from '../services/citiesService'
import modalStyles from './VariationsModal.module.css'
import panelStyles from './checkout/CheckoutAddAddressPanel.module.css'
import styles from './AddressFormModal.module.css'
interface AddressFormModalProps {
open: boolean
address: UserAddress | null
onClose: () => void
onSaved: (address: UserAddress) => void
}
const ANIMATION_MS = 220
export function AddressFormModal({ open, address, onClose, onSaved }: AddressFormModalProps) {
const t = useT()
const { locale, dir } = useLocale()
const { showToast } = useToast()
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
const [formKey, setFormKey] = useState(0)
useEffect(() => {
if (open) {
setMounted(true)
setClosing(false)
setFormKey((key) => key + 1)
} else if (mounted) {
setClosing(true)
const timer = setTimeout(() => {
setMounted(false)
setClosing(false)
}, ANIMATION_MS)
return () => clearTimeout(timer)
}
}, [open, mounted])
useEffect(() => {
if (!mounted || closing) return
const prevOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', onKey)
return () => {
document.body.style.overflow = prevOverflow
document.removeEventListener('keydown', onKey)
}
}, [mounted, closing, onClose])
if (!mounted) return null
const isEdit = Boolean(address?.id)
return createPortal(
<div
className={`${modalStyles.overlay} ${styles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
onClick={onClose}
>
<div
className={`${modalStyles.modal} ${styles.modal} ${closing ? modalStyles.modalOut : modalStyles.modalIn}`}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="address-form-title"
lang={locale}
dir={dir}
>
<div className={modalStyles.header}>
<div>
<h2 id="address-form-title" className={modalStyles.title}>
{isEdit ? t('addresses.modal.editTitle') : t('addresses.modal.addTitle')}
</h2>
<p className={modalStyles.subtitle}>{t('addresses.modal.subtitle')}</p>
</div>
<button
type="button"
className={modalStyles.closeBtn}
onClick={onClose}
aria-label={t('common.close')}
>
<X size={20} />
</button>
</div>
<div className={modalStyles.body}>
<AddressFormFields
key={formKey}
address={address}
onCancel={onClose}
onSaved={(saved) => {
showToast(
isEdit ? t('addresses.toast.updated') : t('addresses.toast.created'),
'success',
)
onSaved(saved)
onClose()
}}
/>
</div>
</div>
</div>,
document.body,
)
}
function AddressFormFields({
address,
onCancel,
onSaved,
}: {
address: UserAddress | null
onCancel: () => void
onSaved: (address: UserAddress) => void
}) {
const t = useT()
const { locale } = useLocale()
const [provinces, setProvinces] = useState<CityOption[]>([])
const [cities, setCities] = useState<CityOption[]>([])
const [loadingLocations, setLoadingLocations] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [label, setLabel] = useState(address?.label ?? '')
const [provinceSlug, setProvinceSlug] = useState('')
const [city, setCity] = useState('')
const [street, setStreet] = useState(address?.address ?? '')
const [postalCode, setPostalCode] = useState(address?.postalCode ?? '')
const [landline, setLandline] = useState(address?.landline ?? '')
useEffect(() => {
const controller = new AbortController()
async function load() {
setLoadingLocations(true)
try {
const items = await listIranProvinces(controller.signal)
if (controller.signal.aborted) return
setProvinces(items)
if (!address) return
const province = matchProvinceByName(address.province, items)
if (!province) return
setProvinceSlug(province.slug)
const cityItems = await listCitiesByProvinceSlug(province.slug, controller.signal)
if (controller.signal.aborted) return
setCities(cityItems)
const matchedCity = matchCityByName(address.city, cityItems)
setCity(
matchedCity ? getLocationOptionLabel(matchedCity, locale) : address.city,
)
} catch {
if (!controller.signal.aborted) setError(t('addresses.error.loadLocations'))
} finally {
if (!controller.signal.aborted) setLoadingLocations(false)
}
}
void load()
return () => controller.abort()
}, [address, locale, t])
async function handleProvinceChange(slug: string) {
setProvinceSlug(slug)
setCity('')
setCities([])
if (!slug) return
try {
const items = await listCitiesByProvinceSlug(slug)
setCities(items)
} catch {
setError(t('addresses.error.loadCities'))
}
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError('')
const province = provinces.find((item) => item.slug === provinceSlug)
if (!label.trim() || !province || !city.trim() || !street.trim()) {
setError(t('addresses.error.incompleteForm'))
return
}
const payload = {
label: label.trim(),
province: getLocationOptionLabel(province, locale),
city: city.trim(),
address: street.trim(),
postalCode: postalCode.trim() || undefined,
landline: landline.trim() || undefined,
}
setSaving(true)
try {
const result = address?.id
? await updateAddress(address.id, payload)
: await createAddress(payload)
onSaved(result.address)
} catch (err) {
setError(err instanceof ApiError ? err.message : t('addresses.error.save'))
} finally {
setSaving(false)
}
}
return (
<form
className={[panelStyles.form, panelStyles.formInModal].join(' ')}
onSubmit={(e) => void handleSubmit(e)}
>
{error && (
<div className={panelStyles.error} role="alert">
{error}
</div>
)}
<div className={panelStyles.fieldRowTriple}>
<div className={panelStyles.field}>
<label htmlFor="address-label">{t('addresses.label')}</label>
<input
id="address-label"
type="text"
value={label}
disabled={saving}
onChange={(e) => setLabel(e.target.value)}
placeholder={t('addresses.labelPlaceholder')}
/>
</div>
<div className={panelStyles.field}>
<label htmlFor="address-province">{t('addresses.province')}</label>
<select
id="address-province"
value={provinceSlug}
disabled={loadingLocations || saving}
onChange={(e) => void handleProvinceChange(e.target.value)}
>
<option value="">{t('addresses.selectProvince')}</option>
{provinces.map((province) => (
<option key={province.id} value={province.slug}>
{getLocationOptionLabel(province, locale)}
</option>
))}
</select>
</div>
<div className={panelStyles.field}>
<label htmlFor="address-city">{t('addresses.city')}</label>
<select
id="address-city"
value={city}
disabled={!provinceSlug || saving}
onChange={(e) => setCity(e.target.value)}
>
<option value="">{t('addresses.selectCity')}</option>
{cities.map((item) => (
<option key={item.id} value={getLocationOptionLabel(item, locale)}>
{getLocationOptionLabel(item, locale)}
</option>
))}
</select>
</div>
</div>
<div className={panelStyles.field}>
<label htmlFor="address-street">{t('addresses.address')}</label>
<input
id="address-street"
type="text"
value={street}
disabled={saving}
onChange={(e) => setStreet(e.target.value)}
placeholder={t('addresses.streetPlaceholder')}
/>
</div>
<div className={panelStyles.fieldRow}>
<div className={panelStyles.field}>
<label htmlFor="address-postal">
{t('addresses.postalCode')}
<span className={panelStyles.optionalMark}> {t('addresses.optional')}</span>
</label>
<input
id="address-postal"
type="text"
inputMode="numeric"
value={postalCode}
disabled={saving}
onChange={(e) => setPostalCode(e.target.value)}
placeholder={t('addresses.postalPlaceholder')}
dir="ltr"
/>
</div>
<div className={panelStyles.field}>
<label htmlFor="address-landline">
{t('addresses.landline')}
<span className={panelStyles.optionalMark}> {t('addresses.optional')}</span>
</label>
<input
id="address-landline"
type="tel"
value={landline}
disabled={saving}
onChange={(e) => setLandline(e.target.value)}
placeholder={t('addresses.landlinePlaceholder')}
dir="ltr"
/>
</div>
</div>
<div className={panelStyles.formActions}>
<button
type="button"
className={panelStyles.cancelBtn}
onClick={onCancel}
disabled={saving}
>
{t('addresses.cancel')}
</button>
<button type="submit" className={panelStyles.saveBtn} disabled={saving}>
{saving ? t('addresses.saving') : t('addresses.saveOne')}
</button>
</div>
</form>
)
}
@@ -1,17 +1,18 @@
import { useLocation } from 'react-router-dom'
import { useDashboardDocumentTitle } from '@meshkee/dashboard-ui'
import { useDashboardDocumentTitle, useLocale } from '@meshkee/dashboard-ui'
import { useTenantBranding } from '../context/TenantBrandingContext'
import { CUSTOMER_DASHBOARD_NAME, customerRouteTitleRules } from '../lib/routeTitles'
import { getCustomerRouteTitleRules, translate } from '../i18n/messages'
export function DashboardDocumentTitle() {
const { pathname } = useLocation()
const { businessName } = useTenantBranding()
const { locale } = useLocale()
useDashboardDocumentTitle({
businessName,
dashboardName: CUSTOMER_DASHBOARD_NAME,
dashboardName: translate(locale, 'app.dashboardName'),
pathname,
routeRules: customerRouteTitleRules,
routeRules: getCustomerRouteTitleRules(locale),
})
return null
@@ -62,13 +62,13 @@
}
.festivalBadge {
left: 8px;
inset-inline-start: 8px;
text-transform: uppercase;
color: #7c3aed;
}
.stockBadge {
right: 8px;
inset-inline-end: 8px;
color: var(--primary);
}
@@ -83,7 +83,7 @@
color: var(--text-primary);
line-height: 1.3;
margin-bottom: 3px;
text-align: left;
text-align: start;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
@@ -91,7 +91,7 @@
}
.nameFa {
font-family: var(--font-fa), var(--font-en);
font-family: var(--font-ui);
font-size: 12px;
color: var(--text-secondary);
direction: rtl;
@@ -129,7 +129,7 @@
display: flex;
align-items: center;
justify-content: flex-start;
padding-left: 4px;
padding-inline-start: 4px;
}
.controlsLeft button {
@@ -1,6 +1,8 @@
import { useId } from 'react'
import { Trash2 } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import type { FavoriteListing } from '../services/favoritesService'
import { useT } from '../i18n/useT'
import { formatVariantCount } from '../utils/storeProductGroups'
import { StoreItemPrice } from './StoreItemPrice'
import { Tooltip } from './Tooltip'
@@ -55,6 +57,8 @@ export function FavoriteStoreItemCard({
onAddToCart,
removing = false,
}: FavoriteStoreItemCardProps) {
const t = useT()
const { locale } = useLocale()
const plusGradientId = `add-cart-gradient-${useId().replace(/:/g, '')}`
return (
@@ -71,16 +75,20 @@ export function FavoriteStoreItemCard({
) : (
<div className={styles.imagePlaceholder} />
)}
{listing.showFestival && <span className={styles.festivalBadge}>Festival</span>}
{listing.showFestival && (
<span className={styles.festivalBadge}>{t('favorites.festival')}</span>
)}
{listing.productTotalStock > 0 && (
<span className={styles.stockBadge}>{listing.productTotalStock} in stock</span>
<span className={styles.stockBadge}>
{t('favorites.inStock', { count: listing.productTotalStock })}
</span>
)}
</div>
<div className={styles.body}>
<h3 className={styles.nameEn}>{listing.productTitle}</h3>
{listing.productNameFa && <p className={styles.nameFa}>{listing.productNameFa}</p>}
<p className={styles.variantLabel}>{formatVariantCount(listing.variantCount)}</p>
<p className={styles.variantLabel}>{formatVariantCount(listing.variantCount, locale)}</p>
<StoreItemPrice
price={listing.displayPrice}
discountedPrice={listing.displayDiscountedPrice}
@@ -90,25 +98,25 @@ export function FavoriteStoreItemCard({
<div className={styles.controls}>
<div className={styles.controlsLeft}>
<Tooltip label="Remove from favorites">
<Tooltip label={t('favorites.remove')}>
<button
type="button"
className={styles.danger}
onClick={() => onRemove(listing)}
disabled={removing}
aria-label="Remove from favorites"
aria-label={t('favorites.remove')}
>
<Trash2 size={16} />
</button>
</Tooltip>
</div>
<Tooltip label="Add to shopping cart">
<Tooltip label={t('favorites.addToCart')}>
<button
type="button"
className={styles.addToCartBtn}
onClick={() => onAddToCart(listing)}
aria-label="Add to shopping cart"
aria-label={t('favorites.addToCart')}
>
<GradientPlusIcon gradientId={plusGradientId} />
</button>
+9 -13
View File
@@ -60,9 +60,10 @@
.badge {
position: absolute;
top: 4px;
right: 4px;
width: 18px;
inset-inline-end: 4px;
min-width: 18px;
height: 18px;
padding: 0 4px;
display: flex;
align-items: center;
justify-content: center;
@@ -70,7 +71,8 @@
color: white;
font-size: 10px;
font-weight: 600;
border-radius: 50%;
line-height: 1;
border-radius: 999px;
border: 2px solid white;
}
@@ -81,8 +83,9 @@
.profile {
display: flex;
align-items: center;
gap: 12px;
padding: 6px 12px 6px 6px;
gap: 10px;
padding-block: 8px;
padding-inline: 14px 12px;
border-radius: 50px;
background: rgba(255, 255, 255, 0.5);
border: 1px solid var(--glass-border);
@@ -96,13 +99,6 @@
border-color: rgba(var(--primary-rgb) / 0.25);
}
.avatar {
width: 36px;
height: 36px;
border-radius: 50%;
object-fit: cover;
}
.profileInfo {
display: flex;
flex-direction: column;
@@ -132,7 +128,7 @@
.dropdown {
position: absolute;
top: calc(100% + 8px);
right: 0;
inset-inline-end: 0;
min-width: 180px;
padding: 6px;
background: rgba(255, 255, 255, 0.95);
+40 -16
View File
@@ -1,20 +1,45 @@
import { useEffect, useRef, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { Menu, Bell, MessageSquare, ChevronDown, User, KeyRound, LogOut } from 'lucide-react'
import { PasswordResetModal } from '@meshkee/dashboard-ui'
import { LanguageSelect, PasswordResetModal, useLocale } from '@meshkee/dashboard-ui'
import { useAuth } from '../context/AuthContext'
import { useT } from '../i18n/useT'
import { changePassword } from '../services/authService'
import styles from './Header.module.css'
function displayUserName(
user: {
firstName: string | null
lastName: string | null
firstNameEn: string | null
lastNameEn: string | null
cellNumber: string
} | null,
locale: 'en' | 'fa',
fallback: string,
) {
if (!user) return fallback
const localized =
locale === 'en'
? [user.firstNameEn, user.lastNameEn].filter(Boolean).join(' ')
: [user.firstName, user.lastName].filter(Boolean).join(' ')
const other =
locale === 'en'
? [user.firstName, user.lastName].filter(Boolean).join(' ')
: [user.firstNameEn, user.lastNameEn].filter(Boolean).join(' ')
return localized || other || user.cellNumber || fallback
}
export function Header() {
const navigate = useNavigate()
const { user, logout } = useAuth()
const { locale } = useLocale()
const t = useT()
const [menuOpen, setMenuOpen] = useState(false)
const [passwordModalOpen, setPasswordModalOpen] = useState(false)
const menuRef = useRef<HTMLDivElement>(null)
const displayName =
[user?.firstName, user?.lastName].filter(Boolean).join(' ') || user?.cellNumber || 'Customer'
const displayName = displayUserName(user, locale, t('app.role.customer'))
useEffect(() => {
if (!menuOpen) return
@@ -52,19 +77,22 @@ export function Header() {
<>
<header className={styles.header}>
<div className={styles.left}>
<button className={styles.menuBtn} aria-label="Toggle menu">
<button className={styles.menuBtn} aria-label={t('header.toggleMenu')}>
<Menu size={22} />
</button>
<h1 className={styles.title}>Customer Dashboard</h1>
</div>
<div className={styles.right}>
<button className={styles.iconBtn} aria-label="Messages">
<LanguageSelect />
<button className={styles.iconBtn} aria-label={t('header.messages')}>
<MessageSquare size={20} />
<span className={styles.badge}>0</span>
</button>
<button className={styles.iconBtn} aria-label="Notifications">
<button className={styles.iconBtn} aria-label={t('header.notifications')}>
<Bell size={20} />
<span className={styles.badge}>0</span>
</button>
<div className={styles.profileWrap} ref={menuRef}>
@@ -75,14 +103,9 @@ export function Header() {
aria-expanded={menuOpen}
aria-haspopup="menu"
>
<img
src={`https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(displayName)}`}
alt={displayName}
className={styles.avatar}
/>
<div className={styles.profileInfo}>
<span className={styles.name}>{displayName}</span>
<span className={styles.role}>Customer</span>
<span className={styles.role}>{t('app.role.customer')}</span>
</div>
<ChevronDown
size={16}
@@ -99,7 +122,7 @@ export function Header() {
onClick={() => setMenuOpen(false)}
>
<User size={16} />
<span>My Profile</span>
<span>{t('header.myProfile')}</span>
</Link>
<button
type="button"
@@ -108,7 +131,7 @@ export function Header() {
onClick={openPasswordModal}
>
<KeyRound size={16} />
<span>Change password</span>
<span>{t('header.changePassword')}</span>
</button>
<button
type="button"
@@ -117,7 +140,7 @@ export function Header() {
onClick={handleLogout}
>
<LogOut size={16} />
<span>Logout</span>
<span>{t('nav.logout')}</span>
</button>
</div>
)}
@@ -129,6 +152,7 @@ export function Header() {
open={passwordModalOpen}
onClose={() => setPasswordModalOpen(false)}
onChangePassword={changePassword}
title={t('header.changePassword')}
/>
</>
)
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
import { useT } from '../i18n/useT'
import type { Order } from '../services/orderService'
import { formatCellForDisplay } from '../lib/cellNumber'
import { formatIrtPrice } from '../utils/irtPrice'
@@ -30,6 +31,7 @@ function totalQuantity(order: Order) {
}
export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps) {
const t = useT()
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
@@ -75,11 +77,16 @@ export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps)
<div className={modalStyles.header}>
<div>
<h2 id="order-items-title" className={modalStyles.title}>
Order items
{t('orderItems.title')}
</h2>
<p className={modalStyles.subtitle}>{order.orderNumber}</p>
</div>
<button type="button" className={modalStyles.closeBtn} onClick={onClose} aria-label="Close">
<button
type="button"
className={modalStyles.closeBtn}
onClick={onClose}
aria-label={t('common.close')}
>
<X size={18} />
</button>
</div>
@@ -87,26 +94,27 @@ export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps)
<div className={`${modalStyles.body} ${styles.body}`}>
<div className={styles.metaRow}>
<span className={styles.metaItem}>
Customer: <strong>{displayName(order)}</strong>
{t('orderItems.customer')}: <strong>{displayName(order)}</strong>
</span>
<span className={styles.metaItem}>
Phone: <strong>{formatCellForDisplay(order.customer.cellNumber)}</strong>
{t('orderItems.phone')}:{' '}
<strong>{formatCellForDisplay(order.customer.cellNumber)}</strong>
</span>
<span className={styles.metaItem}>
Items: <strong>{itemCount}</strong>
{t('orderItems.items')}: <strong>{itemCount}</strong>
</span>
</div>
{order.items.length === 0 ? (
<p className={styles.empty}>No items in this order.</p>
<p className={styles.empty}>{t('orderItems.empty')}</p>
) : (
<div className={styles.tableBlock}>
<div className={styles.tableHead}>
<span aria-hidden="true" />
<span>Product</span>
<span>Qty</span>
<span>Unit price</span>
<span>Line total</span>
<span>{t('orderItems.product')}</span>
<span>{t('orderItems.qty')}</span>
<span>{t('orderItems.unitPrice')}</span>
<span>{t('orderItems.lineTotal')}</span>
</div>
<ul className={styles.itemList}>
{order.items.map((item) => (
@@ -122,7 +130,9 @@ export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps)
<div className={styles.itemTitle}>{item.productTitle}</div>
<div className={styles.itemVariant}>{formatVariantLabel(item.selections)}</div>
{item.variantSku && (
<div className={styles.itemSku}>SKU: {item.variantSku}</div>
<div className={styles.itemSku}>
{t('orderItems.sku', { sku: item.variantSku })}
</div>
)}
</div>
<div className={styles.qtyCell}>{item.quantity}</div>
@@ -136,20 +146,23 @@ export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps)
<div className={styles.summary}>
<span className={styles.summaryLabel}>
Order total · {itemCount} {itemCount === 1 ? 'item' : 'items'}
{t('orderItems.summary', {
count: itemCount,
itemsLabel:
itemCount === 1 ? t('orderItems.item') : t('orderItems.itemsPlural'),
})}
</span>
<span className={styles.summaryValue}>{formatIrtPrice(order.total)}</span>
</div>
<div className={modalStyles.actions}>
<button type="button" className={modalStyles.cancelBtn} onClick={onClose}>
Close
{t('orderItems.close')}
</button>
</div>
</div>
</div>
</div>
,
document.body,
</div>,
document.body,
)
}
@@ -1,6 +1,6 @@
.td {
padding: 10px 12px;
text-align: left;
text-align: start;
border-bottom: 1px solid rgba(148, 163, 184, 0.16);
vertical-align: middle;
font-size: 13px;
@@ -75,16 +75,17 @@
}
.tdActions {
text-align: right;
text-align: end;
white-space: nowrap;
padding-right: 10px;
padding-inline: 10px;
}
.rowActions {
display: inline-flex;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0;
width: 100%;
}
.actionBtn {
+42 -31
View File
@@ -1,4 +1,6 @@
import { Eye } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import type { Order, OrderSource } from '../services/orderService'
import type { OrderProcessStep } from '../utils/orderSteps'
import { stepLabel, stepColor } from '../utils/orderSteps'
@@ -12,12 +14,13 @@ interface OrderRowProps {
onViewItems: (order: Order) => void
}
function formatDateTime(value: string) {
function formatDateTime(value: string, locale: 'en' | 'fa') {
const d = new Date(value)
if (Number.isNaN(d.getTime())) return { date: value, time: '' }
const intlLocale = locale === 'fa' ? 'fa-IR' : 'en-US'
return {
date: d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: '2-digit' }),
time: d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }),
date: d.toLocaleDateString(intlLocale, { year: 'numeric', month: 'short', day: '2-digit' }),
time: d.toLocaleTimeString(intlLocale, { hour: '2-digit', minute: '2-digit' }),
}
}
@@ -25,35 +28,37 @@ function totalItemQuantity(order: Order) {
return order.items.reduce((sum, item) => sum + item.quantity, 0)
}
function sourceLabel(source: OrderSource) {
switch (source) {
case 'admin':
return 'Operator'
case 'app':
return 'Application'
case 'website':
default:
return 'Website'
}
}
function sourceClass(source: OrderSource) {
switch (source) {
case 'admin':
return styles.sourceOperator
case 'app':
return styles.sourceApplication
case 'website':
default:
return styles.sourceWebsite
}
}
export function OrderRow({ order, processSteps, onViewItems }: OrderRowProps) {
const { date, time } = formatDateTime(order.createdAt)
const { locale } = useLocale()
const t = useT()
const { date, time } = formatDateTime(order.createdAt, locale)
const itemQty = totalItemQuantity(order)
const processStepId = order.processStepId ?? processSteps[0]?.id ?? 'processing'
function sourceLabel(source: OrderSource) {
switch (source) {
case 'admin':
return t('orders.source.operator')
case 'app':
return t('orders.source.app')
case 'website':
default:
return t('orders.source.website')
}
}
function sourceClass(source: OrderSource) {
switch (source) {
case 'admin':
return styles.sourceOperator
case 'app':
return styles.sourceApplication
case 'website':
default:
return styles.sourceWebsite
}
}
return (
<tr>
<td className={styles.td}>
@@ -74,7 +79,13 @@ export function OrderRow({ order, processSteps, onViewItems }: OrderRowProps) {
stepColor(processSteps, processStepId, order.processStepColor),
)}
>
{stepLabel(processSteps, processStepId, order.processStepLabel)}
{stepLabel(
processSteps,
processStepId,
order.processStepLabel,
order.processStepLabelFa,
locale,
)}
</span>
</td>
<td className={styles.td}>
@@ -88,8 +99,8 @@ export function OrderRow({ order, processSteps, onViewItems }: OrderRowProps) {
type="button"
className={styles.actionBtn}
onClick={() => onViewItems(order)}
aria-label="View items"
title="View items"
aria-label={t('orders.viewItems')}
title={t('orders.viewItems')}
>
<Eye size={15} />
</button>
@@ -4,7 +4,7 @@
}
.main {
margin-left: var(--sidebar-width);
margin-inline-start: var(--sidebar-width);
min-height: 100vh;
position: relative;
z-index: 1;
@@ -12,6 +12,6 @@
@media (max-width: 768px) {
.main {
margin-left: 0;
margin-inline-start: 0;
}
}
+16 -14
View File
@@ -1,7 +1,7 @@
.sidebar {
position: fixed;
top: 0;
left: 0;
inset-inline-start: 0;
width: var(--sidebar-width);
height: 100vh;
display: flex;
@@ -10,7 +10,7 @@
background: var(--glass-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-right: 1px solid var(--glass-border);
border-inline-end: 1px solid var(--glass-border);
z-index: 100;
}
@@ -61,17 +61,18 @@
}
.brandDomain {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
line-height: 1.2;
}
.brandName {
font-size: 11px;
font-weight: 500;
color: var(--text-muted);
margin-top: 2px;
line-height: 1.2;
}
.brandName {
font-size: 13px;
font-weight: 700;
color: var(--text-primary);
line-height: 1.2;
}
.nav {
@@ -80,7 +81,7 @@
flex-direction: column;
gap: 2px;
overflow-y: auto;
padding-right: 2px;
padding-inline-end: 2px;
}
.navGroup {
@@ -99,7 +100,7 @@
color: var(--text-secondary);
transition: all 0.2s ease;
width: 100%;
text-align: left;
text-align: start;
}
.navItem:hover {
@@ -134,9 +135,10 @@
display: flex;
flex-direction: column;
gap: 2px;
margin: 2px 0 4px 12px;
padding-left: 12px;
border-left: 2px solid rgba(148, 163, 184, 0.2);
margin-block: 2px 4px;
margin-inline: 12px 0;
padding-inline-start: 12px;
border-inline-start: 2px solid rgba(148, 163, 184, 0.2);
}
.subNavItem {
+24 -17
View File
@@ -1,32 +1,35 @@
import { useEffect, useState } from 'react'
import { NavLink, useNavigate } from 'react-router-dom'
import { Home, User, MapPin, ShoppingBag, ShoppingCart, Heart, HelpCircle, LogOut } from 'lucide-react'
import { Home, User, MapPin, ShoppingBag, Heart, HelpCircle, LogOut } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useAuth } from '../context/AuthContext'
import { useT } from '../i18n/useT'
import { getActiveBusinessDomain } from '../lib/businessContext'
import { isAbortError } from '../lib/api'
import { getWebsiteBusinessInfo } from '../services/websiteService'
import meshkeeLogo from '../assets/meshkee-logo.png'
import styles from './Sidebar.module.css'
const navItems = [
{ icon: Home, label: 'Home', to: '/' },
{ icon: ShoppingCart, label: 'Shopping Cart', to: '/checkout' },
{ icon: User, label: 'My Profile', to: '/profile' },
{ icon: MapPin, label: 'My Addresses', to: '/addresses' },
{ icon: ShoppingBag, label: 'My Orders', to: '/orders' },
{ icon: Heart, label: 'My Favorites', to: '/favorites' },
]
export function Sidebar() {
const navigate = useNavigate()
const { user, logout } = useAuth()
const { locale } = useLocale()
const t = useT()
const [brandName, setBrandName] = useState('')
const [logoUrl, setLogoUrl] = useState<string | null>(null)
const businessDomain = getActiveBusinessDomain()
const fallbackBusinessName = user?.customerBusinesses[0]?.name ?? 'Store'
const fallbackBusinessName = user?.customerBusinesses[0]?.name ?? t('app.storeFallback')
const displayName = brandName || fallbackBusinessName
const navItems = [
{ icon: Home, label: t('nav.home'), to: '/' },
{ icon: User, label: t('nav.profile'), to: '/profile' },
{ icon: MapPin, label: t('nav.addresses'), to: '/addresses' },
{ icon: ShoppingBag, label: t('nav.orders'), to: '/orders' },
{ icon: Heart, label: t('nav.favorites'), to: '/favorites' },
]
useEffect(() => {
const controller = new AbortController()
@@ -34,7 +37,11 @@ export function Sidebar() {
try {
const info = await getWebsiteBusinessInfo(businessDomain, controller.signal)
if (controller.signal.aborted) return
setBrandName(info.nameFa?.trim() || info.name.trim() || fallbackBusinessName)
const localized =
locale === 'fa'
? info.nameFa?.trim() || info.name.trim()
: info.name.trim() || info.nameFa?.trim()
setBrandName(localized || fallbackBusinessName)
setLogoUrl(info.logoUrl?.trim() || null)
} catch (err) {
if (isAbortError(err)) return
@@ -48,7 +55,7 @@ export function Sidebar() {
return () => {
controller.abort()
}
}, [businessDomain, fallbackBusinessName])
}, [businessDomain, fallbackBusinessName, locale])
return (
<aside className={styles.sidebar}>
@@ -59,15 +66,15 @@ export function Sidebar() {
className={styles.brandLogo}
/>
<div className={styles.brandText}>
<span className={styles.brandDomain}>{businessDomain}</span>
<span className={styles.brandName}>{displayName}</span>
<span className={styles.brandDomain}>{businessDomain}</span>
</div>
</div>
<nav className={styles.nav}>
{navItems.map((item) => (
<NavLink
key={item.label}
key={item.to}
to={item.to}
end={item.to === '/'}
className={({ isActive }) =>
@@ -83,7 +90,7 @@ export function Sidebar() {
<div className={styles.footer}>
<button type="button" className={styles.navItem}>
<HelpCircle size={20} />
<span>Help Center</span>
<span>{t('nav.help')}</span>
</button>
<button
type="button"
@@ -94,7 +101,7 @@ export function Sidebar() {
}}
>
<LogOut size={20} />
<span>Logout</span>
<span>{t('nav.logout')}</span>
</button>
</div>
</aside>
@@ -17,3 +17,7 @@
.backBtn span {
line-height: 1.4;
}
:global([dir='rtl']) .backBtn svg {
transform: scaleX(-1);
}
@@ -3,10 +3,12 @@ import {
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react'
import { applyDocumentFavicon } from '@meshkee/dashboard-core'
import { useLocale } from '@meshkee/dashboard-ui'
import { isAbortError } from '../lib/api'
import { getTenantDomain } from '../lib/config'
import { getWebsiteBusinessInfo } from '../services/websiteService'
@@ -31,9 +33,11 @@ function pickBusinessName(
}
export function TenantBrandingProvider({ children }: { children: ReactNode }) {
const { setLocale } = useLocale()
const [businessName, setBusinessName] = useState('')
const [logoUrl, setLogoUrl] = useState<string | null>(null)
const [faviconUrl, setFaviconUrl] = useState<string | null>(null)
const defaultLocaleAppliedRef = useRef(false)
const domain = getTenantDomain()
useEffect(() => {
@@ -68,6 +72,15 @@ export function TenantBrandingProvider({ children }: { children: ReactNode }) {
setLogoUrl(nextLogo)
setFaviconUrl(nextFavicon)
applyDocumentFavicon(nextFavicon)
if (!defaultLocaleAppliedRef.current) {
defaultLocaleAppliedRef.current = true
if (tenant.defaultLocale === 'en' || tenant.defaultLocale === 'fa') {
setLocale(tenant.defaultLocale)
} else {
setLocale('fa')
}
}
} catch (err) {
if (isAbortError(err) || controller.signal.aborted) return
setBusinessName(domain)
@@ -82,7 +95,7 @@ export function TenantBrandingProvider({ children }: { children: ReactNode }) {
return () => {
controller.abort()
}
}, [domain])
}, [domain, setLocale])
const value = useMemo(
() => ({ businessName, logoUrl, faviconUrl }),
+506
View File
@@ -0,0 +1,506 @@
import type { DashboardLocale } from '@meshkee/dashboard-core'
const en = {
'app.dashboardName': 'Customer Dashboard',
'app.role.customer': 'Customer',
'app.storeFallback': 'Store',
'app.poweredBy': 'powered by Meshkee.app',
'nav.home': 'Home',
'nav.cart': 'Shopping Cart',
'nav.profile': 'My Profile',
'nav.addresses': 'My Addresses',
'nav.orders': 'My Orders',
'nav.favorites': 'My Favorites',
'nav.help': 'Help Center',
'nav.logout': 'Logout',
'header.toggleMenu': 'Toggle menu',
'header.messages': 'Messages',
'header.notifications': 'Notifications',
'header.changePassword': 'Change password',
'header.myProfile': 'My Profile',
'home.welcome': 'Welcome, dear {name}.',
'home.welcomeFallback': 'there',
'home.subtitle': 'Manage your profile, addresses, orders, and favorites in one place.',
'home.card.profile.title': 'My Profile',
'home.card.profile.desc': 'View and update your personal information and contact details.',
'home.card.profile.link': 'View profile',
'home.card.addresses.title': 'My Addresses',
'home.card.addresses.desc': 'Manage your shipping addresses for checkout and deliveries.',
'home.card.addresses.link': 'View addresses',
'home.card.orders.title': 'My Orders',
'home.card.orders.desc': 'Track your orders, view order history and order details.',
'home.card.orders.link': 'View orders',
'home.card.favorites.title': 'My Favorites',
'home.card.favorites.desc': 'Browse and manage your saved favorite products.',
'home.card.favorites.link': 'View favorites',
'profile.title': 'My Profile',
'profile.subtitle': 'Update your personal information and contact details.',
'profile.section.account': 'Account',
'profile.section.about': 'About',
'profile.section.social': 'Social',
'profile.mobile': 'Mobile number',
'profile.email': 'Email',
'profile.firstName': 'First name (FA)',
'profile.lastName': 'Last name (FA)',
'profile.firstNameEn': 'First name (EN)',
'profile.lastNameEn': 'Last name (EN)',
'profile.landline': 'Landline',
'profile.backupPhone': 'Backup phone number',
'profile.about': 'About',
'profile.instagram': 'Instagram',
'profile.telegram': 'Telegram',
'profile.linkedin': 'LinkedIn',
'profile.emailPlaceholder': 'you@example.com',
'profile.save': 'Save changes',
'profile.saving': 'Saving...',
'profile.toast.success': 'Profile updated successfully.',
'profile.error.update': 'Unable to update profile. Please try again.',
'addresses.title': 'My Addresses',
'addresses.subtitle': 'Manage your shipping addresses for orders at this store.',
'addresses.save': 'Save addresses',
'addresses.saveOne': 'Save address',
'addresses.saving': 'Saving...',
'addresses.cancel': 'Cancel',
'addresses.editorTitle': 'Saved addresses',
'addresses.add': 'Add address',
'addresses.edit': 'Edit address',
'addresses.empty': 'No addresses yet.',
'addresses.loading': 'Loading addresses...',
'addresses.label': 'Address name',
'addresses.labelPlaceholder': 'e.g. Home, Office',
'addresses.optional': '(optional)',
'addresses.province': 'Province',
'addresses.city': 'City',
'addresses.address': 'Address',
'addresses.postalCode': 'Postal code',
'addresses.landline': 'Landline',
'addresses.selectProvince': 'Select province',
'addresses.selectCity': 'Select city',
'addresses.streetPlaceholder': 'Street, plaque, unit',
'addresses.postalPlaceholder': 'Postal code',
'addresses.landlinePlaceholder': 'Landline',
'addresses.remove': 'Remove address',
'addresses.modal.addTitle': 'Add address',
'addresses.modal.editTitle': 'Edit address',
'addresses.modal.subtitle': 'Enter the shipping address details.',
'addresses.toast.removed': 'Address removed.',
'addresses.toast.saved': 'Addresses saved.',
'addresses.toast.created': 'Address saved.',
'addresses.toast.updated': 'Address updated.',
'addresses.error.load': 'Unable to load addresses.',
'addresses.error.remove': 'Unable to remove address.',
'addresses.error.incomplete': 'Add at least one complete address.',
'addresses.error.incompleteForm': 'Please fill in all required fields.',
'addresses.error.save': 'Unable to save address. Please try again.',
'addresses.error.loadLocations': 'Unable to load provinces.',
'addresses.error.loadCities': 'Unable to load cities.',
'orders.title': 'My Orders',
'orders.subtitle': 'View your order history and details.',
'orders.listTitle': 'Order list',
'orders.showing': 'Showing {from} - {to} of {total}',
'orders.none': 'No orders',
'orders.empty': 'You have no orders yet.',
'orders.loading': 'Loading orders...',
'orders.error.load': 'Unable to load orders.',
'orders.col.orderId': 'Order ID',
'orders.col.items': 'Items',
'orders.col.total': 'Total cost',
'orders.col.date': 'Date & time',
'orders.col.step': 'Step',
'orders.col.source': 'Registered by',
'orders.col.actions': 'Actions',
'orders.pageMeta': 'Page {page} / {totalPages} · {pageSize} per page · {total} total',
'orders.viewItems': 'View items',
'orders.source.operator': 'Operator',
'orders.source.app': 'Application',
'orders.source.website': 'Website',
'orders.step.processing': 'Under processing',
'orders.step.ready': 'Ready for shipping',
'orders.step.shipped': 'Shipped',
'orders.step.delivered': 'Delivered',
'orderItems.title': 'Order items',
'orderItems.customer': 'Customer',
'orderItems.phone': 'Phone',
'orderItems.items': 'Items',
'orderItems.empty': 'No items in this order.',
'orderItems.product': 'Product',
'orderItems.qty': 'Qty',
'orderItems.unitPrice': 'Unit price',
'orderItems.lineTotal': 'Line total',
'orderItems.sku': 'SKU: {sku}',
'orderItems.summary': 'Order total · {count} {itemsLabel}',
'orderItems.item': 'item',
'orderItems.itemsPlural': 'items',
'orderItems.close': 'Close',
'favorites.title': 'My Favorites',
'favorites.subtitle': 'Products you have saved for later.',
'favorites.loading': 'Loading favorites...',
'favorites.empty': 'No favorites yet.',
'favorites.error.load': 'Unable to load favorites.',
'favorites.toast.removed': 'Removed from favorites.',
'favorites.error.remove': 'Unable to remove favorite.',
'favorites.cartSoon': 'Shopping cart is coming soon.',
'favorites.remove': 'Remove from favorites',
'favorites.addToCart': 'Add to shopping cart',
'favorites.festival': 'Festival',
'favorites.inStock': '{count} in stock',
'favorites.variantOne': '1 variant',
'favorites.variantMany': '{count} variants',
'title.signIn': 'Sign in',
'title.checkout': 'Checkout',
'title.cart': 'Shopping Cart',
'title.delivery': 'Delivery',
'title.payment': 'Payment',
'title.success': 'Success',
'title.failed': 'Failed',
'login.welcome': 'Welcome back',
'login.subtitle': 'Sign in with your mobile number',
'login.mobile': 'Mobile number',
'login.password': 'Password',
'login.passwordPlaceholder': 'Enter your password',
'login.hidePassword': 'Hide password',
'login.showPassword': 'Show password',
'login.forgot': 'Forgot password?',
'login.signIn': 'Sign in',
'login.signingIn': 'Signing in...',
'login.or': 'or',
'login.otp': 'One-time login with SMS',
'login.noAccount': "Don't have an account?",
'login.signUp': 'Sign up',
'login.error.signIn': 'Unable to sign in. Check your connection and try again.',
'login.error.sendCode': 'Unable to send verification code.',
'signup.title': 'Create account',
'signup.subtitle': 'Register as a customer of {domain}',
'signup.firstName': 'First name',
'signup.lastName': 'Last name',
'signup.passwordPlaceholder': 'Choose a password',
'signup.confirm': 'Confirm password',
'signup.confirmPlaceholder': 'Repeat your password',
'signup.create': 'Create account',
'signup.creating': 'Creating account...',
'signup.hasAccount': 'Already have an account?',
'signup.signIn': 'Sign in',
'signup.error.match': 'Passwords do not match.',
'signup.error.length': 'Password must be at least 8 characters.',
'signup.error.create': 'Unable to create account.',
'forgot.back': 'Back to sign in',
'forgot.title': 'Forgot password',
'forgot.subtitlePhone': 'We will send a verification code via SMS',
'forgot.subtitleCode': 'Enter the code and your new password',
'forgot.sendCode': 'Send SMS code',
'forgot.sending': 'Sending...',
'forgot.code': 'SMS verification code',
'forgot.newPassword': 'New password',
'forgot.newPasswordPlaceholder': 'Enter new password',
'forgot.reset': 'Reset password',
'forgot.verifying': 'Verifying...',
'forgot.error.length': 'Password must be at least 8 characters.',
'forgot.error.verify': 'Unable to verify code.',
'forgot.info.partial':
'Phone number verified. Full password reset via SMS is not available yet — please contact support or sign in if you remember your password.',
'otp.back': 'Back to sign in',
'otp.title': 'One-time login',
'otp.subtitlePhone': 'Verify your mobile number with a one-time SMS code',
'otp.subtitleCode': 'Enter the SMS code and your password',
'otp.sendCode': 'Send SMS code',
'otp.sending': 'Sending...',
'otp.code': 'SMS verification code',
'otp.password': 'Password',
'otp.passwordPlaceholder': 'Your account password',
'otp.signIn': 'Sign in',
'otp.signingIn': 'Signing in...',
'otp.error.password': 'Enter your account password to complete sign-in after SMS verification.',
'otp.error.signIn': 'Unable to sign in with SMS verification.',
'common.close': 'Close',
'common.resendIn': 'Resend code in {seconds}s',
'common.resend': 'Resend SMS code',
'common.codeSent': 'Verification code sent to {phone}',
} as const
type MessageKey = keyof typeof en
const fa: Record<MessageKey, string> = {
'app.dashboardName': 'پنل مشتری',
'app.role.customer': 'مشتری',
'app.storeFallback': 'فروشگاه',
'app.poweredBy': 'قدرت‌گرفته از Meshkee.app',
'nav.home': 'خانه',
'nav.cart': 'سبد خرید',
'nav.profile': 'پروفایل من',
'nav.addresses': 'آدرس‌های من',
'nav.orders': 'سفارش‌های من',
'nav.favorites': 'علاقه‌مندی‌ها',
'nav.help': 'مرکز راهنما',
'nav.logout': 'خروج',
'header.toggleMenu': 'باز و بسته کردن منو',
'header.messages': 'پیام‌ها',
'header.notifications': 'اعلان‌ها',
'header.changePassword': 'تغییر رمز عبور',
'header.myProfile': 'پروفایل من',
'home.welcome': '{name} عزیز، خوش آمدی.',
'home.welcomeFallback': 'کاربر',
'home.subtitle': 'پروفایل، آدرس‌ها، سفارش‌ها و علاقه‌مندی‌ها را از یکجا مدیریت کنید.',
'home.card.profile.title': 'پروفایل من',
'home.card.profile.desc': 'اطلاعات شخصی و راه‌های ارتباطی خود را مشاهده و به‌روزرسانی کنید.',
'home.card.profile.link': 'مشاهده پروفایل',
'home.card.addresses.title': 'آدرس‌های من',
'home.card.addresses.desc': 'آدرس‌های ارسال برای تسویه‌حساب و تحویل را مدیریت کنید.',
'home.card.addresses.link': 'مشاهده آدرس‌ها',
'home.card.orders.title': 'سفارش‌های من',
'home.card.orders.desc': 'سفارش‌ها را پیگیری کنید و تاریخچه و جزئیات را ببینید.',
'home.card.orders.link': 'مشاهده سفارش‌ها',
'home.card.favorites.title': 'علاقه‌مندی‌ها',
'home.card.favorites.desc': 'محصولات ذخیره‌شده مورد علاقه‌تان را ببینید و مدیریت کنید.',
'home.card.favorites.link': 'مشاهده علاقه‌مندی‌ها',
'profile.title': 'پروفایل من',
'profile.subtitle': 'اطلاعات شخصی و راه‌های ارتباطی خود را به‌روزرسانی کنید.',
'profile.section.account': 'حساب کاربری',
'profile.section.about': 'درباره من',
'profile.section.social': 'شبکه‌های اجتماعی',
'profile.mobile': 'شماره موبایل',
'profile.email': 'ایمیل',
'profile.firstName': 'نام (فارسی)',
'profile.lastName': 'نام خانوادگی (فارسی)',
'profile.firstNameEn': 'نام (انگلیسی)',
'profile.lastNameEn': 'نام خانوادگی (انگلیسی)',
'profile.landline': 'تلفن ثابت',
'profile.backupPhone': 'شماره تماس پشتیبان',
'profile.about': 'درباره من',
'profile.instagram': 'اینستاگرام',
'profile.telegram': 'تلگرام',
'profile.linkedin': 'لینکدین',
'profile.emailPlaceholder': 'you@example.com',
'profile.save': 'ذخیره تغییرات',
'profile.saving': 'در حال ذخیره...',
'profile.toast.success': 'پروفایل با موفقیت به‌روزرسانی شد.',
'profile.error.update': 'به‌روزرسانی پروفایل ممکن نشد. دوباره تلاش کنید.',
'addresses.title': 'آدرس‌های من',
'addresses.subtitle': 'آدرس‌های ارسال سفارش در این فروشگاه را مدیریت کنید.',
'addresses.save': 'ذخیره آدرس‌ها',
'addresses.saveOne': 'ذخیره آدرس',
'addresses.saving': 'در حال ذخیره...',
'addresses.cancel': 'انصراف',
'addresses.editorTitle': 'آدرس‌های ذخیره‌شده',
'addresses.add': 'افزودن آدرس',
'addresses.edit': 'ویرایش آدرس',
'addresses.empty': 'هنوز آدرسی ثبت نشده است.',
'addresses.loading': 'در حال بارگذاری آدرس‌ها...',
'addresses.label': 'عنوان آدرس',
'addresses.labelPlaceholder': 'مثلاً خانه، محل کار',
'addresses.optional': '(اختیاری)',
'addresses.province': 'استان',
'addresses.city': 'شهر',
'addresses.address': 'آدرس',
'addresses.postalCode': 'کد پستی',
'addresses.landline': 'تلفن ثابت',
'addresses.selectProvince': 'انتخاب استان',
'addresses.selectCity': 'انتخاب شهر',
'addresses.streetPlaceholder': 'خیابان، پلاک، واحد',
'addresses.postalPlaceholder': 'کد پستی',
'addresses.landlinePlaceholder': 'تلفن ثابت',
'addresses.remove': 'حذف آدرس',
'addresses.modal.addTitle': 'افزودن آدرس',
'addresses.modal.editTitle': 'ویرایش آدرس',
'addresses.modal.subtitle': 'جزئیات آدرس ارسال را وارد کنید.',
'addresses.toast.removed': 'آدرس حذف شد.',
'addresses.toast.saved': 'آدرس‌ها ذخیره شدند.',
'addresses.toast.created': 'آدرس ذخیره شد.',
'addresses.toast.updated': 'آدرس به‌روزرسانی شد.',
'addresses.error.load': 'بارگذاری آدرس‌ها ممکن نشد.',
'addresses.error.remove': 'حذف آدرس ممکن نشد.',
'addresses.error.incomplete': 'حداقل یک آدرس کامل اضافه کنید.',
'addresses.error.incompleteForm': 'لطفاً همه فیلدهای الزامی را تکمیل کنید.',
'addresses.error.save': 'ذخیره آدرس ممکن نشد. دوباره تلاش کنید.',
'addresses.error.loadLocations': 'بارگذاری استان‌ها ممکن نشد.',
'addresses.error.loadCities': 'بارگذاری شهرها ممکن نشد.',
'orders.title': 'سفارش‌های من',
'orders.subtitle': 'تاریخچه و جزئیات سفارش‌های خود را ببینید.',
'orders.listTitle': 'فهرست سفارش‌ها',
'orders.showing': 'نمایش {from} تا {to} از {total}',
'orders.none': 'بدون سفارش',
'orders.empty': 'هنوز سفارشی ندارید.',
'orders.loading': 'در حال بارگذاری سفارش‌ها...',
'orders.error.load': 'بارگذاری سفارش‌ها ممکن نشد.',
'orders.col.orderId': 'شماره سفارش',
'orders.col.items': 'اقلام',
'orders.col.total': 'مبلغ کل',
'orders.col.date': 'تاریخ و ساعت',
'orders.col.step': 'وضعیت',
'orders.col.source': 'ثبت‌شده توسط',
'orders.col.actions': 'عملیات',
'orders.pageMeta': 'صفحه {page} / {totalPages} · {pageSize} در هر صفحه · {total} کل',
'orders.viewItems': 'مشاهده اقلام',
'orders.source.operator': 'اپراتور',
'orders.source.app': 'اپلیکیشن',
'orders.source.website': 'وب‌سایت',
'orders.step.processing': 'در حال پردازش',
'orders.step.ready': 'آماده ارسال',
'orders.step.shipped': 'ارسال‌شده',
'orders.step.delivered': 'تحویل‌شده',
'orderItems.title': 'اقلام سفارش',
'orderItems.customer': 'مشتری',
'orderItems.phone': 'تلفن',
'orderItems.items': 'اقلام',
'orderItems.empty': 'اقلامی در این سفارش نیست.',
'orderItems.product': 'محصول',
'orderItems.qty': 'تعداد',
'orderItems.unitPrice': 'قیمت واحد',
'orderItems.lineTotal': 'جمع ردیف',
'orderItems.sku': 'کد کالا: {sku}',
'orderItems.summary': 'جمع سفارش · {count} {itemsLabel}',
'orderItems.item': 'قلم',
'orderItems.itemsPlural': 'قلم',
'orderItems.close': 'بستن',
'favorites.title': 'علاقه‌مندی‌ها',
'favorites.subtitle': 'محصولاتی که برای بعد ذخیره کرده‌اید.',
'favorites.loading': 'در حال بارگذاری علاقه‌مندی‌ها...',
'favorites.empty': 'هنوز علاقه‌مندی ندارید.',
'favorites.error.load': 'بارگذاری علاقه‌مندی‌ها ممکن نشد.',
'favorites.toast.removed': 'از علاقه‌مندی‌ها حذف شد.',
'favorites.error.remove': 'حذف از علاقه‌مندی‌ها ممکن نشد.',
'favorites.cartSoon': 'سبد خرید به‌زودی فعال می‌شود.',
'favorites.remove': 'حذف از علاقه‌مندی‌ها',
'favorites.addToCart': 'افزودن به سبد خرید',
'favorites.festival': 'جشنواره',
'favorites.inStock': '{count} موجود',
'favorites.variantOne': '۱ تنوع',
'favorites.variantMany': '{count} تنوع',
'title.signIn': 'ورود',
'title.checkout': 'تسویه‌حساب',
'title.cart': 'سبد خرید',
'title.delivery': 'ارسال',
'title.payment': 'پرداخت',
'title.success': 'موفق',
'title.failed': 'ناموفق',
'login.welcome': 'خوش آمدید',
'login.subtitle': 'با شماره موبایل وارد شوید',
'login.mobile': 'شماره موبایل',
'login.password': 'رمز عبور',
'login.passwordPlaceholder': 'رمز عبور خود را وارد کنید',
'login.hidePassword': 'مخفی کردن رمز',
'login.showPassword': 'نمایش رمز',
'login.forgot': 'رمز عبور را فراموش کرده‌اید؟',
'login.signIn': 'ورود',
'login.signingIn': 'در حال ورود...',
'login.or': 'یا',
'login.otp': 'ورود یک‌بارمصرف با پیامک',
'login.noAccount': 'حساب کاربری ندارید؟',
'login.signUp': 'ثبت‌نام',
'login.error.signIn': 'ورود ممکن نشد. اتصال خود را بررسی کنید و دوباره تلاش کنید.',
'login.error.sendCode': 'ارسال کد تأیید ممکن نشد.',
'signup.title': 'ایجاد حساب',
'signup.subtitle': 'به‌عنوان مشتری {domain} ثبت‌نام کنید',
'signup.firstName': 'نام',
'signup.lastName': 'نام خانوادگی',
'signup.passwordPlaceholder': 'یک رمز عبور انتخاب کنید',
'signup.confirm': 'تأیید رمز عبور',
'signup.confirmPlaceholder': 'رمز عبور را تکرار کنید',
'signup.create': 'ایجاد حساب',
'signup.creating': 'در حال ایجاد حساب...',
'signup.hasAccount': 'قبلاً حساب دارید؟',
'signup.signIn': 'ورود',
'signup.error.match': 'رمزهای عبور یکسان نیستند.',
'signup.error.length': 'رمز عبور باید حداقل ۸ کاراکتر باشد.',
'signup.error.create': 'ایجاد حساب ممکن نشد.',
'forgot.back': 'بازگشت به ورود',
'forgot.title': 'فراموشی رمز عبور',
'forgot.subtitlePhone': 'کد تأیید را از طریق پیامک ارسال می‌کنیم',
'forgot.subtitleCode': 'کد و رمز عبور جدید را وارد کنید',
'forgot.sendCode': 'ارسال کد پیامکی',
'forgot.sending': 'در حال ارسال...',
'forgot.code': 'کد تأیید پیامکی',
'forgot.newPassword': 'رمز عبور جدید',
'forgot.newPasswordPlaceholder': 'رمز عبور جدید را وارد کنید',
'forgot.reset': 'بازیابی رمز عبور',
'forgot.verifying': 'در حال تأیید...',
'forgot.error.length': 'رمز عبور باید حداقل ۸ کاراکتر باشد.',
'forgot.error.verify': 'تأیید کد ممکن نشد.',
'forgot.info.partial':
'شماره موبایل تأیید شد. بازیابی کامل رمز با پیامک هنوز در دسترس نیست — با پشتیبانی تماس بگیرید یا اگر رمز را به‌خاطر دارید وارد شوید.',
'otp.back': 'بازگشت به ورود',
'otp.title': 'ورود یک‌بارمصرف',
'otp.subtitlePhone': 'شماره موبایل خود را با کد یک‌بارمصرف پیامکی تأیید کنید',
'otp.subtitleCode': 'کد پیامکی و رمز عبور خود را وارد کنید',
'otp.sendCode': 'ارسال کد پیامکی',
'otp.sending': 'در حال ارسال...',
'otp.code': 'کد تأیید پیامکی',
'otp.password': 'رمز عبور',
'otp.passwordPlaceholder': 'رمز عبور حساب شما',
'otp.signIn': 'ورود',
'otp.signingIn': 'در حال ورود...',
'otp.error.password': 'برای تکمیل ورود پس از تأیید پیامکی، رمز حساب خود را وارد کنید.',
'otp.error.signIn': 'ورود با تأیید پیامکی ممکن نشد.',
'common.close': 'بستن',
'common.resendIn': 'ارسال مجدد کد تا {seconds} ثانیه',
'common.resend': 'ارسال مجدد کد پیامکی',
'common.codeSent': 'کد تأیید به {phone} ارسال شد',
}
const dictionaries: Record<DashboardLocale, Record<MessageKey, string>> = {
en: en as Record<MessageKey, string>,
fa,
}
export type CustomerMessageKey = MessageKey
export function translate(
locale: DashboardLocale,
key: MessageKey,
vars?: Record<string, string | number>,
): string {
const dict = dictionaries[locale] ?? dictionaries.en
let text = dict[key] ?? dictionaries.en[key] ?? key
if (vars) {
for (const [name, value] of Object.entries(vars)) {
text = text.replaceAll(`{${name}}`, String(value))
}
}
return text
}
export function getCustomerRouteTitleRules(locale: DashboardLocale) {
const t = (key: MessageKey) => translate(locale, key)
return [
{ match: '/login', labels: [t('title.signIn')] },
{ match: '/checkout/login', labels: [t('title.checkout'), t('title.signIn')] },
{ match: '/checkout/cart', labels: [t('title.checkout'), t('title.cart')] },
{ match: '/checkout/delivery', labels: [t('title.checkout'), t('title.delivery')] },
{ match: '/checkout/payment', labels: [t('title.checkout'), t('title.payment')] },
{ match: '/checkout/success', labels: [t('title.checkout'), t('title.success')] },
{ match: '/checkout/failed', labels: [t('title.checkout'), t('title.failed')] },
{ match: '/checkout', labels: [t('title.checkout')] },
{ match: '/profile', labels: [t('nav.profile')] },
{ match: '/addresses', labels: [t('nav.addresses')] },
{ match: '/orders', labels: [t('nav.orders')] },
{ match: '/favorites', labels: [t('nav.favorites')] },
{ match: '/', labels: [t('nav.home')] },
]
}
+13
View File
@@ -0,0 +1,13 @@
import { useCallback } from 'react'
import { useLocale } from '@meshkee/dashboard-ui'
import { translate, type CustomerMessageKey } from './messages'
export function useT() {
const { locale } = useLocale()
return useCallback(
(key: CustomerMessageKey, vars?: Record<string, string | number>) =>
translate(locale, key, vars),
[locale],
)
}
+3 -1
View File
@@ -1,6 +1,8 @@
@import '@meshkee/dashboard-core/styles/tokens.css';
/* Customer app Farsi typography — Yekan Bakh for body, inputs, and placeholders */
/* Customer: Montserrat (EN) + Yekan Bakh (FA) via --font-ui stack */
:root {
--font-en: 'Montserrat', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-fa: 'YekanBakh', Tahoma, sans-serif;
--font-ui: var(--font-en), var(--font-fa);
}
+119 -17
View File
@@ -1,9 +1,3 @@
.form {
display: flex;
flex-direction: column;
gap: 24px;
}
.section {
width: 100%;
background: var(--glass-bg);
@@ -15,26 +9,127 @@
padding: 24px;
}
.actions {
display: flex;
justify-content: flex-end;
.sectionTitle {
margin: 0 0 16px;
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.saveBtn {
padding: 10px 20px;
.status {
margin: 0;
font-size: 14px;
color: var(--text-muted);
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 40px 16px;
color: var(--text-muted);
text-align: center;
}
.empty p {
margin: 0;
font-size: 14px;
}
.list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.row {
display: flex;
align-items: center;
gap: 12px;
min-height: 48px;
padding: 10px 12px;
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.55);
}
.rowLine {
min-width: 0;
flex: 1;
display: flex;
align-items: center;
gap: 10px;
overflow: hidden;
white-space: nowrap;
}
.rowLabel {
flex-shrink: 0;
font-size: 14px;
font-weight: 600;
color: #fff;
background: var(--primary);
border-radius: var(--radius-sm);
color: var(--text-primary);
}
.saveBtn:disabled {
opacity: 0.6;
cursor: not-allowed;
.rowText {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
font-size: 13px;
color: var(--text-secondary);
}
.rowActions {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.editBtn {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
border-radius: 8px;
color: var(--text-muted);
transition: background 0.2s, color 0.2s;
}
.editBtn:hover {
background: rgba(var(--primary-rgb) / 0.1);
color: var(--primary);
}
.addFab {
position: fixed;
inset-inline-end: 32px;
bottom: 32px;
z-index: 50;
width: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
color: white;
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
box-shadow: 0 8px 24px rgba(var(--primary-rgb) / 0.4);
transition: transform 0.2s, box-shadow 0.2s;
}
.addFab:hover {
transform: translateY(-2px);
box-shadow: 0 12px 28px rgba(var(--primary-rgb) / 0.45);
}
.error {
margin-bottom: 16px;
padding: 12px 16px;
border-radius: var(--radius-sm);
font-size: 13px;
@@ -42,3 +137,10 @@
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.2);
}
@media (max-width: 768px) {
.addFab {
inset-inline-end: 20px;
bottom: 20px;
}
}
+154 -212
View File
@@ -1,259 +1,201 @@
import { useEffect, useMemo, useState } from 'react'
import {
AddressListEditor,
Breadcrumbs,
createEmptyAddressItem,
matchCityByName,
matchProvinceByName,
useToast,
type AddressListItem,
type CityOption,
} from '@meshkee/dashboard-ui'
import { useCallback, useEffect, useState } from 'react'
import { MapPin, Pencil, Plus, Trash2 } from 'lucide-react'
import { Breadcrumbs, useToast } from '@meshkee/dashboard-ui'
import { AddressFormModal } from '../components/AddressFormModal'
import { Tooltip } from '../components/Tooltip'
import { useT } from '../i18n/useT'
import { ApiError, isAbortError } from '../lib/api'
import {
createAddress,
listAddresses,
removeAddress,
updateAddress,
type UserAddress,
type UserAddressInput,
} from '../services/addressService'
import {
listCitiesByProvinceSlug,
listIranProvinces,
} from '../services/citiesService'
import pageStyles from '../components/PageContent.module.css'
import rowBtnStyles from '../components/VariationsModal.module.css'
import styles from './AddressesPage.module.css'
type AddressDraft = AddressListItem
function toDraft(
item: UserAddress,
provinces: CityOption[],
citiesByProvince: Record<string, CityOption[]>,
): AddressDraft {
const province = matchProvinceByName(item.province, provinces)
const cities = province ? (citiesByProvince[province.slug] ?? []) : []
const city = matchCityByName(item.city, cities)
return {
id: item.id,
provinceSlug: province?.slug ?? '',
province: province?.nameEn ?? item.province,
city: city?.nameEn ?? item.city,
address: item.address,
postalCode: item.postalCode ?? '',
landline: item.landline ?? '',
}
}
function isCompleteAddress(item: AddressDraft) {
return item.province.trim() && item.city.trim() && item.address.trim()
}
export function AddressesPage() {
const { showToast } = useToast()
const [provinces, setProvinces] = useState<CityOption[]>([])
const [citiesByProvince, setCitiesByProvince] = useState<Record<string, CityOption[]>>({})
const [addresses, setAddresses] = useState<AddressDraft[]>([createEmptyAddressItem()])
const t = useT()
const [addresses, setAddresses] = useState<UserAddress[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [removingId, setRemovingId] = useState<string | null>(null)
const [modalOpen, setModalOpen] = useState(false)
const [editingAddress, setEditingAddress] = useState<UserAddress | null>(null)
const loadAddresses = useCallback(
async (signal?: AbortSignal) => {
setLoading(true)
setError('')
try {
const data = await listAddresses(signal)
if (signal?.aborted) return
setAddresses(data.items)
} catch (err) {
if (isAbortError(err) || signal?.aborted) return
setError(err instanceof ApiError ? err.message : t('addresses.error.load'))
} finally {
if (!signal?.aborted) setLoading(false)
}
},
[t],
)
useEffect(() => {
const controller = new AbortController()
async function load() {
setLoading(true)
setError('')
try {
const [provinceItems, data] = await Promise.all([
listIranProvinces(controller.signal),
listAddresses(controller.signal),
])
if (controller.signal.aborted) return
setProvinces(provinceItems)
const draftItems =
data.items.length > 0 ? data.items : []
const slugs = [
...new Set(
draftItems
.map((item) => matchProvinceByName(item.province, provinceItems)?.slug)
.filter(Boolean) as string[],
),
]
const cityGroups = await Promise.all(
slugs.map(async (slug) => ({
slug,
cities: await listCitiesByProvinceSlug(slug, controller.signal),
})),
)
if (controller.signal.aborted) return
const citiesMap = Object.fromEntries(cityGroups.map((group) => [group.slug, group.cities]))
setCitiesByProvince(citiesMap)
setAddresses(
draftItems.length > 0
? draftItems.map((item) => toDraft(item, provinceItems, citiesMap))
: [createEmptyAddressItem()],
)
} catch (err) {
if (isAbortError(err) || controller.signal.aborted) return
setError(err instanceof ApiError ? err.message : 'Unable to load addresses.')
} finally {
if (!controller.signal.aborted) setLoading(false)
}
}
void load()
void loadAddresses(controller.signal)
return () => controller.abort()
}, [])
}, [loadAddresses])
function updateAddressDraft(index: number, patch: Partial<AddressDraft>) {
setAddresses((prev) =>
prev.map((item, i) => (i === index ? { ...item, ...patch } : item)),
)
function openCreateModal() {
setEditingAddress(null)
setModalOpen(true)
}
async function handleProvinceChange(index: number, provinceSlug: string) {
const province = provinces.find((item) => item.slug === provinceSlug)
updateAddressDraft(index, {
provinceSlug,
province: province?.nameEn ?? '',
city: '',
})
if (provinceSlug && !citiesByProvince[provinceSlug]) {
const cities = await listCitiesByProvinceSlug(provinceSlug)
setCitiesByProvince((prev) => ({ ...prev, [provinceSlug]: cities }))
}
function openEditModal(item: UserAddress) {
setEditingAddress(item)
setModalOpen(true)
}
function addAddressRow() {
setAddresses((prev) => [...prev, createEmptyAddressItem()])
}
async function handleRemove(index: number) {
const target = addresses[index]
if (!target) return
if (!target.id) {
setAddresses((prev) =>
prev.length === 1 ? [createEmptyAddressItem()] : prev.filter((_, i) => i !== index),
)
return
}
setSaving(true)
setError('')
try {
await removeAddress(target.id)
setAddresses((prev) => {
const next = prev.filter((_, i) => i !== index)
return next.length > 0 ? next : [createEmptyAddressItem()]
})
showToast('Address removed.', 'success')
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Unable to remove address.')
} finally {
setSaving(false)
}
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError('')
const payload = addresses.filter(isCompleteAddress)
if (payload.length === 0) {
setError('Add at least one complete address.')
return
}
setSaving(true)
try {
const saved: AddressDraft[] = []
for (const item of payload) {
const input: UserAddressInput = {
province: item.province.trim(),
city: item.city.trim(),
address: item.address.trim(),
postalCode: item.postalCode.trim() || undefined,
landline: item.landline?.trim() || undefined,
}
if (item.id) {
const result = await updateAddress(item.id, input)
saved.push(toDraft(result.address, provinces, citiesByProvince))
} else {
const result = await createAddress(input)
saved.push(toDraft(result.address, provinces, citiesByProvince))
}
function handleSaved(saved: UserAddress) {
setAddresses((prev) => {
const index = prev.findIndex((item) => item.id === saved.id)
if (index >= 0) {
const next = [...prev]
next[index] = saved
return next
}
return [saved, ...prev]
})
}
setAddresses(saved.length > 0 ? saved : [createEmptyAddressItem()])
showToast('Addresses saved.', 'success')
async function handleRemove(item: UserAddress) {
setRemovingId(item.id)
setError('')
try {
await removeAddress(item.id)
setAddresses((prev) => prev.filter((row) => row.id !== item.id))
showToast(t('addresses.toast.removed'), 'success')
} catch (err) {
const message =
err instanceof ApiError ? err.message : 'Unable to save addresses. Please try again.'
const message = err instanceof ApiError ? err.message : t('addresses.error.remove')
setError(message)
showToast(message, 'error')
} finally {
setSaving(false)
setRemovingId(null)
}
}
const hasAddresses = useMemo(
() => addresses.some((item) => isCompleteAddress(item)),
[addresses],
)
function formatRowLine(item: UserAddress) {
return [
item.label || null,
item.province,
item.city,
item.address,
item.postalCode,
item.landline,
]
.filter(Boolean)
.join(' · ')
}
return (
<main className={pageStyles.content}>
<Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: 'My Addresses' }]} />
<Breadcrumbs
items={[{ label: t('nav.home'), href: '/' }, { label: t('addresses.title') }]}
/>
<div className={pageStyles.pageHeader}>
<div>
<h2 className={pageStyles.pageTitle}>My Addresses</h2>
<p className={pageStyles.pageSubtitle}>
Manage your shipping addresses for orders at this store.
</p>
<h2 className={pageStyles.pageTitle}>{t('addresses.title')}</h2>
<p className={pageStyles.pageSubtitle}>{t('addresses.subtitle')}</p>
</div>
</div>
<form className={styles.form} onSubmit={handleSubmit}>
{error && (
<div className={styles.error} role="alert">
{error}
{error && (
<div className={styles.error} role="alert">
{error}
</div>
)}
<section className={styles.section}>
<h3 className={styles.sectionTitle}>{t('addresses.editorTitle')}</h3>
{loading && <p className={styles.status}>{t('addresses.loading')}</p>}
{!loading && addresses.length === 0 && (
<div className={styles.empty}>
<MapPin size={28} />
<p>{t('addresses.empty')}</p>
</div>
)}
<section className={styles.section}>
<AddressListEditor
addresses={addresses}
provinces={provinces}
citiesByProvince={citiesByProvince}
onAddressChange={updateAddressDraft}
onProvinceChange={handleProvinceChange}
onAdd={addAddressRow}
onRemove={(index) => void handleRemove(index)}
disabled={saving}
loading={loading}
/>
</section>
{!loading && addresses.length > 0 && (
<ul className={styles.list}>
{addresses.map((item) => (
<li key={item.id} className={styles.row}>
<div className={styles.rowLine} title={formatRowLine(item)}>
{item.label && <span className={styles.rowLabel}>{item.label}</span>}
<span className={styles.rowText}>
{[
item.province,
item.city,
item.address,
item.postalCode,
item.landline,
]
.filter(Boolean)
.join(' · ')}
</span>
</div>
<div className={styles.actions}>
<button type="submit" className={styles.saveBtn} disabled={saving || loading || !hasAddresses}>
{saving ? 'Saving...' : 'Save addresses'}
</button>
</div>
</form>
<div className={styles.rowActions}>
<Tooltip label={t('addresses.edit')}>
<button
type="button"
className={styles.editBtn}
onClick={() => openEditModal(item)}
aria-label={t('addresses.edit')}
>
<Pencil size={15} />
</button>
</Tooltip>
<Tooltip label={t('addresses.remove')}>
<button
type="button"
className={rowBtnStyles.removeRowBtn}
onClick={() => void handleRemove(item)}
disabled={removingId === item.id}
aria-label={t('addresses.remove')}
>
<Trash2 size={15} />
</button>
</Tooltip>
</div>
</li>
))}
</ul>
)}
</section>
<button
type="button"
className={styles.addFab}
onClick={openCreateModal}
aria-label={t('addresses.add')}
>
<Plus size={26} strokeWidth={2.5} />
</button>
<AddressFormModal
open={modalOpen}
address={editingAddress}
onClose={() => {
setModalOpen(false)
setEditingAddress(null)
}}
onSaved={handleSaved}
/>
</main>
)
}
+14 -10
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { Heart } from 'lucide-react'
import { Breadcrumbs, Pagination, useToast } from '@meshkee/dashboard-ui'
import { FavoriteStoreItemCard } from '../components/FavoriteStoreItemCard'
import { useT } from '../i18n/useT'
import { ApiError, isAbortError } from '../lib/api'
import {
listFavorites,
@@ -16,6 +17,7 @@ const PAGE_SIZE = 24
export function FavoritesPage() {
const { showToast } = useToast()
const t = useT()
const [data, setData] = useState<FavoritesListResponse | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
@@ -35,7 +37,7 @@ export function FavoritesPage() {
setData(response)
} catch (err) {
if (isAbortError(err) || controller.signal.aborted) return
setError(err instanceof ApiError ? err.message : 'Unable to load favorites.')
setError(err instanceof ApiError ? err.message : t('favorites.error.load'))
} finally {
if (!controller.signal.aborted) setLoading(false)
}
@@ -43,7 +45,7 @@ export function FavoritesPage() {
void load()
return () => controller.abort()
}, [page])
}, [page, t])
async function handleRemove(listing: FavoriteListing) {
setRemovingId(listing.favoriteId)
@@ -58,10 +60,10 @@ export function FavoritesPage() {
}
: prev,
)
showToast('Removed from favorites.', 'success')
showToast(t('favorites.toast.removed'), 'success')
} catch (err) {
const message =
err instanceof ApiError ? err.message : 'Unable to remove favorite.'
err instanceof ApiError ? err.message : t('favorites.error.remove')
showToast(message, 'error')
} finally {
setRemovingId(null)
@@ -69,17 +71,19 @@ export function FavoritesPage() {
}
function handleAddToCart() {
showToast('Shopping cart is coming soon.', 'success')
showToast(t('favorites.cartSoon'), 'success')
}
return (
<main className={pageStyles.content}>
<Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: 'My Favorites' }]} />
<Breadcrumbs
items={[{ label: t('nav.home'), href: '/' }, { label: t('favorites.title') }]}
/>
<div className={pageStyles.pageHeader}>
<div>
<h2 className={pageStyles.pageTitle}>My Favorites</h2>
<p className={pageStyles.pageSubtitle}>Products you have saved for later.</p>
<h2 className={pageStyles.pageTitle}>{t('favorites.title')}</h2>
<p className={pageStyles.pageSubtitle}>{t('favorites.subtitle')}</p>
</div>
</div>
@@ -89,12 +93,12 @@ export function FavoritesPage() {
</div>
)}
{loading && <p className={styles.status}>Loading favorites...</p>}
{loading && <p className={styles.status}>{t('favorites.loading')}</p>}
{!loading && data?.items.length === 0 && (
<div className={styles.empty}>
<Heart size={32} />
<p>No favorites yet.</p>
<p>{t('favorites.empty')}</p>
</div>
)}
+47 -44
View File
@@ -1,73 +1,76 @@
import { CalendarDays, User, MapPin, ShoppingBag, Heart } from 'lucide-react'
import { SectionCard, useLocale } from '@meshkee/dashboard-ui'
import { useAuth } from '../context/AuthContext'
import { SectionCard } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import styles from '../components/PageContent.module.css'
const sections = [
{
icon: User,
title: 'My Profile',
description: 'View and update your personal information and contact details.',
linkText: 'View profile',
href: '/profile',
},
{
icon: MapPin,
title: 'My Addresses',
description: 'Manage your shipping addresses for checkout and deliveries.',
linkText: 'View addresses',
href: '/addresses',
},
{
icon: ShoppingBag,
title: 'My Orders',
description: 'Track your orders, view order history and order details.',
linkText: 'View orders',
href: '/orders',
},
{
icon: Heart,
title: 'My Favorites',
description: 'Browse and manage your saved favorite products.',
linkText: 'View favorites',
href: '/favorites',
},
]
export function HomePage() {
const { user } = useAuth()
const { locale } = useLocale()
const t = useT()
const firstName =
(locale === 'en' ? user?.firstNameEn : user?.firstName) ||
user?.firstName ||
user?.firstNameEn ||
t('home.welcomeFallback')
function getFormattedDate() {
return new Intl.DateTimeFormat('en-US', {
const sections = [
{
icon: User,
title: t('home.card.profile.title'),
description: t('home.card.profile.desc'),
linkText: t('home.card.profile.link'),
href: '/profile',
},
{
icon: MapPin,
title: t('home.card.addresses.title'),
description: t('home.card.addresses.desc'),
linkText: t('home.card.addresses.link'),
href: '/addresses',
},
{
icon: ShoppingBag,
title: t('home.card.orders.title'),
description: t('home.card.orders.desc'),
linkText: t('home.card.orders.link'),
href: '/orders',
},
{
icon: Heart,
title: t('home.card.favorites.title'),
description: t('home.card.favorites.desc'),
linkText: t('home.card.favorites.link'),
href: '/favorites',
},
]
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>
{t('home.welcome', { name: firstName })}
</h2>
<p className={styles.pageSubtitle}>
Manage your profile, addresses, orders, and favorites in one place.
</p>
<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} {...section} />
))}
</div>
</main>
+9 -4
View File
@@ -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;
+68 -68
View File
@@ -16,6 +16,8 @@ import {
sendOtp,
verifyOtp,
} from '../services/authService'
import { LanguageSelect } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import meshkeeLogo from '../assets/meshkee-logo.png'
import styles from './LoginPage.module.css'
@@ -36,6 +38,7 @@ export function LoginPage() {
const { login } = useAuth()
const { businessName, logoUrl } = useTenantBranding()
const tenantDomain = getTenantDomain()
const t = useT()
const [view, setView] = useState<AuthView>('login')
const [smsStep, setSmsStep] = useState<SmsStep>('phone')
@@ -114,7 +117,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)
}
@@ -130,7 +133,7 @@ export function LoginPage() {
await login(cellNumber, password)
navigate(redirectTo)
} catch (err) {
handleApiError(err, 'Unable to sign in. Check your connection and try again.')
handleApiError(err, t('login.error.signIn'))
} finally {
setIsSubmitting(false)
}
@@ -141,12 +144,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
}
@@ -180,7 +183,7 @@ export function LoginPage() {
await login(cellNumber, password)
navigate(redirectTo)
} catch (err) {
handleApiError(err, 'Unable to create account.')
handleApiError(err, t('signup.error.create'))
} finally {
setIsSubmitting(false)
}
@@ -191,7 +194,7 @@ export function LoginPage() {
clearMessages()
if (newPassword.length < 8) {
setError('Password must be at least 8 characters.')
setError(t('forgot.error.length'))
return
}
@@ -200,12 +203,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 support 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)
}
@@ -221,14 +222,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(redirectTo)
} catch (err) {
handleApiError(err, 'Unable to sign in with SMS verification.')
handleApiError(err, t('otp.error.signIn'))
} finally {
setIsSubmitting(false)
}
@@ -241,14 +242,17 @@ export function LoginPage() {
<img src={logoUrl || meshkeeLogo} alt="" className={styles.logo} />
<div className={styles.brandText}>
<span className={styles.domain}>{businessName || tenantDomain}</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 && (
@@ -259,7 +263,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
@@ -276,13 +280,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
@@ -293,7 +297,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} />}
@@ -308,17 +312,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
@@ -328,18 +332,18 @@ export function LoginPage() {
disabled={isSubmitting}
>
<KeyRound size={18} />
One-time login with SMS
{t('login.otp')}
</button>
<p className={styles.footerText}>
Don&apos;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>
</>
@@ -347,8 +351,8 @@ export function LoginPage() {
{view === 'signup' && (
<>
<h1 className={styles.title}>Create account</h1>
<p className={styles.subtitle}>Register as a customer of {tenantDomain}</p>
<h1 className={styles.title}>{t('signup.title')}</h1>
<p className={styles.subtitle}>{t('signup.subtitle', { domain: tenantDomain })}</p>
<form className={styles.form} onSubmit={handleSignup}>
{error && (
@@ -360,13 +364,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
@@ -376,13 +380,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
@@ -394,7 +398,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
@@ -411,13 +415,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
@@ -428,7 +432,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} />}
@@ -437,13 +441,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
@@ -454,19 +458,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>
</>
@@ -481,14 +485,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}>
@@ -502,7 +504,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
@@ -524,19 +526,19 @@ 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>
{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
@@ -554,13 +556,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
@@ -572,7 +574,7 @@ 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"
@@ -580,13 +582,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>
</>
)}
@@ -603,14 +605,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'
? 'Verify your mobile number 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}>
@@ -623,7 +623,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
@@ -645,19 +645,19 @@ 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>
{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
@@ -675,13 +675,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
@@ -693,7 +693,7 @@ 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"
@@ -701,13 +701,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('login.signingIn') : t('login.signIn')}
</button>
</>
)}
@@ -71,7 +71,7 @@
.th,
.td {
padding: 10px 12px;
text-align: left;
text-align: start;
border-bottom: 1px solid rgba(148, 163, 184, 0.16);
vertical-align: middle;
}
@@ -217,21 +217,22 @@
}
.thActions {
text-align: center;
text-align: end;
white-space: nowrap;
}
.tdActions {
text-align: right;
text-align: end;
white-space: nowrap;
padding-right: 10px;
padding-inline: 10px;
}
.rowActions {
display: inline-flex;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0;
width: 100%;
}
.actionBtn {
+35 -23
View File
@@ -1,7 +1,8 @@
import { useEffect, useMemo, useState } from 'react'
import { Breadcrumbs, Pagination } from '@meshkee/dashboard-ui'
import { Breadcrumbs, Pagination, useLocale } from '@meshkee/dashboard-ui'
import { OrderItemsModal } from '../components/OrderItemsModal'
import { OrderRow } from '../components/OrderRow'
import { useT } from '../i18n/useT'
import { ApiError, isAbortError } from '../lib/api'
import {
listOrders,
@@ -16,12 +17,16 @@ const PAGE_SIZE = 20
const COLUMN_COUNT = 7
export function OrdersPage() {
const t = useT()
const { locale } = useLocale()
const [data, setData] = useState<OrdersListResponse | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [page, setPage] = useState(1)
const [viewOrder, setViewOrder] = useState<Order | null>(null)
const processSteps = DEFAULT_ORDER_PROCESS_STEPS
useEffect(() => {
const controller = new AbortController()
@@ -35,7 +40,7 @@ export function OrdersPage() {
setData(response)
} catch (err) {
if (isAbortError(err) || controller.signal.aborted) return
setError(err instanceof ApiError ? err.message : 'Unable to load orders.')
setError(err instanceof ApiError ? err.message : t('orders.error.load'))
} finally {
if (!controller.signal.aborted) setLoading(false)
}
@@ -43,7 +48,7 @@ export function OrdersPage() {
void load()
return () => controller.abort()
}, [page])
}, [page, t])
const totalPages = useMemo(() => {
const total = data?.total ?? 0
@@ -62,27 +67,29 @@ export function OrdersPage() {
return (
<main className={pageStyles.content}>
<Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: 'My Orders' }]} />
<Breadcrumbs items={[{ label: t('nav.home'), href: '/' }, { label: t('orders.title') }]} />
<div className={pageStyles.pageHeader}>
<div>
<h2 className={pageStyles.pageTitle}>My Orders</h2>
<p className={pageStyles.pageSubtitle}>View your order history and details.</p>
<h2 className={pageStyles.pageTitle}>{t('orders.title')}</h2>
<p className={pageStyles.pageSubtitle}>{t('orders.subtitle')}</p>
</div>
</div>
<div className={styles.tablePanel}>
<div className={styles.tableWrap}>
<div className={styles.tableHeader}>
<div className={styles.tableHeaderTitle}>Order list</div>
<div className={styles.tableHeaderTitle}>{t('orders.listTitle')}</div>
<div className={styles.meta}>
{data ? (
data.total > 0 ? (
<>
Showing {showingFrom} - {showingTo} of {data.total}
</>
t('orders.showing', {
from: showingFrom,
to: showingTo,
total: data.total,
})
) : (
'No orders'
t('orders.none')
)
) : (
' '
@@ -92,7 +99,7 @@ export function OrdersPage() {
{error && <div className={styles.errorBanner}>{error}</div>}
<table className={styles.table}>
<table className={styles.table} dir={locale === 'fa' ? 'rtl' : 'ltr'}>
<colgroup>
<col className={styles.colOrderId} />
<col className={styles.colItems} />
@@ -104,20 +111,20 @@ export function OrdersPage() {
</colgroup>
<thead>
<tr>
<th className={styles.th}>Order ID</th>
<th className={styles.th}>Items</th>
<th className={styles.th}>Total cost</th>
<th className={styles.th}>Date &amp; time</th>
<th className={styles.th}>Step</th>
<th className={styles.th}>Registered by</th>
<th className={`${styles.th} ${styles.thActions}`}>Actions</th>
<th className={styles.th}>{t('orders.col.orderId')}</th>
<th className={styles.th}>{t('orders.col.items')}</th>
<th className={styles.th}>{t('orders.col.total')}</th>
<th className={styles.th}>{t('orders.col.date')}</th>
<th className={styles.th}>{t('orders.col.step')}</th>
<th className={styles.th}>{t('orders.col.source')}</th>
<th className={`${styles.th} ${styles.thActions}`}>{t('orders.col.actions')}</th>
</tr>
</thead>
<tbody>
{loading && (
<tr>
<td className={styles.td} colSpan={COLUMN_COUNT}>
Loading orders...
{t('orders.loading')}
</td>
</tr>
)}
@@ -125,7 +132,7 @@ export function OrdersPage() {
{!loading && data?.items.length === 0 && (
<tr>
<td className={styles.td} colSpan={COLUMN_COUNT}>
You have no orders yet.
{t('orders.empty')}
</td>
</tr>
)}
@@ -135,7 +142,7 @@ export function OrdersPage() {
<OrderRow
key={order.id}
order={order}
processSteps={DEFAULT_ORDER_PROCESS_STEPS}
processSteps={processSteps}
onViewItems={setViewOrder}
/>
))}
@@ -145,7 +152,12 @@ export function OrdersPage() {
{data && data.total > PAGE_SIZE && (
<div className={styles.pagination}>
<div>
Page {page} / {totalPages} · {PAGE_SIZE} per page · {data.total} total
{t('orders.pageMeta', {
page,
totalPages,
pageSize: PAGE_SIZE,
total: data.total,
})}
</div>
<Pagination
currentPage={page}
+57 -24
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'
import { Breadcrumbs } from '@meshkee/dashboard-ui'
import { Breadcrumbs, useToast } from '@meshkee/dashboard-ui'
import { useAuth } from '../context/AuthContext'
import { useToast } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api'
import { formatCellForDisplay } from '../lib/cellNumber'
import { updateProfile } from '../services/authService'
@@ -11,9 +11,12 @@ import styles from './ProfilePage.module.css'
export function ProfilePage() {
const { user, setUser } = useAuth()
const { showToast } = useToast()
const t = useT()
const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('')
const [firstNameEn, setFirstNameEn] = useState('')
const [lastNameEn, setLastNameEn] = useState('')
const [email, setEmail] = useState('')
const [landline, setLandline] = useState('')
const [backupPhone, setBackupPhone] = useState('')
@@ -29,6 +32,8 @@ export function ProfilePage() {
setFirstName(user.firstName ?? '')
setLastName(user.lastName ?? '')
setFirstNameEn(user.firstNameEn ?? '')
setLastNameEn(user.lastNameEn ?? '')
setEmail(user.email ?? '')
setLandline(user.profile.landline ?? '')
setBackupPhone(user.profile.backupPhone ?? '')
@@ -47,6 +52,8 @@ export function ProfilePage() {
const result = await updateProfile({
firstName: firstName.trim(),
lastName: lastName.trim(),
firstNameEn: firstNameEn.trim(),
lastNameEn: lastNameEn.trim(),
email: email.trim() || undefined,
landline: landline.trim(),
backupPhone: backupPhone.trim(),
@@ -57,10 +64,10 @@ export function ProfilePage() {
})
setUser(result.user)
showToast('Profile updated successfully.', 'success')
showToast(t('profile.toast.success'), 'success')
} catch (err) {
const message =
err instanceof ApiError ? err.message : 'Unable to update profile. Please try again.'
err instanceof ApiError ? err.message : t('profile.error.update')
setError(message)
showToast(message, 'error')
} finally {
@@ -70,14 +77,12 @@ export function ProfilePage() {
return (
<main className={pageStyles.content}>
<Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: 'My Profile' }]} />
<Breadcrumbs items={[{ label: t('nav.home'), href: '/' }, { label: t('profile.title') }]} />
<div className={pageStyles.pageHeader}>
<div>
<h2 className={pageStyles.pageTitle}>My Profile</h2>
<p className={pageStyles.pageSubtitle}>
Update your personal information and contact details.
</p>
<h2 className={pageStyles.pageTitle}>{t('profile.title')}</h2>
<p className={pageStyles.pageSubtitle}>{t('profile.subtitle')}</p>
</div>
</div>
@@ -89,10 +94,10 @@ export function ProfilePage() {
)}
<section className={styles.section}>
<h3 className={styles.sectionTitle}>Account</h3>
<h3 className={styles.sectionTitle}>{t('profile.section.account')}</h3>
<div className={styles.grid}>
<div className={`${styles.field} ${styles.col3}`}>
<label htmlFor="profile-cell">Mobile number</label>
<label htmlFor="profile-cell">{t('profile.mobile')}</label>
<input
id="profile-cell"
type="text"
@@ -101,37 +106,65 @@ export function ProfilePage() {
/>
</div>
<div className={`${styles.field} ${styles.col3}`}>
<label htmlFor="profile-email">Email</label>
<label htmlFor="profile-email">{t('profile.email')}</label>
<input
id="profile-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
placeholder={t('profile.emailPlaceholder')}
/>
</div>
<div className={`${styles.field} ${styles.col3}`}>
<label htmlFor="profile-first">First name</label>
<label htmlFor="profile-first">{t('profile.firstName')}</label>
<input
id="profile-first"
type="text"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
required
dir="rtl"
lang="fa"
className="faText"
/>
</div>
<div className={`${styles.field} ${styles.col3}`}>
<label htmlFor="profile-last">Last name</label>
<label htmlFor="profile-last">{t('profile.lastName')}</label>
<input
id="profile-last"
type="text"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
required
dir="rtl"
lang="fa"
className="faText"
/>
</div>
<div className={`${styles.field} ${styles.col3}`}>
<label htmlFor="profile-landline">Landline</label>
<label htmlFor="profile-first-en">{t('profile.firstNameEn')}</label>
<input
id="profile-first-en"
type="text"
value={firstNameEn}
onChange={(e) => setFirstNameEn(e.target.value)}
dir="ltr"
lang="en"
/>
</div>
<div className={`${styles.field} ${styles.col3}`}>
<label htmlFor="profile-last-en">{t('profile.lastNameEn')}</label>
<input
id="profile-last-en"
type="text"
value={lastNameEn}
onChange={(e) => setLastNameEn(e.target.value)}
dir="ltr"
lang="en"
/>
</div>
<div className={`${styles.field} ${styles.col3}`}>
<label htmlFor="profile-landline">{t('profile.landline')}</label>
<input
id="profile-landline"
type="text"
@@ -141,7 +174,7 @@ export function ProfilePage() {
/>
</div>
<div className={`${styles.field} ${styles.col3}`}>
<label htmlFor="profile-backup-phone">Backup phone number</label>
<label htmlFor="profile-backup-phone">{t('profile.backupPhone')}</label>
<input
id="profile-backup-phone"
type="tel"
@@ -155,10 +188,10 @@ export function ProfilePage() {
</section>
<section className={styles.section}>
<h3 className={styles.sectionTitle}>About</h3>
<h3 className={styles.sectionTitle}>{t('profile.section.about')}</h3>
<div className={styles.grid}>
<div className={`${styles.field} ${styles.col12}`}>
<label htmlFor="profile-about">About</label>
<label htmlFor="profile-about">{t('profile.about')}</label>
<textarea
id="profile-about"
value={about}
@@ -170,10 +203,10 @@ export function ProfilePage() {
</section>
<section className={styles.section}>
<h3 className={styles.sectionTitle}>Social</h3>
<h3 className={styles.sectionTitle}>{t('profile.section.social')}</h3>
<div className={styles.grid}>
<div className={`${styles.field} ${styles.col4}`}>
<label htmlFor="profile-instagram">Instagram</label>
<label htmlFor="profile-instagram">{t('profile.instagram')}</label>
<input
id="profile-instagram"
type="text"
@@ -182,7 +215,7 @@ export function ProfilePage() {
/>
</div>
<div className={`${styles.field} ${styles.col4}`}>
<label htmlFor="profile-telegram">Telegram</label>
<label htmlFor="profile-telegram">{t('profile.telegram')}</label>
<input
id="profile-telegram"
type="text"
@@ -191,7 +224,7 @@ export function ProfilePage() {
/>
</div>
<div className={`${styles.field} ${styles.col4}`}>
<label htmlFor="profile-linkedin">LinkedIn</label>
<label htmlFor="profile-linkedin">{t('profile.linkedin')}</label>
<input
id="profile-linkedin"
type="text"
@@ -204,7 +237,7 @@ export function ProfilePage() {
<div className={styles.actions}>
<button type="submit" className={styles.saveBtn} disabled={isSaving}>
{isSaving ? 'Saving...' : 'Save changes'}
{isSaving ? t('profile.saving') : t('profile.save')}
</button>
</div>
</form>
@@ -45,6 +45,8 @@ export async function updateProfile(
payload: Partial<UserProfile> & {
firstName?: string
lastName?: string
firstNameEn?: string
lastNameEn?: string
email?: string
},
) {
@@ -45,6 +45,7 @@ export interface Order {
status: OrderStatus
processStepId: string
processStepLabel?: string | null
processStepLabelFa?: string | null
processStepColor?: string | null
source: OrderSource
subtotal: number
@@ -1,4 +1,5 @@
import { apiRequest } from '../lib/api'
import type { DashboardLocale } from '@meshkee/dashboard-core'
import type { BusinessPrimaryColorId } from '../utils/businessPrimaryColors'
export interface ResolvedTenant {
@@ -8,6 +9,7 @@ export interface ResolvedTenant {
slug: string
domain: string
primaryColor: BusinessPrimaryColorId
defaultLocale?: DashboardLocale
logoUrl?: string | null
faviconUrl?: string | null
}
@@ -11,6 +11,10 @@ const CSS_VAR_DEFAULTS: Record<string, string> = {
'--primary-dark': '#2563eb',
'--primary-rgb': '59 130 246',
'--primary-dark-rgb': '37 99 235',
'--chart-accent': '#06b6d4',
'--chart-accent-dark': '#0891b2',
'--chart-accent-rgb': '6 182 212',
'--chart-accent-dark-rgb': '8 145 178',
}
export function applyBusinessPrimaryColor(colorId?: BusinessPrimaryColorId | null) {
@@ -23,6 +27,10 @@ export function applyBusinessPrimaryColor(colorId?: BusinessPrimaryColorId | nul
root.style.setProperty('--primary-dark', tokens.primaryDark)
root.style.setProperty('--primary-rgb', tokens.primaryRgb)
root.style.setProperty('--primary-dark-rgb', tokens.primaryDarkRgb)
root.style.setProperty('--chart-accent', tokens.chartAccent)
root.style.setProperty('--chart-accent-dark', tokens.chartAccentDark)
root.style.setProperty('--chart-accent-rgb', tokens.chartAccentRgb)
root.style.setProperty('--chart-accent-dark-rgb', tokens.chartAccentDarkRgb)
}
export function resetBusinessPrimaryColor() {
@@ -20,8 +20,27 @@ export type BusinessPrimaryColorTokens = {
primaryGlow: string
primaryRgb: string
primaryDarkRgb: string
/** Second chart series color (red→purple, blue→cyan, …). */
chartAccent: string
chartAccentDark: string
chartAccentRgb: string
chartAccentDarkRgb: string
}
const PURPLE_ACCENT = {
chartAccent: '#a855f7',
chartAccentDark: '#9333ea',
chartAccentRgb: '168 85 247',
chartAccentDarkRgb: '147 51 234',
} as const
const CYAN_ACCENT = {
chartAccent: '#06b6d4',
chartAccentDark: '#0891b2',
chartAccentRgb: '6 182 212',
chartAccentDarkRgb: '8 145 178',
} as const
export const BUSINESS_PRIMARY_COLOR_PALETTE: Record<
BusinessPrimaryColorId,
BusinessPrimaryColorTokens
@@ -34,6 +53,7 @@ export const BUSINESS_PRIMARY_COLOR_PALETTE: Record<
primaryGlow: '#ef4444',
primaryRgb: '239 68 68',
primaryDarkRgb: '220 38 38',
...PURPLE_ACCENT,
},
yellow: {
label: 'Yellow',
@@ -43,6 +63,7 @@ export const BUSINESS_PRIMARY_COLOR_PALETTE: Record<
primaryGlow: '#eab308',
primaryRgb: '234 179 8',
primaryDarkRgb: '202 138 4',
...PURPLE_ACCENT,
},
black: {
label: 'Black',
@@ -52,6 +73,7 @@ export const BUSINESS_PRIMARY_COLOR_PALETTE: Record<
primaryGlow: '#334155',
primaryRgb: '30 41 59',
primaryDarkRgb: '15 23 42',
...CYAN_ACCENT,
},
cyan: {
label: 'Cyan',
@@ -61,6 +83,7 @@ export const BUSINESS_PRIMARY_COLOR_PALETTE: Record<
primaryGlow: '#06b6d4',
primaryRgb: '6 182 212',
primaryDarkRgb: '8 145 178',
...PURPLE_ACCENT,
},
purple: {
label: 'Purple',
@@ -70,6 +93,7 @@ export const BUSINESS_PRIMARY_COLOR_PALETTE: Record<
primaryGlow: '#a855f7',
primaryRgb: '168 85 247',
primaryDarkRgb: '147 51 234',
...CYAN_ACCENT,
},
'light-blue': {
label: 'Light Blue',
@@ -79,6 +103,7 @@ export const BUSINESS_PRIMARY_COLOR_PALETTE: Record<
primaryGlow: '#38bdf8',
primaryRgb: '56 189 248',
primaryDarkRgb: '14 165 233',
...CYAN_ACCENT,
},
'dark-blue': {
label: 'Dark Blue',
@@ -88,6 +113,7 @@ export const BUSINESS_PRIMARY_COLOR_PALETTE: Record<
primaryGlow: '#3b82f6',
primaryRgb: '59 130 246',
primaryDarkRgb: '37 99 235',
...CYAN_ACCENT,
},
}
+60 -6
View File
@@ -1,23 +1,74 @@
export interface OrderProcessStep {
id: string
label: string
labelFa: string
color: string
}
export const DEFAULT_ORDER_PROCESS_STEPS: OrderProcessStep[] = [
{ id: 'processing', label: 'Under processing', color: '#3B82F6' },
{ id: 'ready-for-shipping', label: 'Ready for shipping', color: '#F59E0B' },
{ id: 'shipped', label: 'Shipped', color: '#8B5CF6' },
{ id: 'delivered', label: 'Delivered', color: '#22C55E' },
{
id: 'processing',
label: 'Under processing',
labelFa: 'در حال پردازش',
color: '#3B82F6',
},
{
id: 'ready-for-shipping',
label: 'Ready for shipping',
labelFa: 'آماده ارسال',
color: '#F59E0B',
},
{
id: 'shipped',
label: 'Shipped',
labelFa: 'ارسال‌شده',
color: '#8B5CF6',
},
{
id: 'delivered',
label: 'Delivered',
labelFa: 'تحویل‌شده',
color: '#22C55E',
},
]
function defaultFaForLabel(label: string | null | undefined) {
const trimmed = label?.trim()
if (!trimmed) return undefined
return DEFAULT_ORDER_PROCESS_STEPS.find(
(step) => step.label.toLowerCase() === trimmed.toLowerCase(),
)?.labelFa
}
export function stepLabel(
steps: OrderProcessStep[],
processStepId: string,
processStepLabel?: string | null,
processStepLabelFa?: string | null,
locale: 'en' | 'fa' = 'en',
) {
const step =
steps.find((item) => item.id === processStepId) ??
DEFAULT_ORDER_PROCESS_STEPS.find((item) => item.id === processStepId)
if (locale === 'fa') {
let fa = processStepLabelFa?.trim() || step?.labelFa?.trim()
// Older API payloads sometimes echoed the English label as labelFa.
if (fa && processStepLabel?.trim() && fa === processStepLabel.trim()) {
fa = undefined
}
fa =
fa ||
defaultFaForLabel(processStepLabel) ||
defaultFaForLabel(step?.label)
if (fa) return fa
}
if (processStepLabel?.trim()) return processStepLabel.trim()
return steps.find((step) => step.id === processStepId)?.label ?? processStepId
return step?.label ?? processStepId
}
export function stepColor(
@@ -28,7 +79,10 @@ export function stepColor(
if (processStepColor?.trim()) return processStepColor.trim()
const index = steps.findIndex((step) => step.id === processStepId)
const step = index >= 0 ? steps[index] : steps[0]
const step =
(index >= 0 ? steps[index] : undefined) ??
DEFAULT_ORDER_PROCESS_STEPS.find((item) => item.id === processStepId) ??
steps[0]
if (step?.color) return step.color
@@ -1,3 +1,6 @@
export function formatVariantCount(count: number): string {
export function formatVariantCount(count: number, locale: 'en' | 'fa' = 'en'): string {
if (locale === 'fa') {
return count === 1 ? '۱ تنوع' : `${count} تنوع`
}
return count === 1 ? '1 variant' : `${count} variants`
}