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:
co-authored by
Cursor
parent
55071af98c
commit
06589600bd
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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')] },
|
||||
|
||||
@@ -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'] },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)}`
|
||||
}
|
||||
Reference in New Issue
Block a user