Localize order modals, add dual logos, and improve admin order tools.

Farsi business order/variation UI, light/dark logo upload on business profile, payment order steps, variation editing, super-admin site links, and checkout/auth fixes.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-26 00:21:48 +03:30
co-authored by Cursor
parent dc570c1e7f
commit 5e799eddf9
30 changed files with 779 additions and 169 deletions
@@ -13,14 +13,22 @@ interface AddVariationModalProps {
open: boolean
onClose: () => void
onSubmit: (variation: Omit<Variation, 'id'>) => void
/** When set, form opens in edit mode with values pre-filled. */
initial?: Variation | null
}
const ANIMATION_MS = 220
export function AddVariationModal({ open, onClose, onSubmit }: AddVariationModalProps) {
function seedCustomValues(values: string[]) {
const trimmed = values.map((value) => value.trim()).filter(Boolean)
return trimmed.length ? trimmed : ['']
}
export function AddVariationModal({ open, onClose, onSubmit, initial = null }: AddVariationModalProps) {
const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
const isEdit = Boolean(initial)
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
const [type, setType] = useState<VariationType>('color')
@@ -41,11 +49,21 @@ export function AddVariationModal({ open, onClose, onSubmit }: AddVariationModal
if (open) {
setMounted(true)
setClosing(false)
setType('color')
setCustomName('')
setSelectedColors([])
setSelectedSizes([])
setCustomValues([''])
if (initial) {
setType(initial.type)
setCustomName(initial.type === 'custom' ? initial.name : '')
setSelectedColors(initial.type === 'color' ? [...initial.values] : [])
setSelectedSizes(initial.type === 'size' ? [...initial.values] : [])
setCustomValues(
initial.type === 'custom' ? seedCustomValues(initial.values) : [''],
)
} else {
setType('color')
setCustomName('')
setSelectedColors([])
setSelectedSizes([])
setCustomValues([''])
}
} else if (mounted) {
setClosing(true)
const timer = setTimeout(() => {
@@ -54,7 +72,7 @@ export function AddVariationModal({ open, onClose, onSubmit }: AddVariationModal
}, ANIMATION_MS)
return () => clearTimeout(timer)
}
}, [open, mounted])
}, [open, mounted, initial])
useEffect(() => {
if (!mounted || closing) return
@@ -128,7 +146,7 @@ export function AddVariationModal({ open, onClose, onSubmit }: AddVariationModal
onClick={onClose}
>
<div
className={`${styles.modal} ${closing ? styles.modalOut : styles.modalIn}`}
className={`${styles.modal} ${styles.modalFormWide} ${closing ? styles.modalOut : styles.modalIn}`}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
@@ -138,7 +156,7 @@ export function AddVariationModal({ open, onClose, onSubmit }: AddVariationModal
>
<div className={styles.header}>
<h3 id="add-variation-title" className={styles.title}>
{t('categories.variations.addTitle')}
{isEdit ? t('categories.variations.editTitle') : t('categories.variations.addTitle')}
</h3>
<button className={styles.closeBtn} onClick={onClose} aria-label={t('common.close')}>
<X size={20} />
@@ -151,6 +169,7 @@ export function AddVariationModal({ open, onClose, onSubmit }: AddVariationModal
<select
id="variationType"
value={type}
disabled={isEdit}
onChange={(e) => setType(e.target.value as VariationType)}
>
<option value="color">{t('categories.variations.type.color')}</option>
@@ -250,7 +269,7 @@ export function AddVariationModal({ open, onClose, onSubmit }: AddVariationModal
{t('categories.modal.cancel')}
</button>
<button type="submit" className={styles.submitBtn} disabled={!canSubmit}>
{t('categories.variations.submit')}
{isEdit ? t('categories.variations.saveEdit') : t('categories.variations.submit')}
</button>
</div>
</form>
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
import { useT } from '../i18n/useT'
import type { Order } from '../services/orderService'
import { formatCellForDisplay } from '../lib/cellNumber'
import { formatIrtPrice } from '../utils/irtPrice'
@@ -30,6 +31,7 @@ function totalQuantity(order: Order) {
}
export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps) {
const t = useT()
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
@@ -75,11 +77,16 @@ export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps)
<div className={modalStyles.header}>
<div>
<h2 id="order-items-title" className={modalStyles.title}>
Order items
{t('orderItems.title')}
</h2>
<p className={modalStyles.subtitle}>{order.orderNumber}</p>
</div>
<button type="button" className={modalStyles.closeBtn} onClick={onClose} aria-label="Close">
<button
type="button"
className={modalStyles.closeBtn}
onClick={onClose}
aria-label={t('orderItems.close')}
>
<X size={18} />
</button>
</div>
@@ -87,26 +94,27 @@ export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps)
<div className={`${modalStyles.body} ${styles.body}`}>
<div className={styles.metaRow}>
<span className={styles.metaItem}>
Customer: <strong>{displayName(order)}</strong>
{t('orderItems.customer')}: <strong>{displayName(order)}</strong>
</span>
<span className={styles.metaItem}>
Phone: <strong>{formatCellForDisplay(order.customer.cellNumber)}</strong>
{t('orderItems.phone')}:{' '}
<strong>{formatCellForDisplay(order.customer.cellNumber)}</strong>
</span>
<span className={styles.metaItem}>
Items: <strong>{itemCount}</strong>
{t('orderItems.items')}: <strong>{itemCount}</strong>
</span>
</div>
{order.items.length === 0 ? (
<p className={styles.empty}>No items in this order.</p>
<p className={styles.empty}>{t('orderItems.empty')}</p>
) : (
<div className={styles.tableBlock}>
<div className={styles.tableHead}>
<span aria-hidden="true" />
<span>Product</span>
<span>Qty</span>
<span>Unit price</span>
<span>Line total</span>
<span>{t('orderItems.product')}</span>
<span>{t('orderItems.qty')}</span>
<span>{t('orderItems.unitPrice')}</span>
<span>{t('orderItems.lineTotal')}</span>
</div>
<ul className={styles.itemList}>
{order.items.map((item) => (
@@ -122,7 +130,9 @@ export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps)
<div className={styles.itemTitle}>{item.productTitle}</div>
<div className={styles.itemVariant}>{formatVariantLabel(item.selections)}</div>
{item.variantSku && (
<div className={styles.itemSku}>SKU: {item.variantSku}</div>
<div className={styles.itemSku}>
{t('orderItems.sku', { sku: item.variantSku })}
</div>
)}
</div>
<div className={styles.qtyCell}>{item.quantity}</div>
@@ -136,20 +146,23 @@ export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps)
<div className={styles.summary}>
<span className={styles.summaryLabel}>
Order total · {itemCount} {itemCount === 1 ? 'item' : 'items'}
{t('orderItems.summary', {
count: itemCount,
itemsLabel:
itemCount === 1 ? t('orderItems.item') : t('orderItems.itemsPlural'),
})}
</span>
<span className={styles.summaryValue}>{formatIrtPrice(order.total)}</span>
</div>
<div className={modalStyles.actions}>
<button type="button" className={modalStyles.cancelBtn} onClick={onClose}>
Close
{t('orderItems.close')}
</button>
</div>
</div>
</div>
</div>
,
document.body,
</div>,
document.body,
)
}
@@ -11,7 +11,7 @@
gap: 10px;
width: 100%;
padding: 12px 14px;
text-align: left;
text-align: start;
border: 1px solid rgba(148, 163, 184, 0.3);
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.45);
@@ -55,8 +55,8 @@
flex-shrink: 0;
font-size: 12px;
font-weight: 700;
color: var(--primary);
background: rgba(var(--primary-rgb) / 0.12);
color: #111827;
background: #e5e7eb;
border-radius: 50%;
}
+35 -14
View File
@@ -1,7 +1,9 @@
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useToast } from '../context/ToastContext'
import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api'
import { updateOrder, type Order } from '../services/orderService'
import type { OrderProcessStep } from '../services/settingsService'
@@ -19,6 +21,14 @@ interface OrderStepModalProps {
const ANIMATION_MS = 220
function stepDisplayLabel(step: OrderProcessStep, locale: string) {
if (locale === 'fa') {
const fa = step.labelFa?.trim()
if (fa) return fa
}
return step.label
}
export function OrderStepModal({
open,
order,
@@ -26,6 +36,8 @@ export function OrderStepModal({
onClose,
onSaved,
}: OrderStepModalProps) {
const t = useT()
const { locale } = useLocale()
const { showToast } = useToast()
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
@@ -70,11 +82,11 @@ export function OrderStepModal({
try {
const result = await updateOrder(order.id, { processStepId: stepId })
showToast(`Order ${order.orderNumber} step updated.`, 'success')
showToast(t('orderStep.updated', { orderNumber: order.orderNumber }), 'success')
onSaved?.(result.order)
onClose()
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Unable to update order step.')
setError(err instanceof ApiError ? err.message : t('orderStep.error'))
} finally {
setSavingStepId(null)
}
@@ -82,9 +94,15 @@ export function OrderStepModal({
if (!mounted || !order) return null
const currentStep =
steps.find((step) => step.id === order.processStepId) ??
null
const currentLabel =
(locale === 'fa'
? order.processStepLabelFa?.trim() || currentStep?.labelFa?.trim()
: null) ||
order.processStepLabel?.trim() ||
steps.find((step) => step.id === order.processStepId)?.label ||
currentStep?.label ||
order.processStepId
return createPortal(
@@ -100,14 +118,19 @@ export function OrderStepModal({
role="dialog"
aria-modal="true"
aria-labelledby="order-step-title"
lang={locale === 'fa' ? 'fa' : undefined}
dir={locale === 'fa' ? 'rtl' : undefined}
>
<div className={modalStyles.header}>
<div>
<h2 id="order-step-title" className={modalStyles.title}>
Change order step
{t('orderStep.title')}
</h2>
<p className={modalStyles.subtitle}>
{order.orderNumber} · Current: {currentLabel}
{t('orderStep.current', {
orderNumber: order.orderNumber,
step: currentLabel,
})}
</p>
</div>
<button
@@ -115,7 +138,7 @@ export function OrderStepModal({
className={modalStyles.closeBtn}
onClick={onClose}
disabled={Boolean(savingStepId)}
aria-label="Close"
aria-label={t('common.close')}
>
<X size={20} />
</button>
@@ -128,11 +151,12 @@ export function OrderStepModal({
</div>
)}
<div className={styles.stepList} role="listbox" aria-label="Order process step">
<div className={styles.stepList} role="listbox" aria-label={t('orderStep.listAria')}>
{steps.map((step, index) => {
const selected = order.processStepId === step.id
const saving = savingStepId === step.id
const color = normalizeStepColor(step.color, defaultStepColorForId(step.id, index))
const label = stepDisplayLabel(step, locale)
return (
<button
key={step.id}
@@ -147,7 +171,7 @@ export function OrderStepModal({
<span className={styles.stepColorDot} style={{ backgroundColor: color }} />
<span className={styles.stepNumber}>{index + 1}</span>
<span className={styles.stepLabel}>
{saving ? 'Saving...' : step.label}
{saving ? t('orderStep.saving') : label}
</span>
</button>
)
@@ -155,14 +179,11 @@ export function OrderStepModal({
</div>
{!steps.length && (
<p className={styles.emptyText}>
No order steps configured. Add steps in Store settings first.
</p>
<p className={styles.emptyText}>{t('orderStep.empty')}</p>
)}
</div>
</div>
</div>
,
document.body,
</div>,
document.body,
)
}
@@ -2,6 +2,8 @@ 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 { BusinessMessageKey } from '../i18n/messages'
import type { Order, OrderTransaction, TransactionStatus, TransactionType } from '../services/orderService'
import { formatIrtPrice } from '../utils/irtPrice'
import modalStyles from './VariationsModal.module.css'
@@ -15,19 +17,18 @@ interface OrderTransactionsModalProps {
const ANIMATION_MS = 220
function transactionTypeLabel(type: TransactionType) {
switch (type) {
case 'pos':
return 'POS'
case 'cash':
return 'Cash'
case 'transfer':
return 'Transfer'
case 'e_payment_gate':
return 'E-payment gateway'
default:
return type
}
const TYPE_KEYS: Record<TransactionType, BusinessMessageKey> = {
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, BusinessMessageKey> = {
pending: 'orderTransactions.status.pending',
completed: 'orderTransactions.status.completed',
failed: 'orderTransactions.status.failed',
refunded: 'orderTransactions.status.refunded',
}
function statusClass(status: TransactionStatus) {
@@ -56,34 +57,8 @@ function formatDateTime(value: string, locale: 'en' | 'fa') {
})
}
function transactionDetails(transaction: OrderTransaction, locale: 'en' | 'fa') {
const rows: { label: string; value: string }[] = [
{ label: 'Status', value: transaction.status },
{ label: 'Date', value: formatDateTime(transaction.createdAt, locale) },
]
if (transaction.type === 'pos' && transaction.posType) {
rows.push({ label: 'POS type', value: transaction.posType })
}
if (transaction.type === 'transfer') {
if (transaction.transferAccount) {
rows.push({ label: 'Account number', value: transaction.transferAccount })
}
if (transaction.transferRefNumber) {
rows.push({ label: 'Ref code', value: transaction.transferRefNumber })
}
}
if (transaction.type === 'e_payment_gate' && transaction.gatewayType) {
rows.push({ label: 'Gateway', value: transaction.gatewayType })
}
if (transaction.notes) {
rows.push({ label: 'Notes', value: transaction.notes })
}
return rows
}
export function OrderTransactionsModal({ open, order, onClose }: OrderTransactionsModalProps) {
const t = useT()
const { locale } = useLocale()
const dateLocale = locale === 'fa' ? 'fa' : 'en'
const [mounted, setMounted] = useState(open)
@@ -120,6 +95,40 @@ export function OrderTransactionsModal({ open, order, onClose }: OrderTransactio
if (!mounted || !order) return null
function transactionDetails(transaction: OrderTransaction) {
const rows: { label: string; value: string; isStatus?: boolean }[] = [
{
label: t('orderTransactions.status'),
value: t(STATUS_KEYS[transaction.status] ?? 'orderTransactions.status.pending'),
isStatus: true,
},
{
label: t('orderTransactions.date'),
value: formatDateTime(transaction.createdAt, dateLocale),
},
]
if (transaction.type === 'pos' && transaction.posType) {
rows.push({ label: t('orderTransactions.posType'), value: transaction.posType })
}
if (transaction.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 })
}
}
if (transaction.type === 'e_payment_gate' && transaction.gatewayType) {
rows.push({ label: t('orderTransactions.gateway'), value: transaction.gatewayType })
}
if (transaction.notes) {
rows.push({ label: t('orderTransactions.notes'), value: transaction.notes })
}
return rows
}
return createPortal(
<div
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
@@ -135,33 +144,40 @@ export function OrderTransactionsModal({ open, order, onClose }: OrderTransactio
<div className={modalStyles.header}>
<div>
<h2 id="order-transactions-title" className={modalStyles.title}>
Transaction details
{t('orderTransactions.title')}
</h2>
<p className={modalStyles.subtitle}>{order.orderNumber}</p>
</div>
<button type="button" className={modalStyles.closeBtn} onClick={onClose} aria-label="Close">
<button
type="button"
className={modalStyles.closeBtn}
onClick={onClose}
aria-label={t('orderTransactions.close')}
>
<X size={18} />
</button>
</div>
<div className={`${modalStyles.body} ${styles.body}`}>
{transactions.length === 0 ? (
<p className={styles.empty}>No payment transactions recorded for this order.</p>
<p className={styles.empty}>{t('orderTransactions.empty')}</p>
) : (
<ul className={styles.txList}>
{transactions.map((transaction) => {
const details = transactionDetails(transaction, dateLocale)
const details = transactionDetails(transaction)
return (
<li key={transaction.id} className={styles.txCard}>
<div className={styles.txHeader}>
<div className={styles.txType}>{transactionTypeLabel(transaction.type)}</div>
<div className={styles.txType}>
{t(TYPE_KEYS[transaction.type] ?? 'orderTransactions.type.e_payment_gate')}
</div>
<div className={styles.txAmount}>{formatIrtPrice(transaction.amount)}</div>
</div>
<div className={styles.txMeta}>
{details.map((detail) => (
<div key={detail.label} className={styles.txMetaItem}>
{detail.label}:{' '}
{detail.label === 'Status' ? (
{detail.isStatus ? (
<span className={`${styles.statusBadge} ${statusClass(transaction.status)}`}>
{detail.value}
</span>
@@ -179,20 +195,25 @@ export function OrderTransactionsModal({ open, order, onClose }: OrderTransactio
<div className={styles.summary}>
<span className={styles.summaryLabel}>
{transactions.length} {transactions.length === 1 ? 'transaction' : 'transactions'}
{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}>
Close
{t('orderTransactions.close')}
</button>
</div>
</div>
</div>
</div>
,
document.body,
</div>,
document.body,
)
}
@@ -36,6 +36,14 @@
box-shadow: 0 20px 60px rgba(31, 38, 135, 0.15);
}
.modalListWide {
max-width: 720px;
}
.modalFormWide {
max-width: 560px;
}
.modalIn { animation: modalFadeIn 0.25s ease forwards; }
.modalOut { animation: modalFadeOut 0.22s ease forwards; }
@@ -136,9 +144,7 @@
font-size: 12px;
color: var(--text-muted);
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.45;
text-align: start;
}
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { Plus, X } from 'lucide-react'
import { Pencil, Plus, X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import type { BusinessMessageKey } from '../i18n/messages'
@@ -10,6 +10,7 @@ import { createId } from '../utils/id'
import { textLocaleAttrs } from '../utils/textLocale'
import { AddVariationModal } from './AddVariationModal'
import { Tooltip } from './Tooltip'
import controlStyles from './CategoryRow.module.css'
import styles from './VariationsModal.module.css'
interface VariationsModalProps {
@@ -38,7 +39,8 @@ export function VariationsModal({
const isFa = locale === 'fa'
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
const [addOpen, setAddOpen] = useState(false)
const [formOpen, setFormOpen] = useState(false)
const [editTarget, setEditTarget] = useState<Variation | null>(null)
useEffect(() => {
if (open) {
@@ -49,7 +51,8 @@ export function VariationsModal({
const timer = setTimeout(() => {
setMounted(false)
setClosing(false)
setAddOpen(false)
setFormOpen(false)
setEditTarget(null)
}, ANIMATION_MS)
return () => clearTimeout(timer)
}
@@ -58,11 +61,11 @@ export function VariationsModal({
useEffect(() => {
if (!mounted || closing) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape' && !addOpen) onClose()
if (e.key === 'Escape' && !formOpen) onClose()
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [mounted, closing, addOpen, onClose])
}, [mounted, closing, formOpen, onClose])
if (!mounted) return null
@@ -74,13 +77,35 @@ export function VariationsModal({
...list,
{ ...variation, id: createId() },
])
setAddOpen(false)
setFormOpen(false)
setEditTarget(null)
}
function handleEditSubmit(variation: Omit<Variation, 'id'>) {
if (!editTarget) return
onChange(
list.map((item) =>
item.id === editTarget.id ? { ...item, ...variation } : item,
),
)
setFormOpen(false)
setEditTarget(null)
}
function handleRemove(id: string) {
onChange(list.filter((variation) => variation.id !== id))
}
function openAddForm() {
setEditTarget(null)
setFormOpen(true)
}
function openEditForm(variation: Variation) {
setEditTarget(variation)
setFormOpen(true)
}
function typeLabel(type: VariationType) {
if (type === 'color') return t('categories.variations.type.color')
if (type === 'size') return t('categories.variations.type.size')
@@ -108,7 +133,7 @@ export function VariationsModal({
onClick={onClose}
>
<div
className={`${styles.modal} ${closing ? styles.modalOut : styles.modalIn}`}
className={`${styles.modal} ${styles.modalListWide} ${closing ? styles.modalOut : styles.modalIn}`}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
@@ -164,6 +189,16 @@ export function VariationsModal({
</div>
<div className={styles.listItemEnd}>
<span className={styles.listItemType}>{typeLabel(variation.type)}</span>
<Tooltip label={t('categories.variations.edit')}>
<button
type="button"
className={controlStyles.controlBtn}
onClick={() => openEditForm(variation)}
aria-label={t('categories.variations.edit')}
>
<Pencil size={16} />
</button>
</Tooltip>
<Tooltip label={t('categories.variations.remove')}>
<button
type="button"
@@ -183,7 +218,7 @@ export function VariationsModal({
)}
</div>
<button type="button" className={styles.addBtn} onClick={() => setAddOpen(true)}>
<button type="button" className={styles.addBtn} onClick={openAddForm}>
<Plus size={18} />
{t('categories.variations.add')}
</button>
@@ -201,9 +236,13 @@ export function VariationsModal({
</div>
<AddVariationModal
open={addOpen}
onClose={() => setAddOpen(false)}
onSubmit={handleAdd}
open={formOpen}
initial={editTarget}
onClose={() => {
setFormOpen(false)
setEditTarget(null)
}}
onSubmit={editTarget ? handleEditSubmit : handleAdd}
/>
</>,
document.body,