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:
co-authored by
Cursor
parent
dc570c1e7f
commit
5e799eddf9
@@ -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%;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -147,6 +147,9 @@ const en = {
|
||||
'bizProfile.logo.upload': 'Upload logo',
|
||||
'bizProfile.logo.hint': 'Transparent PNG recommended',
|
||||
'bizProfile.logo.change': 'Change logo',
|
||||
'bizProfile.logo.light': 'Light mode',
|
||||
'bizProfile.logo.dark': 'Dark mode',
|
||||
'bizProfile.logo.modeSwitch': 'Logo for light or dark mode',
|
||||
'bizProfile.categories': 'Activity categories',
|
||||
'bizProfile.categories.placeholder': 'Select activity categories',
|
||||
'bizProfile.nameFa': 'Name (FA)',
|
||||
@@ -612,7 +615,9 @@ const en = {
|
||||
'categories.variations.saving': 'Saving…',
|
||||
'categories.variations.noValues': 'No values',
|
||||
'categories.variations.remove': 'Remove variation',
|
||||
'categories.variations.edit': 'Edit variation',
|
||||
'categories.variations.addTitle': 'Add variation',
|
||||
'categories.variations.editTitle': 'Edit variation',
|
||||
'categories.variations.type': 'Type',
|
||||
'categories.variations.type.color': 'Color',
|
||||
'categories.variations.type.size': 'Size',
|
||||
@@ -626,6 +631,7 @@ const en = {
|
||||
'categories.variations.addValue': 'Add value',
|
||||
'categories.variations.removeValue': 'Remove value',
|
||||
'categories.variations.submit': 'Add',
|
||||
'categories.variations.saveEdit': 'Save changes',
|
||||
'categories.variations.color.Red': 'Red',
|
||||
'categories.variations.color.Blue': 'Blue',
|
||||
'categories.variations.color.Green': 'Green',
|
||||
@@ -850,6 +856,51 @@ const en = {
|
||||
'orders.source.app': 'Application',
|
||||
'orders.source.website': 'Website',
|
||||
|
||||
'orderStep.title': 'Change order step',
|
||||
'orderStep.current': '{orderNumber} · Current: {step}',
|
||||
'orderStep.listAria': 'Order process step',
|
||||
'orderStep.saving': 'Saving...',
|
||||
'orderStep.empty': 'No order steps configured. Add steps in Store settings first.',
|
||||
'orderStep.updated': 'Order {orderNumber} step updated.',
|
||||
'orderStep.error': 'Unable to update order step.',
|
||||
|
||||
'orderItems.title': 'Order items',
|
||||
'orderItems.customer': 'Customer',
|
||||
'orderItems.phone': 'Phone',
|
||||
'orderItems.items': 'Items',
|
||||
'orderItems.empty': 'No items in this order.',
|
||||
'orderItems.product': 'Product',
|
||||
'orderItems.qty': 'Qty',
|
||||
'orderItems.unitPrice': 'Unit price',
|
||||
'orderItems.lineTotal': 'Line total',
|
||||
'orderItems.sku': 'SKU: {sku}',
|
||||
'orderItems.summary': 'Order total · {count} {itemsLabel}',
|
||||
'orderItems.item': 'item',
|
||||
'orderItems.itemsPlural': 'items',
|
||||
'orderItems.close': 'Close',
|
||||
|
||||
'orderTransactions.title': 'Transaction details',
|
||||
'orderTransactions.empty': 'No payment transactions recorded for this order.',
|
||||
'orderTransactions.status': 'Status',
|
||||
'orderTransactions.date': 'Date',
|
||||
'orderTransactions.posType': 'POS type',
|
||||
'orderTransactions.account': 'Account number',
|
||||
'orderTransactions.refCode': 'Ref code',
|
||||
'orderTransactions.gateway': 'Gateway',
|
||||
'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',
|
||||
|
||||
'shoppingCards.title': 'Shopping cards',
|
||||
'shoppingCards.subtitle': 'Saved operator carts waiting to be completed as orders.',
|
||||
'shoppingCards.listTitle': 'Shopping card list',
|
||||
@@ -1793,6 +1844,8 @@ const en = {
|
||||
'website.ePayment.master': 'Enable online payments',
|
||||
'website.ePayment.masterDesc':
|
||||
'When a gateway is enabled and configured, customers can use it at website checkout or in the app.',
|
||||
'website.ePayment.masterOffHint':
|
||||
'Online payments are off. Turn on the switch above, or gateway options will not appear at checkout.',
|
||||
'website.ePayment.gateway.mellat': 'Bank Mellat (Behpardakht)',
|
||||
'website.ePayment.gateway.mellatDesc':
|
||||
'Enter terminal ID, username, and password from the Mellat merchant panel.',
|
||||
@@ -2331,6 +2384,9 @@ const fa: Record<MessageKey, string> = {
|
||||
'bizProfile.logo.upload': 'آپلود لوگو',
|
||||
'bizProfile.logo.hint': 'PNG شفاف توصیه میشود',
|
||||
'bizProfile.logo.change': 'تغییر لوگو',
|
||||
'bizProfile.logo.light': 'حالت روشن',
|
||||
'bizProfile.logo.dark': 'حالت تیره',
|
||||
'bizProfile.logo.modeSwitch': 'لوگوی حالت روشن یا تیره',
|
||||
'bizProfile.categories': 'دستهبندیهای فعالیت',
|
||||
'bizProfile.categories.placeholder': 'انتخاب دستهبندیهای فعالیت',
|
||||
'bizProfile.nameFa': 'نام (فارسی)',
|
||||
@@ -2794,7 +2850,9 @@ const fa: Record<MessageKey, string> = {
|
||||
'categories.variations.saving': 'در حال ذخیره…',
|
||||
'categories.variations.noValues': 'بدون مقدار',
|
||||
'categories.variations.remove': 'حذف تنوع',
|
||||
'categories.variations.edit': 'ویرایش تنوع',
|
||||
'categories.variations.addTitle': 'افزودن تنوع',
|
||||
'categories.variations.editTitle': 'ویرایش تنوع',
|
||||
'categories.variations.type': 'نوع',
|
||||
'categories.variations.type.color': 'رنگ',
|
||||
'categories.variations.type.size': 'سایز',
|
||||
@@ -2808,6 +2866,7 @@ const fa: Record<MessageKey, string> = {
|
||||
'categories.variations.addValue': 'افزودن مقدار',
|
||||
'categories.variations.removeValue': 'حذف مقدار',
|
||||
'categories.variations.submit': 'افزودن',
|
||||
'categories.variations.saveEdit': 'ذخیره تغییرات',
|
||||
'categories.variations.color.Red': 'قرمز',
|
||||
'categories.variations.color.Blue': 'آبی',
|
||||
'categories.variations.color.Green': 'سبز',
|
||||
@@ -3032,6 +3091,51 @@ const fa: Record<MessageKey, string> = {
|
||||
'orders.source.app': 'اپلیکیشن',
|
||||
'orders.source.website': 'وبسایت',
|
||||
|
||||
'orderStep.title': 'تغییر وضعیت سفارش',
|
||||
'orderStep.current': '{orderNumber} · وضعیت فعلی: {step}',
|
||||
'orderStep.listAria': 'وضعیت فرآیند سفارش',
|
||||
'orderStep.saving': 'در حال ذخیره...',
|
||||
'orderStep.empty': 'وضعیتی تعریف نشده است. ابتدا در تنظیمات فروشگاه وضعیتها را اضافه کنید.',
|
||||
'orderStep.updated': 'وضعیت سفارش {orderNumber} بهروزرسانی شد.',
|
||||
'orderStep.error': 'بهروزرسانی وضعیت سفارش ممکن نشد.',
|
||||
|
||||
'orderItems.title': 'اقلام سفارش',
|
||||
'orderItems.customer': 'مشتری',
|
||||
'orderItems.phone': 'تلفن',
|
||||
'orderItems.items': 'اقلام',
|
||||
'orderItems.empty': 'اقلامی در این سفارش نیست.',
|
||||
'orderItems.product': 'محصول',
|
||||
'orderItems.qty': 'تعداد',
|
||||
'orderItems.unitPrice': 'قیمت واحد',
|
||||
'orderItems.lineTotal': 'جمع ردیف',
|
||||
'orderItems.sku': 'کد کالا: {sku}',
|
||||
'orderItems.summary': 'جمع سفارش · {count} {itemsLabel}',
|
||||
'orderItems.item': 'قلم',
|
||||
'orderItems.itemsPlural': 'قلم',
|
||||
'orderItems.close': 'بستن',
|
||||
|
||||
'orderTransactions.title': 'جزئیات تراکنش',
|
||||
'orderTransactions.empty': 'تراکنش پرداختی برای این سفارش ثبت نشده است.',
|
||||
'orderTransactions.status': 'وضعیت',
|
||||
'orderTransactions.date': 'تاریخ',
|
||||
'orderTransactions.posType': 'نوع کارتخوان',
|
||||
'orderTransactions.account': 'شماره حساب',
|
||||
'orderTransactions.refCode': 'کد پیگیری',
|
||||
'orderTransactions.gateway': 'درگاه',
|
||||
'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': 'بازگشتخورده',
|
||||
|
||||
'shoppingCards.title': 'کارتهای خرید',
|
||||
'shoppingCards.subtitle': 'سبدهای ذخیرهشده اپراتور که منتظر تکمیل بهصورت سفارش هستند.',
|
||||
'shoppingCards.listTitle': 'فهرست کارتهای خرید',
|
||||
@@ -3974,6 +4078,8 @@ const fa: Record<MessageKey, string> = {
|
||||
'website.ePayment.master': 'فعالسازی پرداخت آنلاین',
|
||||
'website.ePayment.masterDesc':
|
||||
'با فعال بودن هر درگاه و پیکربندی آن، مشتریان در تسویه حساب وبسایت و یا اپلیکیشن از آن درگاه استفاده کنند.',
|
||||
'website.ePayment.masterOffHint':
|
||||
'پرداخت آنلاین خاموش است. سوییچ بالا را روشن کنید؛ در غیر این صورت درگاه در مرحله پرداخت نمایش داده نمیشود.',
|
||||
'website.ePayment.gateway.mellat': 'بانک ملت (بهپرداخت)',
|
||||
'website.ePayment.gateway.mellatDesc':
|
||||
'شناسه ترمینال، نام کاربری و رمز عبور را از پنل پذیرنده ملت وارد کنید.',
|
||||
|
||||
@@ -44,6 +44,26 @@
|
||||
grid-column: span 12;
|
||||
}
|
||||
|
||||
.logoModeRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.logoModeLabel {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.logoModeLabelActive {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.basicAside {
|
||||
grid-column: span 12;
|
||||
display: grid;
|
||||
|
||||
@@ -14,6 +14,7 @@ import { PageTitle } from '../components/PageTitle'
|
||||
import { ImageCropper } from '../components/ImageCropper'
|
||||
import { MultiSelectDropdown } from '../components/MultiSelectDropdown'
|
||||
import { RichTextEditor } from '../components/RichTextEditor'
|
||||
import { Switch } from '../components/Switch'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { ApiError } from '../lib/api'
|
||||
@@ -80,6 +81,9 @@ export function BusinessProfilePage() {
|
||||
const [vision, setVision] = useState('')
|
||||
const [logo, setLogo] = useState<string | null>(null)
|
||||
const [logoMediaId, setLogoMediaId] = useState<string | null>(null)
|
||||
const [logoDark, setLogoDark] = useState<string | null>(null)
|
||||
const [logoDarkMediaId, setLogoDarkMediaId] = useState<string | null>(null)
|
||||
const [logoModeDark, setLogoModeDark] = useState(false)
|
||||
const [addresses, setAddresses] = useState<AddressDraft[]>([createEmptyAddressItem()])
|
||||
const [phoneNumbers, setPhoneNumbers] = useState<BusinessPhoneNumber[]>([
|
||||
createEmptyPhone(),
|
||||
@@ -153,6 +157,8 @@ export function BusinessProfilePage() {
|
||||
setVision(profileData.profile.vision)
|
||||
setLogo(profileData.profile.logoUrl)
|
||||
setLogoMediaId(profileData.profile.logoMediaId)
|
||||
setLogoDark(profileData.profile.logoDarkUrl)
|
||||
setLogoDarkMediaId(profileData.profile.logoDarkMediaId)
|
||||
|
||||
const nextAddresses =
|
||||
profileData.addresses.length > 0
|
||||
@@ -273,6 +279,11 @@ export function BusinessProfilePage() {
|
||||
'business-logo.png',
|
||||
logoMediaId,
|
||||
)
|
||||
const nextLogoDarkMediaId = await resolveDataUrlToMediaId(
|
||||
logoDark,
|
||||
'business-logo-dark.png',
|
||||
logoDarkMediaId,
|
||||
)
|
||||
|
||||
const payloadAddresses = addresses
|
||||
.filter(
|
||||
@@ -309,6 +320,7 @@ export function BusinessProfilePage() {
|
||||
phoneNumbers: payloadPhones,
|
||||
socialMedia,
|
||||
logoMediaId: nextLogoMediaId,
|
||||
logoDarkMediaId: nextLogoDarkMediaId,
|
||||
categoryIds,
|
||||
addresses: payloadAddresses,
|
||||
})
|
||||
@@ -358,9 +370,27 @@ export function BusinessProfilePage() {
|
||||
<div className={formStyles.formGrid}>
|
||||
<div className={`${formStyles.field} ${styles.logoCol}`}>
|
||||
<label>{t('bizProfile.logo')}</label>
|
||||
<div className={styles.logoModeRow}>
|
||||
<span
|
||||
className={`${styles.logoModeLabel} ${!logoModeDark ? styles.logoModeLabelActive : ''}`}
|
||||
>
|
||||
{t('bizProfile.logo.light')}
|
||||
</span>
|
||||
<Switch
|
||||
checked={logoModeDark}
|
||||
onChange={setLogoModeDark}
|
||||
aria-label={t('bizProfile.logo.modeSwitch')}
|
||||
/>
|
||||
<span
|
||||
className={`${styles.logoModeLabel} ${logoModeDark ? styles.logoModeLabelActive : ''}`}
|
||||
>
|
||||
{t('bizProfile.logo.dark')}
|
||||
</span>
|
||||
</div>
|
||||
<ImageCropper
|
||||
value={logo}
|
||||
onChange={setLogo}
|
||||
key={logoModeDark ? 'logo-dark' : 'logo-light'}
|
||||
value={logoModeDark ? logoDark : logo}
|
||||
onChange={logoModeDark ? setLogoDark : setLogo}
|
||||
outputFormat="png"
|
||||
accept="image/png"
|
||||
uploadLabel={t('bizProfile.logo.upload')}
|
||||
|
||||
@@ -40,6 +40,17 @@
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.masterOffHint {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
background: rgba(217, 119, 6, 0.1);
|
||||
border: 1px solid rgba(217, 119, 6, 0.28);
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.credentials {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, minmax(0, 1fr));
|
||||
|
||||
@@ -157,6 +157,8 @@ export function SettingsPage() {
|
||||
async function handleMellatToggle(checked: boolean) {
|
||||
await patchEPayment('mellat-enabled', {
|
||||
...ePayment,
|
||||
// Turning a gateway on implies online payments are allowed at checkout.
|
||||
enabled: checked ? true : ePayment.enabled,
|
||||
gateways: {
|
||||
...ePayment.gateways,
|
||||
mellat: { ...ePayment.gateways.mellat, enabled: checked },
|
||||
@@ -168,6 +170,7 @@ export function SettingsPage() {
|
||||
async function handleZarinpalToggle(checked: boolean) {
|
||||
await patchEPayment('zarinpal-enabled', {
|
||||
...ePayment,
|
||||
enabled: checked ? true : ePayment.enabled,
|
||||
gateways: {
|
||||
...ePayment.gateways,
|
||||
zarinpal: { ...ePayment.gateways.zarinpal, enabled: checked },
|
||||
@@ -265,6 +268,12 @@ export function SettingsPage() {
|
||||
onChange={(checked) => void handleMasterToggle(checked)}
|
||||
/>
|
||||
</div>
|
||||
{!ePayment.enabled &&
|
||||
(ePayment.gateways.mellat.enabled || ePayment.gateways.zarinpal.enabled) ? (
|
||||
<p className={styles.masterOffHint} role="status">
|
||||
{t('website.ePayment.masterOffHint')}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ export interface BusinessProfile {
|
||||
socialMedia: BusinessSocialMedia
|
||||
logoMediaId: string | null
|
||||
logoUrl: string | null
|
||||
logoDarkMediaId: string | null
|
||||
logoDarkUrl: string | null
|
||||
faviconMediaId: string | null
|
||||
faviconUrl: string | null
|
||||
categoryIds: string[]
|
||||
@@ -56,6 +58,7 @@ export interface UpdateBusinessProfilePayload {
|
||||
phoneNumbers?: BusinessPhoneNumber[]
|
||||
socialMedia?: Partial<BusinessSocialMedia>
|
||||
logoMediaId?: string | null
|
||||
logoDarkMediaId?: string | null
|
||||
categoryIds?: string[]
|
||||
addresses?: Array<{
|
||||
id?: string
|
||||
@@ -92,6 +95,12 @@ export async function updateBusinessProfile(payload: UpdateBusinessProfilePayloa
|
||||
: payload.logoMediaId
|
||||
? Number(payload.logoMediaId)
|
||||
: null,
|
||||
logoDarkMediaId:
|
||||
payload.logoDarkMediaId === undefined
|
||||
? undefined
|
||||
: payload.logoDarkMediaId
|
||||
? Number(payload.logoDarkMediaId)
|
||||
: null,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -120,6 +120,18 @@ export interface SettingsResponse {
|
||||
}
|
||||
|
||||
export const DEFAULT_ORDER_PROCESS_STEPS: OrderProcessStep[] = [
|
||||
{
|
||||
id: 'awaiting-payment',
|
||||
label: 'Waiting for payment',
|
||||
labelFa: 'در انتظار پرداخت',
|
||||
color: '#64748B',
|
||||
},
|
||||
{
|
||||
id: 'payment-successful',
|
||||
label: 'Payment successful',
|
||||
labelFa: 'پرداخت موفق',
|
||||
color: '#10B981',
|
||||
},
|
||||
{
|
||||
id: 'processing',
|
||||
label: 'Under processing',
|
||||
|
||||
@@ -30,6 +30,8 @@ export type StepColorPreset = (typeof STEP_COLOR_PRESETS)[number]
|
||||
export const DEFAULT_STEP_COLOR: StepColorPreset = '#3B82F6'
|
||||
|
||||
const DEFAULT_STEP_COLORS_BY_ID: Record<string, StepColorPreset> = {
|
||||
'awaiting-payment': '#64748B',
|
||||
'payment-successful': '#10B981',
|
||||
processing: '#3B82F6',
|
||||
'ready-for-shipping': '#F59E0B',
|
||||
shipped: '#8B5CF6',
|
||||
|
||||
@@ -17,7 +17,3 @@
|
||||
.backBtn span {
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
:global([dir='rtl']) .backBtn svg {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
@@ -23,7 +23,10 @@ interface AuthContextValue {
|
||||
user: AuthUser | null
|
||||
isLoading: boolean
|
||||
login: (cellNumber: string, password: string) => Promise<void>
|
||||
loginWithOtp: (cellNumber: string, code: string) => Promise<void>
|
||||
loginWithOtp: (
|
||||
cellNumber: string,
|
||||
code: string,
|
||||
) => Promise<{ passwordUpdated?: boolean }>
|
||||
logout: () => void
|
||||
setUser: (user: AuthUser) => void
|
||||
}
|
||||
@@ -130,6 +133,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const data = await loginWithOtpRequest(cellNumber, code, getTenantDomain())
|
||||
await assertCustomerAccess(data.user)
|
||||
setUser(data.user)
|
||||
return { passwordUpdated: data.passwordUpdated }
|
||||
}, [])
|
||||
|
||||
const value = useMemo(
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from 'react'
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import type { Order } from '../services/orderService'
|
||||
import { getTenantDomain } from '../lib/config'
|
||||
|
||||
export type DeliveryMode = 'delivery' | 'pickup'
|
||||
|
||||
@@ -25,23 +33,94 @@ const defaultState: CheckoutState = {
|
||||
placedOrder: null,
|
||||
}
|
||||
|
||||
function storageKey() {
|
||||
return `meshkee_checkout:${getTenantDomain()}`
|
||||
}
|
||||
|
||||
function readPersisted(): Pick<CheckoutState, 'deliveryMode' | 'selectedAddressId' | 'discountCode'> {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(storageKey())
|
||||
if (!raw) return {
|
||||
deliveryMode: defaultState.deliveryMode,
|
||||
selectedAddressId: defaultState.selectedAddressId,
|
||||
discountCode: defaultState.discountCode,
|
||||
}
|
||||
const parsed = JSON.parse(raw) as {
|
||||
deliveryMode?: unknown
|
||||
selectedAddressId?: unknown
|
||||
discountCode?: unknown
|
||||
}
|
||||
return {
|
||||
deliveryMode:
|
||||
parsed.deliveryMode === 'pickup' || parsed.deliveryMode === 'delivery'
|
||||
? parsed.deliveryMode
|
||||
: defaultState.deliveryMode,
|
||||
selectedAddressId:
|
||||
typeof parsed.selectedAddressId === 'string' && parsed.selectedAddressId
|
||||
? parsed.selectedAddressId
|
||||
: null,
|
||||
discountCode:
|
||||
typeof parsed.discountCode === 'string' ? parsed.discountCode : '',
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
deliveryMode: defaultState.deliveryMode,
|
||||
selectedAddressId: defaultState.selectedAddressId,
|
||||
discountCode: defaultState.discountCode,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function persistPartial(state: CheckoutState) {
|
||||
try {
|
||||
sessionStorage.setItem(
|
||||
storageKey(),
|
||||
JSON.stringify({
|
||||
deliveryMode: state.deliveryMode,
|
||||
selectedAddressId: state.selectedAddressId,
|
||||
discountCode: state.discountCode,
|
||||
}),
|
||||
)
|
||||
} catch {
|
||||
// ignore quota / private mode
|
||||
}
|
||||
}
|
||||
|
||||
const CheckoutContext = createContext<CheckoutContextValue | null>(null)
|
||||
|
||||
export function CheckoutProvider({ children }: { children: ReactNode }) {
|
||||
const [state, setState] = useState<CheckoutState>(defaultState)
|
||||
const [state, setState] = useState<CheckoutState>(() => ({
|
||||
...defaultState,
|
||||
...readPersisted(),
|
||||
}))
|
||||
|
||||
const setDeliveryMode = useCallback((deliveryMode: DeliveryMode) => {
|
||||
setState((prev) => ({ ...prev, deliveryMode }))
|
||||
setState((prev) => {
|
||||
const next = { ...prev, deliveryMode }
|
||||
persistPartial(next)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const setSelectedAddressId = useCallback((selectedAddressId: string | null) => {
|
||||
setState((prev) =>
|
||||
prev.selectedAddressId === selectedAddressId ? prev : { ...prev, selectedAddressId },
|
||||
)
|
||||
const normalized =
|
||||
selectedAddressId === null || selectedAddressId === undefined
|
||||
? null
|
||||
: String(selectedAddressId)
|
||||
setState((prev) => {
|
||||
if (prev.selectedAddressId === normalized) return prev
|
||||
const next = { ...prev, selectedAddressId: normalized }
|
||||
persistPartial(next)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const setDiscountCode = useCallback((discountCode: string) => {
|
||||
setState((prev) => ({ ...prev, discountCode }))
|
||||
setState((prev) => {
|
||||
const next = { ...prev, discountCode }
|
||||
persistPartial(next)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const setPlacedOrder = useCallback((placedOrder: Order | null) => {
|
||||
@@ -50,6 +129,11 @@ export function CheckoutProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const resetCheckout = useCallback(() => {
|
||||
setState(defaultState)
|
||||
try {
|
||||
sessionStorage.removeItem(storageKey())
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [])
|
||||
|
||||
const value = useMemo<CheckoutContextValue>(
|
||||
|
||||
@@ -133,6 +133,8 @@ const en = {
|
||||
'orders.source.operator': 'Operator',
|
||||
'orders.source.app': 'Application',
|
||||
'orders.source.website': 'Website',
|
||||
'orders.step.awaitingPayment': 'Waiting for payment',
|
||||
'orders.step.paymentSuccessful': 'Payment successful',
|
||||
'orders.step.processing': 'Under processing',
|
||||
'orders.step.ready': 'Ready for shipping',
|
||||
'orders.step.shipped': 'Shipped',
|
||||
@@ -344,7 +346,7 @@ const en = {
|
||||
'signup.info.verifyExisting':
|
||||
'Enter the SMS code to join this website. Your Meshkee password and profile stay the same.',
|
||||
'signup.info.verifyExistingPasswordUpdated':
|
||||
'Enter the SMS code to finish joining. Your Meshkee password was updated to the one you just entered.',
|
||||
'Enter the SMS code to finish joining. Your Meshkee password will then be updated to the one you just entered.',
|
||||
'signup.toast.passwordUpdated':
|
||||
'Your Meshkee password was updated to the one you entered on this website.',
|
||||
'signup.cellExists.title': 'Existing Meshkee account',
|
||||
@@ -531,6 +533,8 @@ const fa: Record<MessageKey, string> = {
|
||||
'orders.source.operator': 'اپراتور',
|
||||
'orders.source.app': 'اپلیکیشن',
|
||||
'orders.source.website': 'وبسایت',
|
||||
'orders.step.awaitingPayment': 'در انتظار پرداخت',
|
||||
'orders.step.paymentSuccessful': 'پرداخت موفق',
|
||||
'orders.step.processing': 'در حال پردازش',
|
||||
'orders.step.ready': 'آماده ارسال',
|
||||
'orders.step.shipped': 'ارسالشده',
|
||||
@@ -742,7 +746,7 @@ const fa: Record<MessageKey, string> = {
|
||||
'signup.info.verifyExisting':
|
||||
'کد پیامکی را وارد کنید تا به این وبسایت بپیوندید. رمز عبور و اطلاعات مشکی شما همان میماند.',
|
||||
'signup.info.verifyExistingPasswordUpdated':
|
||||
'کد پیامکی را وارد کنید تا عضویت تمام شود. رمز مشکی شما به رمزی که همین الان وارد کردید بهروز شد.',
|
||||
'کد پیامکی را وارد کنید تا عضویت تمام شود. پس از تأیید، رمز مشکی شما به رمزی که همین الان وارد کردید بهروز میشود.',
|
||||
'signup.toast.passwordUpdated':
|
||||
'رمز مشکی شما به رمزی که در این وبسایت وارد کردید بهروز شد.',
|
||||
'signup.cellExists.title': 'حساب موجود در مشکی',
|
||||
|
||||
@@ -226,27 +226,30 @@ export function LoginPage() {
|
||||
|
||||
setCellExistsSites(null)
|
||||
|
||||
// Password is applied only after OTP when passwordPending; toast then.
|
||||
if (data.passwordUpdated) {
|
||||
showToast(t('signup.toast.passwordUpdated'), 'info')
|
||||
}
|
||||
|
||||
if (data.requiresOtp || data.passwordPending || !data.user.cellVerifiedAt) {
|
||||
if (data.accessToken) {
|
||||
logoutRequest()
|
||||
}
|
||||
await continueToSmsVerify(
|
||||
cellNumber,
|
||||
data.passwordPending
|
||||
? t('signup.info.verifyExistingPasswordUpdated')
|
||||
: t('signup.info.verify'),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (data.user.customerBusinesses.length === 0) {
|
||||
logoutRequest()
|
||||
setError(CUSTOMER_ACCESS_MESSAGE)
|
||||
return
|
||||
}
|
||||
|
||||
if (!data.user.cellVerifiedAt) {
|
||||
logoutRequest()
|
||||
await continueToSmsVerify(
|
||||
cellNumber,
|
||||
data.passwordUpdated
|
||||
? t('signup.info.verifyExistingPasswordUpdated')
|
||||
: t('signup.info.verify'),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
await login(cellNumber, password)
|
||||
navigate(redirectTo)
|
||||
} catch (err) {
|
||||
@@ -356,7 +359,10 @@ export function LoginPage() {
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
await loginWithOtp(cellNumber, smsCode)
|
||||
const result = await loginWithOtp(cellNumber, smsCode)
|
||||
if (result.passwordUpdated) {
|
||||
showToast(t('signup.toast.passwordUpdated'), 'info')
|
||||
}
|
||||
navigate(redirectTo)
|
||||
} catch (err) {
|
||||
handleApiError(err, t('otp.error.signIn'))
|
||||
|
||||
@@ -117,7 +117,8 @@ export function CheckoutPaymentStep() {
|
||||
setError('')
|
||||
|
||||
if (deliveryMode === 'delivery' && !selectedAddressId) {
|
||||
setError('لطفاً یک آدرس ارسال انتخاب کنید.')
|
||||
showToast('لطفاً یک آدرس ارسال انتخاب کنید.', 'error')
|
||||
navigate('/checkout/delivery', { replace: true })
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -621,7 +621,7 @@
|
||||
|
||||
.successMeta {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
max-width: 540px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
@@ -645,6 +645,7 @@
|
||||
}
|
||||
|
||||
.successMetaRow dt {
|
||||
flex: 0 0 auto;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
@@ -653,10 +654,13 @@
|
||||
|
||||
.successMetaRow dd {
|
||||
margin: 0;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
text-align: left;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.successMetaValue {
|
||||
|
||||
@@ -39,7 +39,9 @@ export async function register(input: {
|
||||
body: input,
|
||||
})
|
||||
|
||||
setTokens(data.accessToken, data.refreshToken)
|
||||
if (data.accessToken && data.refreshToken) {
|
||||
setTokens(data.accessToken, data.refreshToken)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,18 @@ export interface OrderProcessStep {
|
||||
}
|
||||
|
||||
export const DEFAULT_ORDER_PROCESS_STEPS: OrderProcessStep[] = [
|
||||
{
|
||||
id: 'awaiting-payment',
|
||||
label: 'Waiting for payment',
|
||||
labelFa: 'در انتظار پرداخت',
|
||||
color: '#64748B',
|
||||
},
|
||||
{
|
||||
id: 'payment-successful',
|
||||
label: 'Payment successful',
|
||||
labelFa: 'پرداخت موفق',
|
||||
color: '#10B981',
|
||||
},
|
||||
{
|
||||
id: 'processing',
|
||||
label: 'Under processing',
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Eraser, MoreHorizontal, Trash2, Users } from 'lucide-react'
|
||||
import {
|
||||
Building2,
|
||||
Eraser,
|
||||
ExternalLink,
|
||||
Globe,
|
||||
MoreHorizontal,
|
||||
Trash2,
|
||||
UserRound,
|
||||
Users,
|
||||
} from 'lucide-react'
|
||||
import { fitFixedPopover } from '../utils/fitFixedPopover'
|
||||
import styles from './BusinessActionsOverflowMenu.module.css'
|
||||
|
||||
@@ -11,13 +20,32 @@ type PopoverPosition = {
|
||||
|
||||
interface BusinessActionsOverflowMenuProps {
|
||||
businessName: string
|
||||
/** Primary apex domain host when set — enables website / dashboard links. */
|
||||
host?: string | null
|
||||
onAllUsers: () => void
|
||||
onWipeData: () => void
|
||||
onRemove: () => void
|
||||
}
|
||||
|
||||
function apexHost(host: string) {
|
||||
return host.trim().replace(/^www\./i, '')
|
||||
}
|
||||
|
||||
function siteProtocol(host: string) {
|
||||
return /\.local$/i.test(host) ? 'http:' : 'https:'
|
||||
}
|
||||
|
||||
function siteUrl(host: string) {
|
||||
return `${siteProtocol(host)}//${host}`
|
||||
}
|
||||
|
||||
function openHost(host: string) {
|
||||
window.open(siteUrl(host), '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
export function BusinessActionsOverflowMenu({
|
||||
businessName,
|
||||
host,
|
||||
onAllUsers,
|
||||
onWipeData,
|
||||
onRemove,
|
||||
@@ -28,13 +56,40 @@ export function BusinessActionsOverflowMenu({
|
||||
const popoverRef = useRef<HTMLDivElement>(null)
|
||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
const apex = host?.trim() ? apexHost(host) : null
|
||||
const linkItems = apex
|
||||
? ([
|
||||
{
|
||||
key: 'website',
|
||||
label: 'باز کردن وبسایت',
|
||||
detail: apex,
|
||||
icon: Globe,
|
||||
onClick: () => openHost(apex),
|
||||
},
|
||||
{
|
||||
key: 'business',
|
||||
label: 'داشبورد کسبوکار',
|
||||
detail: `business.${apex}`,
|
||||
icon: Building2,
|
||||
onClick: () => openHost(`business.${apex}`),
|
||||
},
|
||||
{
|
||||
key: 'customer',
|
||||
label: 'داشبورد مشتری',
|
||||
detail: `customer.${apex}`,
|
||||
icon: UserRound,
|
||||
onClick: () => openHost(`customer.${apex}`),
|
||||
},
|
||||
] as const)
|
||||
: []
|
||||
|
||||
function updatePopoverPosition() {
|
||||
const rect = triggerRef.current?.getBoundingClientRect()
|
||||
if (!rect) return
|
||||
|
||||
const size = popoverRef.current
|
||||
? { width: popoverRef.current.offsetWidth, height: popoverRef.current.offsetHeight }
|
||||
: { width: 220, height: 160 }
|
||||
: { width: 220, height: linkItems.length ? 280 : 160 }
|
||||
|
||||
setPopoverPos(fitFixedPopover(rect, size, 'end'))
|
||||
}
|
||||
@@ -96,8 +151,8 @@ export function BusinessActionsOverflowMenu({
|
||||
e.stopPropagation()
|
||||
toggleOpen()
|
||||
}}
|
||||
title="More actions"
|
||||
aria-label={`More actions for ${businessName}`}
|
||||
title="عملیات بیشتر"
|
||||
aria-label={`عملیات بیشتر برای ${businessName}`}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
>
|
||||
@@ -111,12 +166,36 @@ export function BusinessActionsOverflowMenu({
|
||||
ref={popoverRef}
|
||||
className={styles.menu}
|
||||
role="menu"
|
||||
aria-label={`More actions for ${businessName}`}
|
||||
aria-label={`عملیات بیشتر برای ${businessName}`}
|
||||
lang="fa"
|
||||
dir="rtl"
|
||||
style={{
|
||||
top: popoverPos.top,
|
||||
left: popoverPos.left,
|
||||
}}
|
||||
>
|
||||
{linkItems.map(({ key, label, detail, icon: Icon, onClick }) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
className={styles.item}
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
onClick()
|
||||
}}
|
||||
>
|
||||
<Icon size={16} />
|
||||
<span className={styles.itemText}>
|
||||
<span>{label}</span>
|
||||
<span className={styles.itemDetail} dir="ltr">
|
||||
{detail}
|
||||
<ExternalLink size={12} aria-hidden="true" />
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{linkItems.length > 0 && <div className={styles.menuDivider} role="separator" />}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.item}
|
||||
@@ -127,7 +206,7 @@ export function BusinessActionsOverflowMenu({
|
||||
}}
|
||||
>
|
||||
<Users size={16} />
|
||||
<span>All users</span>
|
||||
<span>همه کاربران</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -141,7 +220,7 @@ export function BusinessActionsOverflowMenu({
|
||||
}}
|
||||
>
|
||||
<Eraser size={16} />
|
||||
<span>Wipe data</span>
|
||||
<span>پاکسازی داده</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -153,7 +232,7 @@ export function BusinessActionsOverflowMenu({
|
||||
}}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
<span>Remove</span>
|
||||
<span>حذف</span>
|
||||
</button>
|
||||
</div>,
|
||||
document.body,
|
||||
|
||||
@@ -109,21 +109,21 @@ export function DomainLinksOverflowMenu({ host, onRemove }: DomainLinksOverflowM
|
||||
const items = [
|
||||
{
|
||||
key: 'website',
|
||||
label: 'Open website',
|
||||
label: 'باز کردن وبسایت',
|
||||
detail: websiteHost,
|
||||
icon: Globe,
|
||||
onClick: () => openHost(websiteHost),
|
||||
},
|
||||
{
|
||||
key: 'business',
|
||||
label: 'Business dashboard',
|
||||
label: 'داشبورد کسبوکار',
|
||||
detail: businessHost,
|
||||
icon: Building2,
|
||||
onClick: () => openHost(businessHost),
|
||||
},
|
||||
{
|
||||
key: 'customer',
|
||||
label: 'Customer dashboard',
|
||||
label: 'داشبورد مشتری',
|
||||
detail: customerHost,
|
||||
icon: UserRound,
|
||||
onClick: () => openHost(customerHost),
|
||||
@@ -141,8 +141,8 @@ export function DomainLinksOverflowMenu({ host, onRemove }: DomainLinksOverflowM
|
||||
e.stopPropagation()
|
||||
toggleOpen()
|
||||
}}
|
||||
title="More actions"
|
||||
aria-label={`More actions for ${apex}`}
|
||||
title="عملیات بیشتر"
|
||||
aria-label={`عملیات بیشتر برای ${apex}`}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
>
|
||||
@@ -156,7 +156,9 @@ export function DomainLinksOverflowMenu({ host, onRemove }: DomainLinksOverflowM
|
||||
ref={popoverRef}
|
||||
className={styles.menu}
|
||||
role="menu"
|
||||
aria-label={`More actions for ${apex}`}
|
||||
aria-label={`عملیات بیشتر برای ${apex}`}
|
||||
lang="fa"
|
||||
dir="rtl"
|
||||
style={{
|
||||
top: popoverPos.top,
|
||||
left: popoverPos.left,
|
||||
@@ -176,7 +178,7 @@ export function DomainLinksOverflowMenu({ host, onRemove }: DomainLinksOverflowM
|
||||
<Icon size={16} />
|
||||
<span className={styles.itemText}>
|
||||
<span>{label}</span>
|
||||
<span className={styles.itemDetail}>
|
||||
<span className={styles.itemDetail} dir="ltr">
|
||||
{detail}
|
||||
<ExternalLink size={12} aria-hidden="true" />
|
||||
</span>
|
||||
@@ -194,7 +196,7 @@ export function DomainLinksOverflowMenu({ host, onRemove }: DomainLinksOverflowM
|
||||
}}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
<span>Remove</span>
|
||||
<span>حذف</span>
|
||||
</button>
|
||||
</div>,
|
||||
document.body,
|
||||
|
||||
@@ -1347,6 +1347,7 @@ export function BusinessesPage() {
|
||||
</button>
|
||||
<BusinessActionsOverflowMenu
|
||||
businessName={b.name}
|
||||
host={b.domain}
|
||||
onAllUsers={() => goToBusinessUsers(b, 'all')}
|
||||
onWipeData={() => openPurge(b)}
|
||||
onRemove={() => setRemoveTarget(b)}
|
||||
|
||||
@@ -65,3 +65,69 @@
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.deployErrorTip {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin: 0 0 14px;
|
||||
padding: 14px 16px;
|
||||
border-radius: var(--radius-md, 12px);
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
border: 1px solid rgba(239, 68, 68, 0.28);
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.deployErrorIcon {
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.deployErrorBody {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.deployErrorTitle {
|
||||
margin: 0 0 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.deployErrorDetail {
|
||||
margin: 0;
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-family: var(--font-ui), ui-monospace, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: #7f1d1d;
|
||||
}
|
||||
|
||||
.deployErrorDismiss {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm, 8px);
|
||||
background: transparent;
|
||||
color: #991b1b;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.deployErrorDismiss:hover {
|
||||
background: rgba(185, 28, 28, 0.12);
|
||||
}
|
||||
|
||||
.listErrorWrap {
|
||||
padding: 0 16px 12px;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
RotateCcw,
|
||||
Search,
|
||||
ShieldPlus,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
|
||||
import { DomainLinksOverflowMenu } from '../components/DomainLinksOverflowMenu'
|
||||
@@ -107,6 +108,7 @@ export function WebsitesPage() {
|
||||
const [removeTarget, setRemoveTarget] = useState<DomainListItem | null>(null)
|
||||
const [togglingActiveId, setTogglingActiveId] = useState<number | null>(null)
|
||||
const [deployingId, setDeployingId] = useState<DomainListItem['id'] | null>(null)
|
||||
const [deployError, setDeployError] = useState('')
|
||||
const [syncingSsl, setSyncingSsl] = useState(false)
|
||||
const [issuingWebsiteSsl, setIssuingWebsiteSsl] = useState(false)
|
||||
const [issuingSslId, setIssuingSslId] = useState<DomainListItem['id'] | null>(null)
|
||||
@@ -356,7 +358,7 @@ export function WebsitesPage() {
|
||||
flushSync(() => {
|
||||
setDeployingId(domain.id)
|
||||
})
|
||||
setError('')
|
||||
setDeployError('')
|
||||
showToast(`Deploying "${domain.host}"…`, 'info')
|
||||
|
||||
try {
|
||||
@@ -376,15 +378,17 @@ export function WebsitesPage() {
|
||||
),
|
||||
}
|
||||
})
|
||||
showToast(
|
||||
result.lastDeployStatus === 'success'
|
||||
? `Deploy succeeded for "${result.host}".`
|
||||
: `Deploy finished for "${result.host}" (${result.lastDeployStatus ?? 'unknown'}).`,
|
||||
result.lastDeployStatus === 'success' ? 'success' : 'info',
|
||||
)
|
||||
if (result.lastDeployStatus === 'success') {
|
||||
showToast(`Deploy succeeded for "${result.host}".`, 'success')
|
||||
return
|
||||
}
|
||||
const detail =
|
||||
result.message?.trim() ||
|
||||
`Deploy finished for "${result.host}" with status ${result.lastDeployStatus ?? 'unknown'}.`
|
||||
setDeployError(`${result.host}: ${detail}`)
|
||||
} catch (err) {
|
||||
const message = err instanceof ApiError ? err.message : 'Deploy failed.'
|
||||
setError(message)
|
||||
setDeployError(`${domain.host}: ${message}`)
|
||||
setData((prev) => {
|
||||
if (!prev) return prev
|
||||
return {
|
||||
@@ -400,7 +404,6 @@ export function WebsitesPage() {
|
||||
),
|
||||
}
|
||||
})
|
||||
showToast(message, 'error')
|
||||
} finally {
|
||||
setDeployingId(null)
|
||||
}
|
||||
@@ -511,6 +514,24 @@ export function WebsitesPage() {
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{deployError ? (
|
||||
<div className={styles.deployErrorTip} role="alert">
|
||||
<AlertTriangle size={18} className={styles.deployErrorIcon} aria-hidden />
|
||||
<div className={styles.deployErrorBody}>
|
||||
<p className={styles.deployErrorTitle}>Deploy failed</p>
|
||||
<pre className={styles.deployErrorDetail}>{deployError}</pre>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.deployErrorDismiss}
|
||||
onClick={() => setDeployError('')}
|
||||
aria-label="Dismiss deploy error"
|
||||
>
|
||||
<X size={16} aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={tableStyles.tablePanel}>
|
||||
<div className={tableStyles.tableWrap}>
|
||||
<div className={tableStyles.tableHeader}>
|
||||
@@ -527,10 +548,10 @@ export function WebsitesPage() {
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div style={{ padding: 16 }}>
|
||||
<div className={tableStyles.meta} style={{ color: '#b91c1c' }}>
|
||||
<div className={styles.listErrorWrap}>
|
||||
<p className={tableStyles.alertError} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user