Improve customer orders: own list, payment/order status, and transaction details.

Also widen order item modals, link FA product names, and polish related dashboard UI.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-10-02 23:00:48 +03:30
co-authored by Cursor
parent 55071af98c
commit 06589600bd
24 changed files with 911 additions and 70 deletions
@@ -1,5 +1,6 @@
.modalWide {
max-width: 720px;
max-width: 960px;
width: min(960px, calc(100vw - 32px));
}
.body {
@@ -42,7 +43,7 @@
.itemRow {
display: grid;
grid-template-columns: 64px minmax(0, 1fr) 72px 120px 120px;
grid-template-columns: 64px minmax(220px, 1.6fr) 64px 110px 110px;
align-items: center;
gap: 10px;
padding: 10px 12px;
@@ -105,7 +106,7 @@
.tableHead {
display: grid;
grid-template-columns: 64px minmax(0, 1fr) 72px 120px 120px;
grid-template-columns: 64px minmax(220px, 1.6fr) 64px 110px 110px;
gap: 10px;
padding: 0 12px 2px;
font-size: 11px;
@@ -176,10 +177,10 @@
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px;
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: var(--radius-sm);
background: var(--surface);
padding: 8px 0;
border: none;
border-radius: 0;
background: transparent;
}
.shippingGroupHeader {
@@ -218,6 +219,19 @@
color: var(--text-secondary);
}
.itemTitleLink {
display: inline;
font-size: 13px;
font-weight: 600;
color: var(--primary);
line-height: 1.3;
text-decoration: none;
}
.itemTitleLink:hover {
text-decoration: underline;
}
.totalsBlock {
display: flex;
flex-direction: column;
@@ -5,6 +5,7 @@ import { useT } from '../i18n/useT'
import type { Order } from '../services/orderService'
import { formatCellForDisplay } from '../lib/cellNumber'
import { formatIrtPrice } from '../utils/irtPrice'
import { buildWebsiteContentUrl } from '../utils/websiteLinks'
import modalStyles from './VariationsModal.module.css'
import styles from './OrderItemsModal.module.css'
@@ -181,7 +182,7 @@ export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps)
{group.methodLabel ? (
<div className={styles.shippingMethod}>{group.methodLabel}</div>
) : null}
{group.itemNames.length > 0 ? (
{shippingInfo.groups.length > 1 && group.itemNames.length > 0 ? (
<ul className={styles.shippingItemNames}>
{group.itemNames.map((name) => (
<li key={`${group.groupId}-${name}`}>{name}</li>
@@ -206,7 +207,15 @@ export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps)
<span>{t('orderItems.lineTotal')}</span>
</div>
<ul className={styles.itemList}>
{order.items.map((item) => (
{order.items.map((item) => {
const displayTitle = item.productNameFa?.trim() || item.productTitle
const productHref = buildWebsiteContentUrl('product', {
id: item.productId,
nameFa: item.productNameFa,
title: item.productTitle,
slug: item.productSlug,
})
return (
<li key={item.id} className={styles.itemRow}>
<div className={styles.itemThumb}>
{item.productImage ? (
@@ -216,7 +225,18 @@ export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps)
)}
</div>
<div>
<div className={styles.itemTitle}>{item.productTitle}</div>
{productHref ? (
<a
className={styles.itemTitleLink}
href={productHref}
target="_blank"
rel="noopener noreferrer"
>
{displayTitle}
</a>
) : (
<div className={styles.itemTitle}>{displayTitle}</div>
)}
<div className={styles.itemVariant}>{formatVariantLabel(item.selections)}</div>
{item.variantSku && (
<div className={styles.itemSku}>
@@ -228,7 +248,8 @@ export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps)
<div className={styles.unitCell}>{formatIrtPrice(item.unitPrice)}</div>
<div className={styles.totalCell}>{formatIrtPrice(item.lineTotal)}</div>
</li>
))}
)
})}
</ul>
</div>
)}
@@ -4,6 +4,42 @@
padding: 32px;
}
@keyframes homeReveal {
from {
opacity: 0;
transform: translateY(18px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.revealItem {
opacity: 0;
animation: homeReveal 0.58s cubic-bezier(0.22, 1, 0.36, 1) forwards;
animation-delay: var(--reveal-delay, 0ms);
}
.revealItemFill {
display: flex;
min-height: 0;
}
.revealItemFill > * {
flex: 1;
width: 100%;
min-height: 100%;
}
@media (prefers-reduced-motion: reduce) {
.revealItem {
opacity: 1;
animation: none;
transform: none;
}
}
.pageHeader {
display: flex;
align-items: flex-start;
@@ -7,6 +7,7 @@
border: 1px solid var(--glass-border);
border-radius: var(--radius);
box-shadow: var(--glass-shadow);
overflow: hidden;
transition: transform 0.2s, box-shadow 0.2s;
}
@@ -45,8 +46,8 @@
.image {
width: 100%;
height: 100%;
object-fit: contain;
padding: 10px;
object-fit: cover;
display: block;
}
.imagePlaceholder {
@@ -51,6 +51,13 @@
box-shadow: 0 12px 28px rgba(var(--primary-rgb) / 0.45);
}
.addFab:disabled {
opacity: 0.45;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
@media (max-width: 768px) {
.fabDock {
inset-inline-end: 20px;
+42 -14
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useEffect, useState, type CSSProperties } from 'react'
import { CalendarDays } from 'lucide-react'
import {
ShoppingBag,
@@ -135,6 +135,14 @@ async function loadSectionCounts(signal: AbortSignal): Promise<SectionCounts> {
return counts
}
const REVEAL_STEP_MS = 75
const REVEAL_HEADER_MS = 40
const REVEAL_TILE_BASE_MS = 120
function revealStyle(delayMs: number): CSSProperties {
return { ['--reveal-delay' as string]: `${delayMs}ms` }
}
export function HomePage() {
const { user } = useAuth()
const { locale } = useLocale()
@@ -170,9 +178,15 @@ export function HomePage() {
weekday: 'long',
}).format(new Date())
const chartRevealBase =
REVEAL_TILE_BASE_MS + visibleSections.length * REVEAL_STEP_MS
return (
<main className={styles.content}>
<div className={styles.pageHeader}>
<div
className={`${styles.pageHeader} ${styles.revealItem}`}
style={revealStyle(REVEAL_HEADER_MS)}
>
<div>
<h2 className={styles.welcomeTitle}>{t('home.welcome', { name: firstName })}</h2>
<p className={styles.welcomeSubtitle}>{t('home.subtitle')}</p>
@@ -185,25 +199,39 @@ export function HomePage() {
</div>
<div className={styles.gridHome}>
{visibleSections.map((section) => (
<SectionCard
{visibleSections.map((section, index) => (
<div
key={section.href}
icon={section.icon}
title={t(section.titleKey)}
description={t(section.descKey)}
linkText={t(section.linkKey)}
href={section.href}
className={section.wide ? styles.span4 : undefined}
count={section.countKey ? counts[section.countKey] : undefined}
countLabel={section.countLabelKey ? t(section.countLabelKey) : undefined}
/>
className={[
styles.revealItem,
styles.revealItemFill,
section.wide ? styles.span4 : undefined,
]
.filter(Boolean)
.join(' ')}
style={revealStyle(REVEAL_TILE_BASE_MS + index * REVEAL_STEP_MS)}
>
<SectionCard
icon={section.icon}
title={t(section.titleKey)}
description={t(section.descKey)}
linkText={t(section.linkKey)}
href={section.href}
count={section.countKey ? counts[section.countKey] : undefined}
countLabel={section.countLabelKey ? t(section.countLabelKey) : undefined}
/>
</div>
))}
</div>
{visibleCharts.length > 0 ? (
<div className={styles.grid12}>
{visibleCharts.map((chartId, index) => (
<div key={`${chartId}-${index}`} className={styles.col6}>
<div
key={`${chartId}-${index}`}
className={`${styles.col6} ${styles.revealItem} ${styles.revealItemFill}`}
style={revealStyle(chartRevealBase + index * REVEAL_STEP_MS)}
>
<HomeChartSlot chartId={chartId} />
</div>
))}
@@ -16,6 +16,8 @@ export interface OrderItem {
storeItemVariantId: string | null
productId: string
productTitle: string
productNameFa?: string | null
productSlug?: string | null
productImage: string | null
variantSku: string | null
unitPrice: number
@@ -3,16 +3,26 @@ import { useDashboardDocumentTitle, useLocale } from '@meshkee/dashboard-ui'
import { useTenantBranding } from '../context/TenantBrandingContext'
import { getCustomerRouteTitleRules, translate } from '../i18n/messages'
function isCheckoutCartPath(pathname: string) {
const path = pathname.split('?')[0]?.split('#')[0] ?? '/'
const normalized = path === '/' ? '/' : path.replace(/\/+$/, '') || '/'
return normalized === '/checkout/cart' || normalized === '/checkout'
}
export function DashboardDocumentTitle() {
const { pathname } = useLocation()
const { businessName } = useTenantBranding()
const { locale } = useLocale()
const cartTitle = isCheckoutCartPath(pathname)
? `${businessName.trim() || 'فروشگاه'} - تکمیل سفارش`
: undefined
useDashboardDocumentTitle({
businessName,
dashboardName: translate(locale, 'app.dashboardName'),
pathname,
routeRules: getCustomerRouteTitleRules(locale),
overrideTitle: cartTitle,
})
return null
@@ -1,5 +1,6 @@
.modalWide {
max-width: 720px;
max-width: 960px;
width: min(960px, calc(100vw - 32px));
}
.body {
@@ -42,9 +43,9 @@
.itemRow {
display: grid;
grid-template-columns: 64px minmax(0, 1fr) 72px 120px 120px;
grid-template-columns: 64px minmax(220px, 1.6fr) 64px 110px 110px;
align-items: center;
gap: 10px;
gap: 12px;
padding: 10px 12px;
border-radius: var(--radius-sm);
border: 1px solid rgba(148, 163, 184, 0.18);
@@ -78,7 +79,9 @@
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
line-height: 1.3;
line-height: 1.35;
overflow-wrap: anywhere;
word-break: break-word;
}
.itemVariant {
@@ -105,8 +108,8 @@
.tableHead {
display: grid;
grid-template-columns: 64px minmax(0, 1fr) 72px 120px 120px;
gap: 10px;
grid-template-columns: 64px minmax(220px, 1.6fr) 64px 110px 110px;
gap: 12px;
padding: 0 12px 2px;
font-size: 11px;
font-weight: 700;
@@ -176,10 +179,10 @@
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px;
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: var(--radius-sm);
background: var(--surface);
padding: 8px 0;
border: none;
border-radius: 0;
background: transparent;
}
.shippingGroupHeader {
@@ -218,6 +221,21 @@
color: var(--text-secondary);
}
.itemTitleLink {
display: inline;
font-size: 13px;
font-weight: 600;
color: var(--primary);
line-height: 1.35;
text-decoration: none;
overflow-wrap: anywhere;
word-break: break-word;
}
.itemTitleLink:hover {
text-decoration: underline;
}
.totalsBlock {
display: flex;
flex-direction: column;
@@ -6,6 +6,7 @@ import { EmptyState } from './EmptyState'
import type { Order } from '../services/orderService'
import { formatCellForDisplay } from '../lib/cellNumber'
import { formatIrtPrice } from '../utils/irtPrice'
import { buildProductDetailsUrl } from '../utils/productWebsiteUrl'
import modalStyles from './VariationsModal.module.css'
import styles from './OrderItemsModal.module.css'
@@ -182,7 +183,7 @@ export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps)
{group.methodLabel ? (
<div className={styles.shippingMethod}>{group.methodLabel}</div>
) : null}
{group.itemNames.length > 0 ? (
{shippingInfo.groups.length > 1 && group.itemNames.length > 0 ? (
<ul className={styles.shippingItemNames}>
{group.itemNames.map((name) => (
<li key={`${group.groupId}-${name}`}>{name}</li>
@@ -207,7 +208,11 @@ export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps)
<span>{t('orderItems.lineTotal')}</span>
</div>
<ul className={styles.itemList}>
{order.items.map((item) => (
{order.items.map((item) => {
const displayTitle =
item.productNameFa?.trim() || item.productTitle
const productHref = buildProductDetailsUrl(item)
return (
<li key={item.id} className={styles.itemRow}>
<div className={styles.itemThumb}>
{item.productImage ? (
@@ -217,7 +222,18 @@ export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps)
)}
</div>
<div>
<div className={styles.itemTitle}>{item.productTitle}</div>
{productHref ? (
<a
className={styles.itemTitleLink}
href={productHref}
target="_blank"
rel="noopener noreferrer"
>
{displayTitle}
</a>
) : (
<div className={styles.itemTitle}>{displayTitle}</div>
)}
<div className={styles.itemVariant}>{formatVariantLabel(item.selections)}</div>
{item.variantSku && (
<div className={styles.itemSku}>
@@ -229,7 +245,8 @@ export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps)
<div className={styles.unitCell}>{formatIrtPrice(item.unitPrice)}</div>
<div className={styles.totalCell}>{formatIrtPrice(item.lineTotal)}</div>
</li>
))}
)
})}
</ul>
</div>
)}
@@ -61,6 +61,32 @@
background: rgba(139, 92, 246, 0.12);
}
.paymentBadge {
display: inline-flex;
align-items: center;
max-width: 100%;
padding: 4px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
white-space: nowrap;
}
.paymentPaid {
color: #047857;
background: rgba(16, 185, 129, 0.12);
}
.paymentUnpaid {
color: #64748b;
background: rgba(100, 116, 139, 0.14);
}
.paymentFailed {
color: #b91c1c;
background: rgba(239, 68, 68, 0.12);
}
.stepBadge {
display: inline-flex;
align-items: center;
+61 -3
View File
@@ -1,7 +1,7 @@
import { Eye } from 'lucide-react'
import { CreditCard, Eye } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import type { Order, OrderSource } from '../services/orderService'
import type { Order, OrderPaymentStatus, OrderSource } from '../services/orderService'
import type { OrderProcessStep } from '../utils/orderSteps'
import { stepLabel, stepColor } from '../utils/orderSteps'
import { formatIrtPrice } from '../utils/irtPrice'
@@ -12,6 +12,7 @@ interface OrderRowProps {
order: Order
processSteps: OrderProcessStep[]
onViewItems: (order: Order) => void
onViewTransactions?: (order: Order) => void
}
function formatDateTime(value: string, locale: 'en' | 'fa') {
@@ -28,12 +29,29 @@ function totalItemQuantity(order: Order) {
return order.items.reduce((sum, item) => sum + item.quantity, 0)
}
export function OrderRow({ order, processSteps, onViewItems }: OrderRowProps) {
function derivePaymentStatus(order: Order): OrderPaymentStatus {
if (order.paymentStatus) return order.paymentStatus
const txs = order.transactions ?? []
if (txs.some((tx) => tx.status === 'completed')) return 'paid'
if (txs.length > 0 && txs.every((tx) => tx.status === 'failed')) return 'failed'
return 'unpaid'
}
export function OrderRow({
order,
processSteps,
onViewItems,
onViewTransactions,
}: OrderRowProps) {
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'
const paymentStatus = derivePaymentStatus(order)
const showTransactions =
(paymentStatus === 'paid' || paymentStatus === 'failed') &&
Boolean(onViewTransactions)
function sourceLabel(source: OrderSource) {
switch (source) {
@@ -59,6 +77,30 @@ export function OrderRow({ order, processSteps, onViewItems }: OrderRowProps) {
}
}
function paymentStatusLabel(status: OrderPaymentStatus) {
switch (status) {
case 'paid':
return t('orders.payment.paid')
case 'failed':
return t('orders.payment.failed')
case 'unpaid':
default:
return t('orders.payment.unpaid')
}
}
function paymentStatusClass(status: OrderPaymentStatus) {
switch (status) {
case 'paid':
return styles.paymentPaid
case 'failed':
return styles.paymentFailed
case 'unpaid':
default:
return styles.paymentUnpaid
}
}
return (
<tr>
<td className={styles.td}>
@@ -72,6 +114,11 @@ export function OrderRow({ order, processSteps, onViewItems }: OrderRowProps) {
<div className={styles.dateTime}>{date}</div>
{time && <div className={styles.dateTimeSub}>{time}</div>}
</td>
<td className={styles.td}>
<span className={`${styles.paymentBadge} ${paymentStatusClass(paymentStatus)}`}>
{paymentStatusLabel(paymentStatus)}
</span>
</td>
<td className={styles.td}>
<span
className={styles.stepBadge}
@@ -104,6 +151,17 @@ export function OrderRow({ order, processSteps, onViewItems }: OrderRowProps) {
>
<Eye size={15} />
</button>
{showTransactions ? (
<button
type="button"
className={styles.actionBtn}
onClick={() => onViewTransactions?.(order)}
aria-label={t('orders.transactions')}
title={t('orders.transactions')}
>
<CreditCard size={15} />
</button>
) : null}
</div>
</td>
</tr>
@@ -0,0 +1,143 @@
.modalWide {
max-width: 720px;
}
.body {
padding-top: 8px;
display: flex;
flex-direction: column;
gap: 16px;
}
.txList {
list-style: none;
display: flex;
flex-direction: column;
gap: 8px;
max-height: 420px;
overflow-y: auto;
}
.txCard {
padding: 12px 14px;
border-radius: var(--radius-sm);
border: 1px solid rgba(148, 163, 184, 0.18);
background: var(--surface);
}
.txHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
}
.txType {
font-size: 14px;
font-weight: 700;
color: var(--text-primary);
}
.txAmount {
font-size: 14px;
font-weight: 700;
color: var(--primary);
white-space: nowrap;
}
.txMeta {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px 16px;
font-size: 12px;
color: var(--text-secondary);
}
.txMetaItem {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.txMetaLabel {
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
}
.txMetaItem strong {
color: var(--text-primary);
font-weight: 600;
word-break: break-all;
}
.txMono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
letter-spacing: 0.01em;
}
.statusBadge {
display: inline-flex;
align-items: center;
padding: 3px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
text-transform: capitalize;
}
.statusPending {
color: #b45309;
background: rgba(245, 158, 11, 0.12);
}
.statusCompleted {
color: #047857;
background: rgba(16, 185, 129, 0.12);
}
.statusFailed {
color: #b91c1c;
background: rgba(239, 68, 68, 0.12);
}
.statusRefunded {
color: #6d28d9;
background: rgba(139, 92, 246, 0.12);
}
.empty {
font-size: 13px;
color: var(--text-muted);
padding: 12px 0;
}
.summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding-top: 12px;
border-top: 1px solid rgba(148, 163, 184, 0.25);
}
.summaryLabel {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.summaryValue {
font-size: 15px;
font-weight: 700;
color: var(--primary);
}
@media (max-width: 640px) {
.txMeta {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,284 @@
import { useEffect, useMemo, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import type { CustomerMessageKey } from '../i18n/messages'
import type {
Order,
OrderTransaction,
TransactionStatus,
TransactionType,
} from '../services/orderService'
import { formatIrtPrice } from '../utils/irtPrice'
import modalStyles from './VariationsModal.module.css'
import styles from './OrderTransactionsModal.module.css'
interface OrderTransactionsModalProps {
open: boolean
order: Order | null
onClose: () => void
}
const ANIMATION_MS = 220
const TYPE_KEYS: Record<TransactionType, CustomerMessageKey> = {
pos: 'orderTransactions.type.pos',
cash: 'orderTransactions.type.cash',
transfer: 'orderTransactions.type.transfer',
e_payment_gate: 'orderTransactions.type.e_payment_gate',
}
const STATUS_KEYS: Record<TransactionStatus, CustomerMessageKey> = {
pending: 'orderTransactions.status.pending',
completed: 'orderTransactions.status.completed',
failed: 'orderTransactions.status.failed',
refunded: 'orderTransactions.status.refunded',
}
function asTransactionType(value: string): TransactionType {
if (value === 'pos' || value === 'cash' || value === 'transfer' || value === 'e_payment_gate') {
return value
}
return 'e_payment_gate'
}
function asTransactionStatus(value: string): TransactionStatus {
if (
value === 'pending' ||
value === 'completed' ||
value === 'failed' ||
value === 'refunded'
) {
return value
}
return 'pending'
}
function statusClass(status: TransactionStatus) {
switch (status) {
case 'completed':
return styles.statusCompleted
case 'failed':
return styles.statusFailed
case 'refunded':
return styles.statusRefunded
case 'pending':
default:
return styles.statusPending
}
}
function formatDateTime(value: string, locale: 'en' | 'fa') {
const d = new Date(value)
if (Number.isNaN(d.getTime())) return value
return d.toLocaleString(locale === 'fa' ? 'fa-IR' : 'en-US', {
year: 'numeric',
month: 'short',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
export function OrderTransactionsModal({ open, order, onClose }: OrderTransactionsModalProps) {
const t = useT()
const { locale } = useLocale()
const dateLocale = locale === 'fa' ? 'fa' : 'en'
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
const transactions = order?.transactions ?? []
const totalPaid = useMemo(
() => transactions.reduce((sum, tx) => sum + tx.amount, 0),
[transactions],
)
useEffect(() => {
if (open) {
setMounted(true)
setClosing(false)
} 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 onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [mounted, closing, onClose])
if (!mounted || !order) return null
function transactionDetails(transaction: OrderTransaction) {
const type = asTransactionType(String(transaction.type))
const status = asTransactionStatus(String(transaction.status))
const rows: { label: string; value: string; isStatus?: boolean; mono?: boolean }[] = [
{
label: t('orderTransactions.transactionId'),
value: transaction.id,
mono: true,
},
{
label: t('orderTransactions.status'),
value: t(STATUS_KEYS[status]),
isStatus: true,
},
{
label: t('orderTransactions.date'),
value: formatDateTime(transaction.createdAt, dateLocale),
},
]
if (transaction.paidAt) {
rows.push({
label: t('orderTransactions.paidAt'),
value: formatDateTime(transaction.paidAt, dateLocale),
})
}
if (type === 'pos' && transaction.posType) {
rows.push({ label: t('orderTransactions.posType'), value: transaction.posType })
}
if (type === 'transfer') {
if (transaction.transferAccount) {
rows.push({ label: t('orderTransactions.account'), value: transaction.transferAccount })
}
if (transaction.transferRefNumber) {
rows.push({
label: t('orderTransactions.refCode'),
value: transaction.transferRefNumber,
mono: true,
})
}
}
if (type === 'e_payment_gate') {
if (transaction.gatewayType) {
rows.push({ label: t('orderTransactions.gateway'), value: transaction.gatewayType })
}
if (transaction.gatewayTrackId) {
rows.push({
label: t('orderTransactions.gatewayTrackId'),
value: transaction.gatewayTrackId,
mono: true,
})
}
if (transaction.gatewayRef) {
rows.push({
label: t('orderTransactions.gatewayRef'),
value: transaction.gatewayRef,
mono: true,
})
}
if (transaction.cardPan) {
rows.push({
label: t('orderTransactions.cardPan'),
value: transaction.cardPan,
mono: true,
})
}
}
if (transaction.notes) {
rows.push({ label: t('orderTransactions.notes'), value: transaction.notes })
}
return { type, status, rows }
}
return createPortal(
<div
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
onClick={onClose}
>
<div
className={`${modalStyles.modal} ${styles.modalWide} ${closing ? modalStyles.modalOut : modalStyles.modalIn}`}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="order-transactions-title"
>
<div className={modalStyles.header}>
<div>
<h2 id="order-transactions-title" className={modalStyles.title}>
{t('orderTransactions.title')}
</h2>
<p className={modalStyles.subtitle}>{order.orderNumber}</p>
</div>
<button
type="button"
className={modalStyles.closeBtn}
onClick={onClose}
aria-label={t('orderTransactions.close')}
>
<X size={18} />
</button>
</div>
<div className={`${modalStyles.body} ${styles.body}`}>
{transactions.length === 0 ? (
<p className={styles.empty}>{t('orderTransactions.empty')}</p>
) : (
<ul className={styles.txList}>
{transactions.map((transaction) => {
const { type, status, rows } = transactionDetails(transaction)
return (
<li key={transaction.id} className={styles.txCard}>
<div className={styles.txHeader}>
<div className={styles.txType}>{t(TYPE_KEYS[type])}</div>
<div className={styles.txAmount}>{formatIrtPrice(transaction.amount)}</div>
</div>
<div className={styles.txMeta}>
{rows.map((detail) => (
<div key={detail.label} className={styles.txMetaItem}>
<span className={styles.txMetaLabel}>{detail.label}</span>
{detail.isStatus ? (
<span className={`${styles.statusBadge} ${statusClass(status)}`}>
{detail.value}
</span>
) : (
<strong className={detail.mono ? styles.txMono : undefined}>
{detail.value}
</strong>
)}
</div>
))}
</div>
</li>
)
})}
</ul>
)}
<div className={styles.summary}>
<span className={styles.summaryLabel}>
{t('orderTransactions.summary', {
count: transactions.length,
txLabel:
transactions.length === 1
? t('orderTransactions.one')
: t('orderTransactions.many'),
})}
</span>
<span className={styles.summaryValue}>{formatIrtPrice(totalPaid)}</span>
</div>
<div className={modalStyles.actions}>
<button type="button" className={modalStyles.cancelBtn} onClick={onClose}>
{t('orderTransactions.close')}
</button>
</div>
</div>
</div>
</div>,
document.body,
)
}
@@ -4,6 +4,31 @@
padding: 32px;
}
@keyframes homeReveal {
from {
opacity: 0;
transform: translateY(18px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.revealItem {
opacity: 0;
animation: homeReveal 0.58s cubic-bezier(0.22, 1, 0.36, 1) forwards;
animation-delay: var(--reveal-delay, 0ms);
}
@media (prefers-reduced-motion: reduce) {
.revealItem {
opacity: 1;
animation: none;
transform: none;
}
}
.pageHeader {
display: flex;
align-items: flex-start;
@@ -5,13 +5,20 @@
position: relative;
direction: rtl;
font-family: var(--font-ui);
background-color: var(--bg-gradient-mid);
background-image: linear-gradient(
135deg,
var(--bg-gradient-start) 0%,
var(--bg-gradient-mid) 50%,
var(--bg-gradient-end) 100%
);
/* Match customer dashboard body background (index.css) */
background:
radial-gradient(ellipse 80% 60% at 10% 0%, rgba(var(--primary-rgb) / 0.07) 0%, transparent 55%),
radial-gradient(
ellipse 70% 50% at 90% 100%,
rgba(var(--page-accent-rgb, var(--primary-rgb)) / 0.06) 0%,
transparent 50%
),
linear-gradient(
135deg,
var(--bg-gradient-start) 0%,
var(--bg-gradient-mid) 45%,
var(--bg-gradient-end) 100%
);
background-attachment: fixed;
background-repeat: no-repeat;
background-size: cover;
+68 -6
View File
@@ -125,9 +125,40 @@ const en = {
'orders.col.items': 'Items',
'orders.col.total': 'Total cost',
'orders.col.date': 'Date & time',
'orders.col.step': 'Step',
'orders.col.payment': 'Payment',
'orders.col.step': 'Order status',
'orders.col.source': 'Registered by',
'orders.col.actions': 'Actions',
'orders.payment.paid': 'Paid',
'orders.payment.unpaid': 'Unpaid',
'orders.payment.failed': 'Failed',
'orders.transactions': 'Transaction details',
'orderTransactions.title': 'Transaction details',
'orderTransactions.empty': 'No payment transactions recorded for this order.',
'orderTransactions.transactionId': 'Transaction ID',
'orderTransactions.status': 'Status',
'orderTransactions.date': 'Created',
'orderTransactions.paidAt': 'Paid at',
'orderTransactions.posType': 'POS type',
'orderTransactions.account': 'Account number',
'orderTransactions.refCode': 'Ref code',
'orderTransactions.gateway': 'Gateway',
'orderTransactions.gatewayTrackId': 'Gateway tracking ID',
'orderTransactions.gatewayRef': 'Gateway authority / ref',
'orderTransactions.cardPan': 'Card number',
'orderTransactions.notes': 'Notes',
'orderTransactions.summary': '{count} {txLabel}',
'orderTransactions.one': 'transaction',
'orderTransactions.many': 'transactions',
'orderTransactions.close': 'Close',
'orderTransactions.type.pos': 'POS',
'orderTransactions.type.cash': 'Cash',
'orderTransactions.type.transfer': 'Transfer',
'orderTransactions.type.e_payment_gate': 'E-payment gateway',
'orderTransactions.status.pending': 'Pending',
'orderTransactions.status.completed': 'Completed',
'orderTransactions.status.failed': 'Failed',
'orderTransactions.status.refunded': 'Refunded',
'orders.pageMeta': 'Page {page} / {totalPages} · {pageSize} per page · {total} total',
'orders.viewItems': 'View items',
'orders.source.operator': 'Operator',
@@ -307,7 +338,7 @@ const en = {
'title.signIn': 'Sign in',
'title.checkout': 'Checkout',
'title.cart': 'Shopping Cart',
'title.cart': 'Complete order',
'title.delivery': 'Delivery',
'title.payment': 'Payment',
'title.success': 'Success',
@@ -554,9 +585,40 @@ const fa: Record<MessageKey, string> = {
'orders.col.items': 'اقلام',
'orders.col.total': 'مبلغ کل',
'orders.col.date': 'تاریخ و ساعت',
'orders.col.step': 'وضعیت',
'orders.col.payment': 'وضعیت پرداخت',
'orders.col.step': 'وضعیت سفارش',
'orders.col.source': 'ثبت‌شده توسط',
'orders.col.actions': 'عملیات',
'orders.payment.paid': 'پرداخت شده',
'orders.payment.unpaid': 'پرداخت نشده',
'orders.payment.failed': 'ناموفق',
'orders.transactions': 'جزئیات تراکنش',
'orderTransactions.title': 'جزئیات تراکنش',
'orderTransactions.empty': 'تراکنش پرداختی برای این سفارش ثبت نشده است.',
'orderTransactions.transactionId': 'شناسه تراکنش',
'orderTransactions.status': 'وضعیت',
'orderTransactions.date': 'زمان ایجاد',
'orderTransactions.paidAt': 'زمان پرداخت',
'orderTransactions.posType': 'نوع کارتخوان',
'orderTransactions.account': 'شماره حساب',
'orderTransactions.refCode': 'کد پیگیری',
'orderTransactions.gateway': 'درگاه',
'orderTransactions.gatewayTrackId': 'کد پیگیری درگاه',
'orderTransactions.gatewayRef': 'کد مرجع / Authority',
'orderTransactions.cardPan': 'شماره کارت',
'orderTransactions.notes': 'یادداشت',
'orderTransactions.summary': '{count} {txLabel}',
'orderTransactions.one': 'تراکنش',
'orderTransactions.many': 'تراکنش',
'orderTransactions.close': 'بستن',
'orderTransactions.type.pos': 'کارتخوان',
'orderTransactions.type.cash': 'نقدی',
'orderTransactions.type.transfer': 'انتقال',
'orderTransactions.type.e_payment_gate': 'درگاه پرداخت',
'orderTransactions.status.pending': 'در انتظار',
'orderTransactions.status.completed': 'موفق',
'orderTransactions.status.failed': 'ناموفق',
'orderTransactions.status.refunded': 'بازگشت‌خورده',
'orders.pageMeta': 'صفحه {page} / {totalPages} · {pageSize} در هر صفحه · {total} کل',
'orders.viewItems': 'مشاهده اقلام',
'orders.source.operator': 'اپراتور',
@@ -736,7 +798,7 @@ const fa: Record<MessageKey, string> = {
'title.signIn': 'ورود',
'title.checkout': 'تسویه‌حساب',
'title.cart': 'سبد خرید',
'title.cart': 'تکمیل سفارش',
'title.delivery': 'ارسال',
'title.payment': 'پرداخت',
'title.success': 'موفق',
@@ -883,12 +945,12 @@ export function getCustomerRouteTitleRules(locale: DashboardLocale) {
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/cart', labels: [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: '/checkout', labels: [t('title.cart')] },
{ match: '/profile', labels: [t('nav.profile')] },
{ match: '/addresses', labels: [t('nav.addresses')] },
{ match: '/orders', labels: [t('nav.orders')] },
+2 -2
View File
@@ -5,13 +5,13 @@ export const CUSTOMER_DASHBOARD_NAME = 'Customer Dashboard'
export const customerRouteTitleRules: RouteTitleRule[] = [
{ match: '/login', labels: ['Sign in'] },
{ match: '/checkout/login', labels: ['Checkout', 'Sign in'] },
{ match: '/checkout/cart', labels: ['Checkout', 'Shopping Cart'] },
{ match: '/checkout/cart', labels: ['Complete order'] },
{ match: '/checkout/delivery', labels: ['Checkout', 'Delivery'] },
{ match: '/checkout/payment', labels: ['Checkout', 'Payment'] },
{ match: '/checkout/result', labels: ['Checkout', 'Payment result'] },
{ match: '/checkout/success', labels: ['Checkout', 'Success'] },
{ match: '/checkout/failed', labels: ['Checkout', 'Failed'] },
{ match: '/checkout', labels: ['Checkout'] },
{ match: '/checkout', labels: ['Complete order'] },
{ match: '/profile', labels: ['My Profile'] },
{ match: '/addresses', labels: ['My Addresses'] },
{ match: '/orders', labels: ['My Orders'] },
+19 -4
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useEffect, useState, type CSSProperties } from 'react'
import { CalendarDays, User, MapPin, ShoppingBag, Heart, Package } from 'lucide-react'
import { SectionCard, useLocale } from '@meshkee/dashboard-ui'
import { useAuth } from '../context/AuthContext'
@@ -98,6 +98,14 @@ async function loadSectionCounts(signal: AbortSignal): Promise<SectionCounts> {
return counts
}
const REVEAL_STEP_MS = 75
const REVEAL_HEADER_MS = 40
const REVEAL_TILE_BASE_MS = 120
function revealStyle(delayMs: number): CSSProperties {
return { ['--reveal-delay' as string]: `${delayMs}ms` }
}
export function HomePage() {
const { user } = useAuth()
const { locale } = useLocale()
@@ -134,7 +142,10 @@ export function HomePage() {
return (
<main className={styles.content}>
<div className={styles.pageHeader}>
<div
className={`${styles.pageHeader} ${styles.revealItem}`}
style={revealStyle(REVEAL_HEADER_MS)}
>
<div>
<h2 className={styles.pageTitle}>
{t('home.welcome', { name: firstName })}
@@ -149,8 +160,12 @@ export function HomePage() {
</div>
<div className={styles.grid12}>
{sections.map((section) => (
<div key={section.href} className={section.colClass}>
{sections.map((section, index) => (
<div
key={section.href}
className={`${section.colClass} ${styles.revealItem}`}
style={revealStyle(REVEAL_TILE_BASE_MS + index * REVEAL_STEP_MS)}
>
<SectionCard
icon={section.icon}
title={t(section.titleKey)}
@@ -53,11 +53,15 @@
}
.colDate {
width: 16%;
width: 14%;
}
.colPayment {
width: 12%;
}
.colStep {
width: 18%;
width: 14%;
}
.colSource {
@@ -65,7 +69,7 @@
}
.colActions {
width: 72px;
width: 96px;
}
.th,
+12 -1
View File
@@ -4,6 +4,7 @@ import { Breadcrumbs, Pagination, useLocale } from '@meshkee/dashboard-ui'
import { EmptyTableRow } from '../components/EmptyState'
import { OrderItemsModal } from '../components/OrderItemsModal'
import { OrderRow } from '../components/OrderRow'
import { OrderTransactionsModal } from '../components/OrderTransactionsModal'
import { useT } from '../i18n/useT'
import { ApiError, isAbortError } from '../lib/api'
import {
@@ -16,7 +17,7 @@ import pageStyles from '../components/PageContent.module.css'
import styles from './OrdersPage.module.css'
const PAGE_SIZE = 20
const COLUMN_COUNT = 7
const COLUMN_COUNT = 8
export function OrdersPage() {
const t = useT()
@@ -26,6 +27,7 @@ export function OrdersPage() {
const [error, setError] = useState('')
const [page, setPage] = useState(1)
const [viewOrder, setViewOrder] = useState<Order | null>(null)
const [transactionsOrder, setTransactionsOrder] = useState<Order | null>(null)
const processSteps = DEFAULT_ORDER_PROCESS_STEPS
@@ -107,6 +109,7 @@ export function OrdersPage() {
<col className={styles.colItems} />
<col className={styles.colTotal} />
<col className={styles.colDate} />
<col className={styles.colPayment} />
<col className={styles.colStep} />
<col className={styles.colSource} />
<col className={styles.colActions} />
@@ -117,6 +120,7 @@ export function OrdersPage() {
<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.payment')}</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>
@@ -142,6 +146,7 @@ export function OrdersPage() {
order={order}
processSteps={processSteps}
onViewItems={setViewOrder}
onViewTransactions={setTransactionsOrder}
/>
))}
</tbody>
@@ -174,6 +179,12 @@ export function OrdersPage() {
order={viewOrder}
onClose={() => setViewOrder(null)}
/>
<OrderTransactionsModal
open={transactionsOrder !== null}
order={transactionsOrder}
onClose={() => setTransactionsOrder(null)}
/>
</main>
)
}
+14 -2
View File
@@ -16,6 +16,8 @@ export interface OrderItem {
storeItemVariantId: string | null
productId: string
productTitle: string
productNameFa?: string | null
productSlug?: string | null
productImage: string | null
variantSku: string | null
unitPrice: number
@@ -38,15 +40,20 @@ export interface OrderCustomer {
email: string | null
}
export type TransactionType = 'pos' | 'cash' | 'transfer' | 'e_payment_gate'
export type TransactionStatus = 'pending' | 'completed' | 'failed' | 'refunded'
export interface OrderTransaction {
id: string
type: string
status: string
type: TransactionType | string
status: TransactionStatus | string
amount: number
posType: string | null
gatewayType: string | null
gatewayRef: string | null
gatewayTrackId: string | null
cardPan?: string | null
paidAt?: string | null
transferAccount: string | null
transferRefNumber: string | null
notes: string | null
@@ -54,6 +61,8 @@ export interface OrderTransaction {
updatedAt: string
}
export type OrderPaymentStatus = 'paid' | 'unpaid' | 'failed'
export interface Order {
id: string
businessId: string
@@ -63,6 +72,7 @@ export interface Order {
processStepLabel?: string | null
processStepLabelFa?: string | null
processStepColor?: string | null
paymentStatus?: OrderPaymentStatus
source: OrderSource
subtotal: number
shippingTotal: number
@@ -112,6 +122,8 @@ export async function listOrders(params: ListOrdersParams = {}, signal?: AbortSi
if (params.orderNumber) q.set('orderNumber', params.orderNumber)
if (params.dateFrom) q.set('dateFrom', params.dateFrom)
if (params.dateTo) q.set('dateTo', params.dateTo)
// Customer portal: never list other shoppers' orders (even for staff accounts).
q.set('selfOnly', 'true')
const query = q.toString()
const path = `${businessPath()}${query ? `?${query}` : ''}`
@@ -0,0 +1,33 @@
import { getWebsiteUrl } from './websiteService'
/** Matches business/storefront slugify used for product detail paths. */
function slugifyForWebsitePath(value: string, fallback = 'product') {
const slug = value
.trim()
.normalize('NFC')
.replace(/\s+/g, '-')
.replace(
/[^\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFFa-zA-Z0-9-]+/g,
'',
)
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
return slug || fallback
}
/** Public storefront product detail URL. */
export function buildProductDetailsUrl(item: {
productId: string
productNameFa?: string | null
productTitle?: string | null
productSlug?: string | null
}) {
const id = item.productId?.trim()
if (!id) return ''
const pathSlug = slugifyForWebsitePath(
item.productNameFa?.trim() || item.productTitle?.trim() || item.productSlug?.trim() || '',
'product',
)
return `${getWebsiteUrl()}/products/${encodeURIComponent(id)}/${encodeURIComponent(pathSlug)}`
}