Add FA/EN locale, home activity charts, and theme-aware polish.
Ship shared LocaleProvider, business/customer i18n, branding defaultLocale, curated home tiles with dual 30-day charts, and chart/page aura tokens; refresh PROJECT_CONTEXT. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
f5b2193ba1
commit
66004a0fba
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'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 {
|
||||
|
||||
@@ -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 & 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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user