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
|
open: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSubmit: (variation: Omit<Variation, 'id'>) => 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
|
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 t = useT()
|
||||||
const { locale } = useLocale()
|
const { locale } = useLocale()
|
||||||
const isFa = locale === 'fa'
|
const isFa = locale === 'fa'
|
||||||
|
const isEdit = Boolean(initial)
|
||||||
const [mounted, setMounted] = useState(open)
|
const [mounted, setMounted] = useState(open)
|
||||||
const [closing, setClosing] = useState(false)
|
const [closing, setClosing] = useState(false)
|
||||||
const [type, setType] = useState<VariationType>('color')
|
const [type, setType] = useState<VariationType>('color')
|
||||||
@@ -41,11 +49,21 @@ export function AddVariationModal({ open, onClose, onSubmit }: AddVariationModal
|
|||||||
if (open) {
|
if (open) {
|
||||||
setMounted(true)
|
setMounted(true)
|
||||||
setClosing(false)
|
setClosing(false)
|
||||||
setType('color')
|
if (initial) {
|
||||||
setCustomName('')
|
setType(initial.type)
|
||||||
setSelectedColors([])
|
setCustomName(initial.type === 'custom' ? initial.name : '')
|
||||||
setSelectedSizes([])
|
setSelectedColors(initial.type === 'color' ? [...initial.values] : [])
|
||||||
setCustomValues([''])
|
setSelectedSizes(initial.type === 'size' ? [...initial.values] : [])
|
||||||
|
setCustomValues(
|
||||||
|
initial.type === 'custom' ? seedCustomValues(initial.values) : [''],
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
setType('color')
|
||||||
|
setCustomName('')
|
||||||
|
setSelectedColors([])
|
||||||
|
setSelectedSizes([])
|
||||||
|
setCustomValues([''])
|
||||||
|
}
|
||||||
} else if (mounted) {
|
} else if (mounted) {
|
||||||
setClosing(true)
|
setClosing(true)
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
@@ -54,7 +72,7 @@ export function AddVariationModal({ open, onClose, onSubmit }: AddVariationModal
|
|||||||
}, ANIMATION_MS)
|
}, ANIMATION_MS)
|
||||||
return () => clearTimeout(timer)
|
return () => clearTimeout(timer)
|
||||||
}
|
}
|
||||||
}, [open, mounted])
|
}, [open, mounted, initial])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mounted || closing) return
|
if (!mounted || closing) return
|
||||||
@@ -128,7 +146,7 @@ export function AddVariationModal({ open, onClose, onSubmit }: AddVariationModal
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`${styles.modal} ${closing ? styles.modalOut : styles.modalIn}`}
|
className={`${styles.modal} ${styles.modalFormWide} ${closing ? styles.modalOut : styles.modalIn}`}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
@@ -138,7 +156,7 @@ export function AddVariationModal({ open, onClose, onSubmit }: AddVariationModal
|
|||||||
>
|
>
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
<h3 id="add-variation-title" className={styles.title}>
|
<h3 id="add-variation-title" className={styles.title}>
|
||||||
{t('categories.variations.addTitle')}
|
{isEdit ? t('categories.variations.editTitle') : t('categories.variations.addTitle')}
|
||||||
</h3>
|
</h3>
|
||||||
<button className={styles.closeBtn} onClick={onClose} aria-label={t('common.close')}>
|
<button className={styles.closeBtn} onClick={onClose} aria-label={t('common.close')}>
|
||||||
<X size={20} />
|
<X size={20} />
|
||||||
@@ -151,6 +169,7 @@ export function AddVariationModal({ open, onClose, onSubmit }: AddVariationModal
|
|||||||
<select
|
<select
|
||||||
id="variationType"
|
id="variationType"
|
||||||
value={type}
|
value={type}
|
||||||
|
disabled={isEdit}
|
||||||
onChange={(e) => setType(e.target.value as VariationType)}
|
onChange={(e) => setType(e.target.value as VariationType)}
|
||||||
>
|
>
|
||||||
<option value="color">{t('categories.variations.type.color')}</option>
|
<option value="color">{t('categories.variations.type.color')}</option>
|
||||||
@@ -250,7 +269,7 @@ export function AddVariationModal({ open, onClose, onSubmit }: AddVariationModal
|
|||||||
{t('categories.modal.cancel')}
|
{t('categories.modal.cancel')}
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" className={styles.submitBtn} disabled={!canSubmit}>
|
<button type="submit" className={styles.submitBtn} disabled={!canSubmit}>
|
||||||
{t('categories.variations.submit')}
|
{isEdit ? t('categories.variations.saveEdit') : t('categories.variations.submit')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { X } from 'lucide-react'
|
import { X } from 'lucide-react'
|
||||||
|
import { useT } from '../i18n/useT'
|
||||||
import type { Order } from '../services/orderService'
|
import type { Order } from '../services/orderService'
|
||||||
import { formatCellForDisplay } from '../lib/cellNumber'
|
import { formatCellForDisplay } from '../lib/cellNumber'
|
||||||
import { formatIrtPrice } from '../utils/irtPrice'
|
import { formatIrtPrice } from '../utils/irtPrice'
|
||||||
@@ -30,6 +31,7 @@ function totalQuantity(order: Order) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps) {
|
export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps) {
|
||||||
|
const t = useT()
|
||||||
const [mounted, setMounted] = useState(open)
|
const [mounted, setMounted] = useState(open)
|
||||||
const [closing, setClosing] = useState(false)
|
const [closing, setClosing] = useState(false)
|
||||||
|
|
||||||
@@ -75,11 +77,16 @@ export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps)
|
|||||||
<div className={modalStyles.header}>
|
<div className={modalStyles.header}>
|
||||||
<div>
|
<div>
|
||||||
<h2 id="order-items-title" className={modalStyles.title}>
|
<h2 id="order-items-title" className={modalStyles.title}>
|
||||||
Order items
|
{t('orderItems.title')}
|
||||||
</h2>
|
</h2>
|
||||||
<p className={modalStyles.subtitle}>{order.orderNumber}</p>
|
<p className={modalStyles.subtitle}>{order.orderNumber}</p>
|
||||||
</div>
|
</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} />
|
<X size={18} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -87,26 +94,27 @@ export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps)
|
|||||||
<div className={`${modalStyles.body} ${styles.body}`}>
|
<div className={`${modalStyles.body} ${styles.body}`}>
|
||||||
<div className={styles.metaRow}>
|
<div className={styles.metaRow}>
|
||||||
<span className={styles.metaItem}>
|
<span className={styles.metaItem}>
|
||||||
Customer: <strong>{displayName(order)}</strong>
|
{t('orderItems.customer')}: <strong>{displayName(order)}</strong>
|
||||||
</span>
|
</span>
|
||||||
<span className={styles.metaItem}>
|
<span className={styles.metaItem}>
|
||||||
Phone: <strong>{formatCellForDisplay(order.customer.cellNumber)}</strong>
|
{t('orderItems.phone')}:{' '}
|
||||||
|
<strong>{formatCellForDisplay(order.customer.cellNumber)}</strong>
|
||||||
</span>
|
</span>
|
||||||
<span className={styles.metaItem}>
|
<span className={styles.metaItem}>
|
||||||
Items: <strong>{itemCount}</strong>
|
{t('orderItems.items')}: <strong>{itemCount}</strong>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{order.items.length === 0 ? (
|
{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.tableBlock}>
|
||||||
<div className={styles.tableHead}>
|
<div className={styles.tableHead}>
|
||||||
<span aria-hidden="true" />
|
<span aria-hidden="true" />
|
||||||
<span>Product</span>
|
<span>{t('orderItems.product')}</span>
|
||||||
<span>Qty</span>
|
<span>{t('orderItems.qty')}</span>
|
||||||
<span>Unit price</span>
|
<span>{t('orderItems.unitPrice')}</span>
|
||||||
<span>Line total</span>
|
<span>{t('orderItems.lineTotal')}</span>
|
||||||
</div>
|
</div>
|
||||||
<ul className={styles.itemList}>
|
<ul className={styles.itemList}>
|
||||||
{order.items.map((item) => (
|
{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.itemTitle}>{item.productTitle}</div>
|
||||||
<div className={styles.itemVariant}>{formatVariantLabel(item.selections)}</div>
|
<div className={styles.itemVariant}>{formatVariantLabel(item.selections)}</div>
|
||||||
{item.variantSku && (
|
{item.variantSku && (
|
||||||
<div className={styles.itemSku}>SKU: {item.variantSku}</div>
|
<div className={styles.itemSku}>
|
||||||
|
{t('orderItems.sku', { sku: item.variantSku })}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.qtyCell}>{item.quantity}</div>
|
<div className={styles.qtyCell}>{item.quantity}</div>
|
||||||
@@ -136,20 +146,23 @@ export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps)
|
|||||||
|
|
||||||
<div className={styles.summary}>
|
<div className={styles.summary}>
|
||||||
<span className={styles.summaryLabel}>
|
<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>
|
||||||
<span className={styles.summaryValue}>{formatIrtPrice(order.total)}</span>
|
<span className={styles.summaryValue}>{formatIrtPrice(order.total)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={modalStyles.actions}>
|
<div className={modalStyles.actions}>
|
||||||
<button type="button" className={modalStyles.cancelBtn} onClick={onClose}>
|
<button type="button" className={modalStyles.cancelBtn} onClick={onClose}>
|
||||||
Close
|
{t('orderItems.close')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
,
|
document.body,
|
||||||
document.body,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
text-align: left;
|
text-align: start;
|
||||||
border: 1px solid rgba(148, 163, 184, 0.3);
|
border: 1px solid rgba(148, 163, 184, 0.3);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: rgba(255, 255, 255, 0.45);
|
background: rgba(255, 255, 255, 0.45);
|
||||||
@@ -55,8 +55,8 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--primary);
|
color: #111827;
|
||||||
background: rgba(var(--primary-rgb) / 0.12);
|
background: #e5e7eb;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { X } from 'lucide-react'
|
import { X } from 'lucide-react'
|
||||||
|
import { useLocale } from '@meshkee/dashboard-ui'
|
||||||
import { useToast } from '../context/ToastContext'
|
import { useToast } from '../context/ToastContext'
|
||||||
|
import { useT } from '../i18n/useT'
|
||||||
import { ApiError } from '../lib/api'
|
import { ApiError } from '../lib/api'
|
||||||
import { updateOrder, type Order } from '../services/orderService'
|
import { updateOrder, type Order } from '../services/orderService'
|
||||||
import type { OrderProcessStep } from '../services/settingsService'
|
import type { OrderProcessStep } from '../services/settingsService'
|
||||||
@@ -19,6 +21,14 @@ interface OrderStepModalProps {
|
|||||||
|
|
||||||
const ANIMATION_MS = 220
|
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({
|
export function OrderStepModal({
|
||||||
open,
|
open,
|
||||||
order,
|
order,
|
||||||
@@ -26,6 +36,8 @@ export function OrderStepModal({
|
|||||||
onClose,
|
onClose,
|
||||||
onSaved,
|
onSaved,
|
||||||
}: OrderStepModalProps) {
|
}: OrderStepModalProps) {
|
||||||
|
const t = useT()
|
||||||
|
const { locale } = useLocale()
|
||||||
const { showToast } = useToast()
|
const { showToast } = useToast()
|
||||||
const [mounted, setMounted] = useState(open)
|
const [mounted, setMounted] = useState(open)
|
||||||
const [closing, setClosing] = useState(false)
|
const [closing, setClosing] = useState(false)
|
||||||
@@ -70,11 +82,11 @@ export function OrderStepModal({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await updateOrder(order.id, { processStepId: stepId })
|
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)
|
onSaved?.(result.order)
|
||||||
onClose()
|
onClose()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof ApiError ? err.message : 'Unable to update order step.')
|
setError(err instanceof ApiError ? err.message : t('orderStep.error'))
|
||||||
} finally {
|
} finally {
|
||||||
setSavingStepId(null)
|
setSavingStepId(null)
|
||||||
}
|
}
|
||||||
@@ -82,9 +94,15 @@ export function OrderStepModal({
|
|||||||
|
|
||||||
if (!mounted || !order) return null
|
if (!mounted || !order) return null
|
||||||
|
|
||||||
|
const currentStep =
|
||||||
|
steps.find((step) => step.id === order.processStepId) ??
|
||||||
|
null
|
||||||
const currentLabel =
|
const currentLabel =
|
||||||
|
(locale === 'fa'
|
||||||
|
? order.processStepLabelFa?.trim() || currentStep?.labelFa?.trim()
|
||||||
|
: null) ||
|
||||||
order.processStepLabel?.trim() ||
|
order.processStepLabel?.trim() ||
|
||||||
steps.find((step) => step.id === order.processStepId)?.label ||
|
currentStep?.label ||
|
||||||
order.processStepId
|
order.processStepId
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
@@ -100,14 +118,19 @@ export function OrderStepModal({
|
|||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="order-step-title"
|
aria-labelledby="order-step-title"
|
||||||
|
lang={locale === 'fa' ? 'fa' : undefined}
|
||||||
|
dir={locale === 'fa' ? 'rtl' : undefined}
|
||||||
>
|
>
|
||||||
<div className={modalStyles.header}>
|
<div className={modalStyles.header}>
|
||||||
<div>
|
<div>
|
||||||
<h2 id="order-step-title" className={modalStyles.title}>
|
<h2 id="order-step-title" className={modalStyles.title}>
|
||||||
Change order step
|
{t('orderStep.title')}
|
||||||
</h2>
|
</h2>
|
||||||
<p className={modalStyles.subtitle}>
|
<p className={modalStyles.subtitle}>
|
||||||
{order.orderNumber} · Current: {currentLabel}
|
{t('orderStep.current', {
|
||||||
|
orderNumber: order.orderNumber,
|
||||||
|
step: currentLabel,
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -115,7 +138,7 @@ export function OrderStepModal({
|
|||||||
className={modalStyles.closeBtn}
|
className={modalStyles.closeBtn}
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
disabled={Boolean(savingStepId)}
|
disabled={Boolean(savingStepId)}
|
||||||
aria-label="Close"
|
aria-label={t('common.close')}
|
||||||
>
|
>
|
||||||
<X size={20} />
|
<X size={20} />
|
||||||
</button>
|
</button>
|
||||||
@@ -128,11 +151,12 @@ export function OrderStepModal({
|
|||||||
</div>
|
</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) => {
|
{steps.map((step, index) => {
|
||||||
const selected = order.processStepId === step.id
|
const selected = order.processStepId === step.id
|
||||||
const saving = savingStepId === step.id
|
const saving = savingStepId === step.id
|
||||||
const color = normalizeStepColor(step.color, defaultStepColorForId(step.id, index))
|
const color = normalizeStepColor(step.color, defaultStepColorForId(step.id, index))
|
||||||
|
const label = stepDisplayLabel(step, locale)
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={step.id}
|
key={step.id}
|
||||||
@@ -147,7 +171,7 @@ export function OrderStepModal({
|
|||||||
<span className={styles.stepColorDot} style={{ backgroundColor: color }} />
|
<span className={styles.stepColorDot} style={{ backgroundColor: color }} />
|
||||||
<span className={styles.stepNumber}>{index + 1}</span>
|
<span className={styles.stepNumber}>{index + 1}</span>
|
||||||
<span className={styles.stepLabel}>
|
<span className={styles.stepLabel}>
|
||||||
{saving ? 'Saving...' : step.label}
|
{saving ? t('orderStep.saving') : label}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
@@ -155,14 +179,11 @@ export function OrderStepModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!steps.length && (
|
{!steps.length && (
|
||||||
<p className={styles.emptyText}>
|
<p className={styles.emptyText}>{t('orderStep.empty')}</p>
|
||||||
No order steps configured. Add steps in Store settings first.
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
,
|
document.body,
|
||||||
document.body,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { useEffect, useMemo, useState } from 'react'
|
|||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { X } from 'lucide-react'
|
import { X } from 'lucide-react'
|
||||||
import { useLocale } from '@meshkee/dashboard-ui'
|
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 type { Order, OrderTransaction, TransactionStatus, TransactionType } from '../services/orderService'
|
||||||
import { formatIrtPrice } from '../utils/irtPrice'
|
import { formatIrtPrice } from '../utils/irtPrice'
|
||||||
import modalStyles from './VariationsModal.module.css'
|
import modalStyles from './VariationsModal.module.css'
|
||||||
@@ -15,19 +17,18 @@ interface OrderTransactionsModalProps {
|
|||||||
|
|
||||||
const ANIMATION_MS = 220
|
const ANIMATION_MS = 220
|
||||||
|
|
||||||
function transactionTypeLabel(type: TransactionType) {
|
const TYPE_KEYS: Record<TransactionType, BusinessMessageKey> = {
|
||||||
switch (type) {
|
pos: 'orderTransactions.type.pos',
|
||||||
case 'pos':
|
cash: 'orderTransactions.type.cash',
|
||||||
return 'POS'
|
transfer: 'orderTransactions.type.transfer',
|
||||||
case 'cash':
|
e_payment_gate: 'orderTransactions.type.e_payment_gate',
|
||||||
return 'Cash'
|
}
|
||||||
case 'transfer':
|
|
||||||
return 'Transfer'
|
const STATUS_KEYS: Record<TransactionStatus, BusinessMessageKey> = {
|
||||||
case 'e_payment_gate':
|
pending: 'orderTransactions.status.pending',
|
||||||
return 'E-payment gateway'
|
completed: 'orderTransactions.status.completed',
|
||||||
default:
|
failed: 'orderTransactions.status.failed',
|
||||||
return type
|
refunded: 'orderTransactions.status.refunded',
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusClass(status: TransactionStatus) {
|
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) {
|
export function OrderTransactionsModal({ open, order, onClose }: OrderTransactionsModalProps) {
|
||||||
|
const t = useT()
|
||||||
const { locale } = useLocale()
|
const { locale } = useLocale()
|
||||||
const dateLocale = locale === 'fa' ? 'fa' : 'en'
|
const dateLocale = locale === 'fa' ? 'fa' : 'en'
|
||||||
const [mounted, setMounted] = useState(open)
|
const [mounted, setMounted] = useState(open)
|
||||||
@@ -120,6 +95,40 @@ export function OrderTransactionsModal({ open, order, onClose }: OrderTransactio
|
|||||||
|
|
||||||
if (!mounted || !order) return null
|
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(
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
|
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
|
||||||
@@ -135,33 +144,40 @@ export function OrderTransactionsModal({ open, order, onClose }: OrderTransactio
|
|||||||
<div className={modalStyles.header}>
|
<div className={modalStyles.header}>
|
||||||
<div>
|
<div>
|
||||||
<h2 id="order-transactions-title" className={modalStyles.title}>
|
<h2 id="order-transactions-title" className={modalStyles.title}>
|
||||||
Transaction details
|
{t('orderTransactions.title')}
|
||||||
</h2>
|
</h2>
|
||||||
<p className={modalStyles.subtitle}>{order.orderNumber}</p>
|
<p className={modalStyles.subtitle}>{order.orderNumber}</p>
|
||||||
</div>
|
</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} />
|
<X size={18} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`${modalStyles.body} ${styles.body}`}>
|
<div className={`${modalStyles.body} ${styles.body}`}>
|
||||||
{transactions.length === 0 ? (
|
{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}>
|
<ul className={styles.txList}>
|
||||||
{transactions.map((transaction) => {
|
{transactions.map((transaction) => {
|
||||||
const details = transactionDetails(transaction, dateLocale)
|
const details = transactionDetails(transaction)
|
||||||
return (
|
return (
|
||||||
<li key={transaction.id} className={styles.txCard}>
|
<li key={transaction.id} className={styles.txCard}>
|
||||||
<div className={styles.txHeader}>
|
<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 className={styles.txAmount}>{formatIrtPrice(transaction.amount)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.txMeta}>
|
<div className={styles.txMeta}>
|
||||||
{details.map((detail) => (
|
{details.map((detail) => (
|
||||||
<div key={detail.label} className={styles.txMetaItem}>
|
<div key={detail.label} className={styles.txMetaItem}>
|
||||||
{detail.label}:{' '}
|
{detail.label}:{' '}
|
||||||
{detail.label === 'Status' ? (
|
{detail.isStatus ? (
|
||||||
<span className={`${styles.statusBadge} ${statusClass(transaction.status)}`}>
|
<span className={`${styles.statusBadge} ${statusClass(transaction.status)}`}>
|
||||||
{detail.value}
|
{detail.value}
|
||||||
</span>
|
</span>
|
||||||
@@ -179,20 +195,25 @@ export function OrderTransactionsModal({ open, order, onClose }: OrderTransactio
|
|||||||
|
|
||||||
<div className={styles.summary}>
|
<div className={styles.summary}>
|
||||||
<span className={styles.summaryLabel}>
|
<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>
|
||||||
<span className={styles.summaryValue}>{formatIrtPrice(totalPaid)}</span>
|
<span className={styles.summaryValue}>{formatIrtPrice(totalPaid)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={modalStyles.actions}>
|
<div className={modalStyles.actions}>
|
||||||
<button type="button" className={modalStyles.cancelBtn} onClick={onClose}>
|
<button type="button" className={modalStyles.cancelBtn} onClick={onClose}>
|
||||||
Close
|
{t('orderTransactions.close')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
,
|
document.body,
|
||||||
document.body,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,14 @@
|
|||||||
box-shadow: 0 20px 60px rgba(31, 38, 135, 0.15);
|
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; }
|
.modalIn { animation: modalFadeIn 0.25s ease forwards; }
|
||||||
.modalOut { animation: modalFadeOut 0.22s ease forwards; }
|
.modalOut { animation: modalFadeOut 0.22s ease forwards; }
|
||||||
|
|
||||||
@@ -136,9 +144,7 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
white-space: nowrap;
|
line-height: 1.45;
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
text-align: start;
|
text-align: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
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 { useLocale } from '@meshkee/dashboard-ui'
|
||||||
import { useT } from '../i18n/useT'
|
import { useT } from '../i18n/useT'
|
||||||
import type { BusinessMessageKey } from '../i18n/messages'
|
import type { BusinessMessageKey } from '../i18n/messages'
|
||||||
@@ -10,6 +10,7 @@ import { createId } from '../utils/id'
|
|||||||
import { textLocaleAttrs } from '../utils/textLocale'
|
import { textLocaleAttrs } from '../utils/textLocale'
|
||||||
import { AddVariationModal } from './AddVariationModal'
|
import { AddVariationModal } from './AddVariationModal'
|
||||||
import { Tooltip } from './Tooltip'
|
import { Tooltip } from './Tooltip'
|
||||||
|
import controlStyles from './CategoryRow.module.css'
|
||||||
import styles from './VariationsModal.module.css'
|
import styles from './VariationsModal.module.css'
|
||||||
|
|
||||||
interface VariationsModalProps {
|
interface VariationsModalProps {
|
||||||
@@ -38,7 +39,8 @@ export function VariationsModal({
|
|||||||
const isFa = locale === 'fa'
|
const isFa = locale === 'fa'
|
||||||
const [mounted, setMounted] = useState(open)
|
const [mounted, setMounted] = useState(open)
|
||||||
const [closing, setClosing] = useState(false)
|
const [closing, setClosing] = useState(false)
|
||||||
const [addOpen, setAddOpen] = useState(false)
|
const [formOpen, setFormOpen] = useState(false)
|
||||||
|
const [editTarget, setEditTarget] = useState<Variation | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
@@ -49,7 +51,8 @@ export function VariationsModal({
|
|||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
setMounted(false)
|
setMounted(false)
|
||||||
setClosing(false)
|
setClosing(false)
|
||||||
setAddOpen(false)
|
setFormOpen(false)
|
||||||
|
setEditTarget(null)
|
||||||
}, ANIMATION_MS)
|
}, ANIMATION_MS)
|
||||||
return () => clearTimeout(timer)
|
return () => clearTimeout(timer)
|
||||||
}
|
}
|
||||||
@@ -58,11 +61,11 @@ export function VariationsModal({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mounted || closing) return
|
if (!mounted || closing) return
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape' && !addOpen) onClose()
|
if (e.key === 'Escape' && !formOpen) onClose()
|
||||||
}
|
}
|
||||||
document.addEventListener('keydown', onKey)
|
document.addEventListener('keydown', onKey)
|
||||||
return () => document.removeEventListener('keydown', onKey)
|
return () => document.removeEventListener('keydown', onKey)
|
||||||
}, [mounted, closing, addOpen, onClose])
|
}, [mounted, closing, formOpen, onClose])
|
||||||
|
|
||||||
if (!mounted) return null
|
if (!mounted) return null
|
||||||
|
|
||||||
@@ -74,13 +77,35 @@ export function VariationsModal({
|
|||||||
...list,
|
...list,
|
||||||
{ ...variation, id: createId() },
|
{ ...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) {
|
function handleRemove(id: string) {
|
||||||
onChange(list.filter((variation) => variation.id !== id))
|
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) {
|
function typeLabel(type: VariationType) {
|
||||||
if (type === 'color') return t('categories.variations.type.color')
|
if (type === 'color') return t('categories.variations.type.color')
|
||||||
if (type === 'size') return t('categories.variations.type.size')
|
if (type === 'size') return t('categories.variations.type.size')
|
||||||
@@ -108,7 +133,7 @@ export function VariationsModal({
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`${styles.modal} ${closing ? styles.modalOut : styles.modalIn}`}
|
className={`${styles.modal} ${styles.modalListWide} ${closing ? styles.modalOut : styles.modalIn}`}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
@@ -164,6 +189,16 @@ export function VariationsModal({
|
|||||||
</div>
|
</div>
|
||||||
<div className={styles.listItemEnd}>
|
<div className={styles.listItemEnd}>
|
||||||
<span className={styles.listItemType}>{typeLabel(variation.type)}</span>
|
<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')}>
|
<Tooltip label={t('categories.variations.remove')}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -183,7 +218,7 @@ export function VariationsModal({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="button" className={styles.addBtn} onClick={() => setAddOpen(true)}>
|
<button type="button" className={styles.addBtn} onClick={openAddForm}>
|
||||||
<Plus size={18} />
|
<Plus size={18} />
|
||||||
{t('categories.variations.add')}
|
{t('categories.variations.add')}
|
||||||
</button>
|
</button>
|
||||||
@@ -201,9 +236,13 @@ export function VariationsModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AddVariationModal
|
<AddVariationModal
|
||||||
open={addOpen}
|
open={formOpen}
|
||||||
onClose={() => setAddOpen(false)}
|
initial={editTarget}
|
||||||
onSubmit={handleAdd}
|
onClose={() => {
|
||||||
|
setFormOpen(false)
|
||||||
|
setEditTarget(null)
|
||||||
|
}}
|
||||||
|
onSubmit={editTarget ? handleEditSubmit : handleAdd}
|
||||||
/>
|
/>
|
||||||
</>,
|
</>,
|
||||||
document.body,
|
document.body,
|
||||||
|
|||||||
@@ -147,6 +147,9 @@ const en = {
|
|||||||
'bizProfile.logo.upload': 'Upload logo',
|
'bizProfile.logo.upload': 'Upload logo',
|
||||||
'bizProfile.logo.hint': 'Transparent PNG recommended',
|
'bizProfile.logo.hint': 'Transparent PNG recommended',
|
||||||
'bizProfile.logo.change': 'Change logo',
|
'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': 'Activity categories',
|
||||||
'bizProfile.categories.placeholder': 'Select activity categories',
|
'bizProfile.categories.placeholder': 'Select activity categories',
|
||||||
'bizProfile.nameFa': 'Name (FA)',
|
'bizProfile.nameFa': 'Name (FA)',
|
||||||
@@ -612,7 +615,9 @@ const en = {
|
|||||||
'categories.variations.saving': 'Saving…',
|
'categories.variations.saving': 'Saving…',
|
||||||
'categories.variations.noValues': 'No values',
|
'categories.variations.noValues': 'No values',
|
||||||
'categories.variations.remove': 'Remove variation',
|
'categories.variations.remove': 'Remove variation',
|
||||||
|
'categories.variations.edit': 'Edit variation',
|
||||||
'categories.variations.addTitle': 'Add variation',
|
'categories.variations.addTitle': 'Add variation',
|
||||||
|
'categories.variations.editTitle': 'Edit variation',
|
||||||
'categories.variations.type': 'Type',
|
'categories.variations.type': 'Type',
|
||||||
'categories.variations.type.color': 'Color',
|
'categories.variations.type.color': 'Color',
|
||||||
'categories.variations.type.size': 'Size',
|
'categories.variations.type.size': 'Size',
|
||||||
@@ -626,6 +631,7 @@ const en = {
|
|||||||
'categories.variations.addValue': 'Add value',
|
'categories.variations.addValue': 'Add value',
|
||||||
'categories.variations.removeValue': 'Remove value',
|
'categories.variations.removeValue': 'Remove value',
|
||||||
'categories.variations.submit': 'Add',
|
'categories.variations.submit': 'Add',
|
||||||
|
'categories.variations.saveEdit': 'Save changes',
|
||||||
'categories.variations.color.Red': 'Red',
|
'categories.variations.color.Red': 'Red',
|
||||||
'categories.variations.color.Blue': 'Blue',
|
'categories.variations.color.Blue': 'Blue',
|
||||||
'categories.variations.color.Green': 'Green',
|
'categories.variations.color.Green': 'Green',
|
||||||
@@ -850,6 +856,51 @@ const en = {
|
|||||||
'orders.source.app': 'Application',
|
'orders.source.app': 'Application',
|
||||||
'orders.source.website': 'Website',
|
'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.title': 'Shopping cards',
|
||||||
'shoppingCards.subtitle': 'Saved operator carts waiting to be completed as orders.',
|
'shoppingCards.subtitle': 'Saved operator carts waiting to be completed as orders.',
|
||||||
'shoppingCards.listTitle': 'Shopping card list',
|
'shoppingCards.listTitle': 'Shopping card list',
|
||||||
@@ -1793,6 +1844,8 @@ const en = {
|
|||||||
'website.ePayment.master': 'Enable online payments',
|
'website.ePayment.master': 'Enable online payments',
|
||||||
'website.ePayment.masterDesc':
|
'website.ePayment.masterDesc':
|
||||||
'When a gateway is enabled and configured, customers can use it at website checkout or in the app.',
|
'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.mellat': 'Bank Mellat (Behpardakht)',
|
||||||
'website.ePayment.gateway.mellatDesc':
|
'website.ePayment.gateway.mellatDesc':
|
||||||
'Enter terminal ID, username, and password from the Mellat merchant panel.',
|
'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.upload': 'آپلود لوگو',
|
||||||
'bizProfile.logo.hint': 'PNG شفاف توصیه میشود',
|
'bizProfile.logo.hint': 'PNG شفاف توصیه میشود',
|
||||||
'bizProfile.logo.change': 'تغییر لوگو',
|
'bizProfile.logo.change': 'تغییر لوگو',
|
||||||
|
'bizProfile.logo.light': 'حالت روشن',
|
||||||
|
'bizProfile.logo.dark': 'حالت تیره',
|
||||||
|
'bizProfile.logo.modeSwitch': 'لوگوی حالت روشن یا تیره',
|
||||||
'bizProfile.categories': 'دستهبندیهای فعالیت',
|
'bizProfile.categories': 'دستهبندیهای فعالیت',
|
||||||
'bizProfile.categories.placeholder': 'انتخاب دستهبندیهای فعالیت',
|
'bizProfile.categories.placeholder': 'انتخاب دستهبندیهای فعالیت',
|
||||||
'bizProfile.nameFa': 'نام (فارسی)',
|
'bizProfile.nameFa': 'نام (فارسی)',
|
||||||
@@ -2794,7 +2850,9 @@ const fa: Record<MessageKey, string> = {
|
|||||||
'categories.variations.saving': 'در حال ذخیره…',
|
'categories.variations.saving': 'در حال ذخیره…',
|
||||||
'categories.variations.noValues': 'بدون مقدار',
|
'categories.variations.noValues': 'بدون مقدار',
|
||||||
'categories.variations.remove': 'حذف تنوع',
|
'categories.variations.remove': 'حذف تنوع',
|
||||||
|
'categories.variations.edit': 'ویرایش تنوع',
|
||||||
'categories.variations.addTitle': 'افزودن تنوع',
|
'categories.variations.addTitle': 'افزودن تنوع',
|
||||||
|
'categories.variations.editTitle': 'ویرایش تنوع',
|
||||||
'categories.variations.type': 'نوع',
|
'categories.variations.type': 'نوع',
|
||||||
'categories.variations.type.color': 'رنگ',
|
'categories.variations.type.color': 'رنگ',
|
||||||
'categories.variations.type.size': 'سایز',
|
'categories.variations.type.size': 'سایز',
|
||||||
@@ -2808,6 +2866,7 @@ const fa: Record<MessageKey, string> = {
|
|||||||
'categories.variations.addValue': 'افزودن مقدار',
|
'categories.variations.addValue': 'افزودن مقدار',
|
||||||
'categories.variations.removeValue': 'حذف مقدار',
|
'categories.variations.removeValue': 'حذف مقدار',
|
||||||
'categories.variations.submit': 'افزودن',
|
'categories.variations.submit': 'افزودن',
|
||||||
|
'categories.variations.saveEdit': 'ذخیره تغییرات',
|
||||||
'categories.variations.color.Red': 'قرمز',
|
'categories.variations.color.Red': 'قرمز',
|
||||||
'categories.variations.color.Blue': 'آبی',
|
'categories.variations.color.Blue': 'آبی',
|
||||||
'categories.variations.color.Green': 'سبز',
|
'categories.variations.color.Green': 'سبز',
|
||||||
@@ -3032,6 +3091,51 @@ const fa: Record<MessageKey, string> = {
|
|||||||
'orders.source.app': 'اپلیکیشن',
|
'orders.source.app': 'اپلیکیشن',
|
||||||
'orders.source.website': 'وبسایت',
|
'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.title': 'کارتهای خرید',
|
||||||
'shoppingCards.subtitle': 'سبدهای ذخیرهشده اپراتور که منتظر تکمیل بهصورت سفارش هستند.',
|
'shoppingCards.subtitle': 'سبدهای ذخیرهشده اپراتور که منتظر تکمیل بهصورت سفارش هستند.',
|
||||||
'shoppingCards.listTitle': 'فهرست کارتهای خرید',
|
'shoppingCards.listTitle': 'فهرست کارتهای خرید',
|
||||||
@@ -3974,6 +4078,8 @@ const fa: Record<MessageKey, string> = {
|
|||||||
'website.ePayment.master': 'فعالسازی پرداخت آنلاین',
|
'website.ePayment.master': 'فعالسازی پرداخت آنلاین',
|
||||||
'website.ePayment.masterDesc':
|
'website.ePayment.masterDesc':
|
||||||
'با فعال بودن هر درگاه و پیکربندی آن، مشتریان در تسویه حساب وبسایت و یا اپلیکیشن از آن درگاه استفاده کنند.',
|
'با فعال بودن هر درگاه و پیکربندی آن، مشتریان در تسویه حساب وبسایت و یا اپلیکیشن از آن درگاه استفاده کنند.',
|
||||||
|
'website.ePayment.masterOffHint':
|
||||||
|
'پرداخت آنلاین خاموش است. سوییچ بالا را روشن کنید؛ در غیر این صورت درگاه در مرحله پرداخت نمایش داده نمیشود.',
|
||||||
'website.ePayment.gateway.mellat': 'بانک ملت (بهپرداخت)',
|
'website.ePayment.gateway.mellat': 'بانک ملت (بهپرداخت)',
|
||||||
'website.ePayment.gateway.mellatDesc':
|
'website.ePayment.gateway.mellatDesc':
|
||||||
'شناسه ترمینال، نام کاربری و رمز عبور را از پنل پذیرنده ملت وارد کنید.',
|
'شناسه ترمینال، نام کاربری و رمز عبور را از پنل پذیرنده ملت وارد کنید.',
|
||||||
|
|||||||
@@ -44,6 +44,26 @@
|
|||||||
grid-column: span 12;
|
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 {
|
.basicAside {
|
||||||
grid-column: span 12;
|
grid-column: span 12;
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { PageTitle } from '../components/PageTitle'
|
|||||||
import { ImageCropper } from '../components/ImageCropper'
|
import { ImageCropper } from '../components/ImageCropper'
|
||||||
import { MultiSelectDropdown } from '../components/MultiSelectDropdown'
|
import { MultiSelectDropdown } from '../components/MultiSelectDropdown'
|
||||||
import { RichTextEditor } from '../components/RichTextEditor'
|
import { RichTextEditor } from '../components/RichTextEditor'
|
||||||
|
import { Switch } from '../components/Switch'
|
||||||
import { useToast } from '../context/ToastContext'
|
import { useToast } from '../context/ToastContext'
|
||||||
import { useT } from '../i18n/useT'
|
import { useT } from '../i18n/useT'
|
||||||
import { ApiError } from '../lib/api'
|
import { ApiError } from '../lib/api'
|
||||||
@@ -80,6 +81,9 @@ export function BusinessProfilePage() {
|
|||||||
const [vision, setVision] = useState('')
|
const [vision, setVision] = useState('')
|
||||||
const [logo, setLogo] = useState<string | null>(null)
|
const [logo, setLogo] = useState<string | null>(null)
|
||||||
const [logoMediaId, setLogoMediaId] = 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 [addresses, setAddresses] = useState<AddressDraft[]>([createEmptyAddressItem()])
|
||||||
const [phoneNumbers, setPhoneNumbers] = useState<BusinessPhoneNumber[]>([
|
const [phoneNumbers, setPhoneNumbers] = useState<BusinessPhoneNumber[]>([
|
||||||
createEmptyPhone(),
|
createEmptyPhone(),
|
||||||
@@ -153,6 +157,8 @@ export function BusinessProfilePage() {
|
|||||||
setVision(profileData.profile.vision)
|
setVision(profileData.profile.vision)
|
||||||
setLogo(profileData.profile.logoUrl)
|
setLogo(profileData.profile.logoUrl)
|
||||||
setLogoMediaId(profileData.profile.logoMediaId)
|
setLogoMediaId(profileData.profile.logoMediaId)
|
||||||
|
setLogoDark(profileData.profile.logoDarkUrl)
|
||||||
|
setLogoDarkMediaId(profileData.profile.logoDarkMediaId)
|
||||||
|
|
||||||
const nextAddresses =
|
const nextAddresses =
|
||||||
profileData.addresses.length > 0
|
profileData.addresses.length > 0
|
||||||
@@ -273,6 +279,11 @@ export function BusinessProfilePage() {
|
|||||||
'business-logo.png',
|
'business-logo.png',
|
||||||
logoMediaId,
|
logoMediaId,
|
||||||
)
|
)
|
||||||
|
const nextLogoDarkMediaId = await resolveDataUrlToMediaId(
|
||||||
|
logoDark,
|
||||||
|
'business-logo-dark.png',
|
||||||
|
logoDarkMediaId,
|
||||||
|
)
|
||||||
|
|
||||||
const payloadAddresses = addresses
|
const payloadAddresses = addresses
|
||||||
.filter(
|
.filter(
|
||||||
@@ -309,6 +320,7 @@ export function BusinessProfilePage() {
|
|||||||
phoneNumbers: payloadPhones,
|
phoneNumbers: payloadPhones,
|
||||||
socialMedia,
|
socialMedia,
|
||||||
logoMediaId: nextLogoMediaId,
|
logoMediaId: nextLogoMediaId,
|
||||||
|
logoDarkMediaId: nextLogoDarkMediaId,
|
||||||
categoryIds,
|
categoryIds,
|
||||||
addresses: payloadAddresses,
|
addresses: payloadAddresses,
|
||||||
})
|
})
|
||||||
@@ -358,9 +370,27 @@ export function BusinessProfilePage() {
|
|||||||
<div className={formStyles.formGrid}>
|
<div className={formStyles.formGrid}>
|
||||||
<div className={`${formStyles.field} ${styles.logoCol}`}>
|
<div className={`${formStyles.field} ${styles.logoCol}`}>
|
||||||
<label>{t('bizProfile.logo')}</label>
|
<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
|
<ImageCropper
|
||||||
value={logo}
|
key={logoModeDark ? 'logo-dark' : 'logo-light'}
|
||||||
onChange={setLogo}
|
value={logoModeDark ? logoDark : logo}
|
||||||
|
onChange={logoModeDark ? setLogoDark : setLogo}
|
||||||
outputFormat="png"
|
outputFormat="png"
|
||||||
accept="image/png"
|
accept="image/png"
|
||||||
uploadLabel={t('bizProfile.logo.upload')}
|
uploadLabel={t('bizProfile.logo.upload')}
|
||||||
|
|||||||
@@ -40,6 +40,17 @@
|
|||||||
color: var(--text-secondary);
|
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 {
|
.credentials {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(12, minmax(0, 1fr));
|
grid-template-columns: repeat(12, minmax(0, 1fr));
|
||||||
|
|||||||
@@ -157,6 +157,8 @@ export function SettingsPage() {
|
|||||||
async function handleMellatToggle(checked: boolean) {
|
async function handleMellatToggle(checked: boolean) {
|
||||||
await patchEPayment('mellat-enabled', {
|
await patchEPayment('mellat-enabled', {
|
||||||
...ePayment,
|
...ePayment,
|
||||||
|
// Turning a gateway on implies online payments are allowed at checkout.
|
||||||
|
enabled: checked ? true : ePayment.enabled,
|
||||||
gateways: {
|
gateways: {
|
||||||
...ePayment.gateways,
|
...ePayment.gateways,
|
||||||
mellat: { ...ePayment.gateways.mellat, enabled: checked },
|
mellat: { ...ePayment.gateways.mellat, enabled: checked },
|
||||||
@@ -168,6 +170,7 @@ export function SettingsPage() {
|
|||||||
async function handleZarinpalToggle(checked: boolean) {
|
async function handleZarinpalToggle(checked: boolean) {
|
||||||
await patchEPayment('zarinpal-enabled', {
|
await patchEPayment('zarinpal-enabled', {
|
||||||
...ePayment,
|
...ePayment,
|
||||||
|
enabled: checked ? true : ePayment.enabled,
|
||||||
gateways: {
|
gateways: {
|
||||||
...ePayment.gateways,
|
...ePayment.gateways,
|
||||||
zarinpal: { ...ePayment.gateways.zarinpal, enabled: checked },
|
zarinpal: { ...ePayment.gateways.zarinpal, enabled: checked },
|
||||||
@@ -265,6 +268,12 @@ export function SettingsPage() {
|
|||||||
onChange={(checked) => void handleMasterToggle(checked)}
|
onChange={(checked) => void handleMasterToggle(checked)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{!ePayment.enabled &&
|
||||||
|
(ePayment.gateways.mellat.enabled || ePayment.gateways.zarinpal.enabled) ? (
|
||||||
|
<p className={styles.masterOffHint} role="status">
|
||||||
|
{t('website.ePayment.masterOffHint')}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ export interface BusinessProfile {
|
|||||||
socialMedia: BusinessSocialMedia
|
socialMedia: BusinessSocialMedia
|
||||||
logoMediaId: string | null
|
logoMediaId: string | null
|
||||||
logoUrl: string | null
|
logoUrl: string | null
|
||||||
|
logoDarkMediaId: string | null
|
||||||
|
logoDarkUrl: string | null
|
||||||
faviconMediaId: string | null
|
faviconMediaId: string | null
|
||||||
faviconUrl: string | null
|
faviconUrl: string | null
|
||||||
categoryIds: string[]
|
categoryIds: string[]
|
||||||
@@ -56,6 +58,7 @@ export interface UpdateBusinessProfilePayload {
|
|||||||
phoneNumbers?: BusinessPhoneNumber[]
|
phoneNumbers?: BusinessPhoneNumber[]
|
||||||
socialMedia?: Partial<BusinessSocialMedia>
|
socialMedia?: Partial<BusinessSocialMedia>
|
||||||
logoMediaId?: string | null
|
logoMediaId?: string | null
|
||||||
|
logoDarkMediaId?: string | null
|
||||||
categoryIds?: string[]
|
categoryIds?: string[]
|
||||||
addresses?: Array<{
|
addresses?: Array<{
|
||||||
id?: string
|
id?: string
|
||||||
@@ -92,6 +95,12 @@ export async function updateBusinessProfile(payload: UpdateBusinessProfilePayloa
|
|||||||
: payload.logoMediaId
|
: payload.logoMediaId
|
||||||
? Number(payload.logoMediaId)
|
? Number(payload.logoMediaId)
|
||||||
: null,
|
: 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[] = [
|
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',
|
id: 'processing',
|
||||||
label: 'Under processing',
|
label: 'Under processing',
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ export type StepColorPreset = (typeof STEP_COLOR_PRESETS)[number]
|
|||||||
export const DEFAULT_STEP_COLOR: StepColorPreset = '#3B82F6'
|
export const DEFAULT_STEP_COLOR: StepColorPreset = '#3B82F6'
|
||||||
|
|
||||||
const DEFAULT_STEP_COLORS_BY_ID: Record<string, StepColorPreset> = {
|
const DEFAULT_STEP_COLORS_BY_ID: Record<string, StepColorPreset> = {
|
||||||
|
'awaiting-payment': '#64748B',
|
||||||
|
'payment-successful': '#10B981',
|
||||||
processing: '#3B82F6',
|
processing: '#3B82F6',
|
||||||
'ready-for-shipping': '#F59E0B',
|
'ready-for-shipping': '#F59E0B',
|
||||||
shipped: '#8B5CF6',
|
shipped: '#8B5CF6',
|
||||||
|
|||||||
@@ -17,7 +17,3 @@
|
|||||||
.backBtn span {
|
.backBtn span {
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global([dir='rtl']) .backBtn svg {
|
|
||||||
transform: scaleX(-1);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -23,7 +23,10 @@ interface AuthContextValue {
|
|||||||
user: AuthUser | null
|
user: AuthUser | null
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
login: (cellNumber: string, password: string) => Promise<void>
|
login: (cellNumber: string, password: string) => Promise<void>
|
||||||
loginWithOtp: (cellNumber: string, code: string) => Promise<void>
|
loginWithOtp: (
|
||||||
|
cellNumber: string,
|
||||||
|
code: string,
|
||||||
|
) => Promise<{ passwordUpdated?: boolean }>
|
||||||
logout: () => void
|
logout: () => void
|
||||||
setUser: (user: AuthUser) => void
|
setUser: (user: AuthUser) => void
|
||||||
}
|
}
|
||||||
@@ -130,6 +133,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
const data = await loginWithOtpRequest(cellNumber, code, getTenantDomain())
|
const data = await loginWithOtpRequest(cellNumber, code, getTenantDomain())
|
||||||
await assertCustomerAccess(data.user)
|
await assertCustomerAccess(data.user)
|
||||||
setUser(data.user)
|
setUser(data.user)
|
||||||
|
return { passwordUpdated: data.passwordUpdated }
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const value = useMemo(
|
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 type { Order } from '../services/orderService'
|
||||||
|
import { getTenantDomain } from '../lib/config'
|
||||||
|
|
||||||
export type DeliveryMode = 'delivery' | 'pickup'
|
export type DeliveryMode = 'delivery' | 'pickup'
|
||||||
|
|
||||||
@@ -25,23 +33,94 @@ const defaultState: CheckoutState = {
|
|||||||
placedOrder: null,
|
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)
|
const CheckoutContext = createContext<CheckoutContextValue | null>(null)
|
||||||
|
|
||||||
export function CheckoutProvider({ children }: { children: ReactNode }) {
|
export function CheckoutProvider({ children }: { children: ReactNode }) {
|
||||||
const [state, setState] = useState<CheckoutState>(defaultState)
|
const [state, setState] = useState<CheckoutState>(() => ({
|
||||||
|
...defaultState,
|
||||||
|
...readPersisted(),
|
||||||
|
}))
|
||||||
|
|
||||||
const setDeliveryMode = useCallback((deliveryMode: DeliveryMode) => {
|
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) => {
|
const setSelectedAddressId = useCallback((selectedAddressId: string | null) => {
|
||||||
setState((prev) =>
|
const normalized =
|
||||||
prev.selectedAddressId === selectedAddressId ? prev : { ...prev, selectedAddressId },
|
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) => {
|
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) => {
|
const setPlacedOrder = useCallback((placedOrder: Order | null) => {
|
||||||
@@ -50,6 +129,11 @@ export function CheckoutProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
const resetCheckout = useCallback(() => {
|
const resetCheckout = useCallback(() => {
|
||||||
setState(defaultState)
|
setState(defaultState)
|
||||||
|
try {
|
||||||
|
sessionStorage.removeItem(storageKey())
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const value = useMemo<CheckoutContextValue>(
|
const value = useMemo<CheckoutContextValue>(
|
||||||
|
|||||||
@@ -133,6 +133,8 @@ const en = {
|
|||||||
'orders.source.operator': 'Operator',
|
'orders.source.operator': 'Operator',
|
||||||
'orders.source.app': 'Application',
|
'orders.source.app': 'Application',
|
||||||
'orders.source.website': 'Website',
|
'orders.source.website': 'Website',
|
||||||
|
'orders.step.awaitingPayment': 'Waiting for payment',
|
||||||
|
'orders.step.paymentSuccessful': 'Payment successful',
|
||||||
'orders.step.processing': 'Under processing',
|
'orders.step.processing': 'Under processing',
|
||||||
'orders.step.ready': 'Ready for shipping',
|
'orders.step.ready': 'Ready for shipping',
|
||||||
'orders.step.shipped': 'Shipped',
|
'orders.step.shipped': 'Shipped',
|
||||||
@@ -344,7 +346,7 @@ const en = {
|
|||||||
'signup.info.verifyExisting':
|
'signup.info.verifyExisting':
|
||||||
'Enter the SMS code to join this website. Your Meshkee password and profile stay the same.',
|
'Enter the SMS code to join this website. Your Meshkee password and profile stay the same.',
|
||||||
'signup.info.verifyExistingPasswordUpdated':
|
'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':
|
'signup.toast.passwordUpdated':
|
||||||
'Your Meshkee password was updated to the one you entered on this website.',
|
'Your Meshkee password was updated to the one you entered on this website.',
|
||||||
'signup.cellExists.title': 'Existing Meshkee account',
|
'signup.cellExists.title': 'Existing Meshkee account',
|
||||||
@@ -531,6 +533,8 @@ const fa: Record<MessageKey, string> = {
|
|||||||
'orders.source.operator': 'اپراتور',
|
'orders.source.operator': 'اپراتور',
|
||||||
'orders.source.app': 'اپلیکیشن',
|
'orders.source.app': 'اپلیکیشن',
|
||||||
'orders.source.website': 'وبسایت',
|
'orders.source.website': 'وبسایت',
|
||||||
|
'orders.step.awaitingPayment': 'در انتظار پرداخت',
|
||||||
|
'orders.step.paymentSuccessful': 'پرداخت موفق',
|
||||||
'orders.step.processing': 'در حال پردازش',
|
'orders.step.processing': 'در حال پردازش',
|
||||||
'orders.step.ready': 'آماده ارسال',
|
'orders.step.ready': 'آماده ارسال',
|
||||||
'orders.step.shipped': 'ارسالشده',
|
'orders.step.shipped': 'ارسالشده',
|
||||||
@@ -742,7 +746,7 @@ const fa: Record<MessageKey, string> = {
|
|||||||
'signup.info.verifyExisting':
|
'signup.info.verifyExisting':
|
||||||
'کد پیامکی را وارد کنید تا به این وبسایت بپیوندید. رمز عبور و اطلاعات مشکی شما همان میماند.',
|
'کد پیامکی را وارد کنید تا به این وبسایت بپیوندید. رمز عبور و اطلاعات مشکی شما همان میماند.',
|
||||||
'signup.info.verifyExistingPasswordUpdated':
|
'signup.info.verifyExistingPasswordUpdated':
|
||||||
'کد پیامکی را وارد کنید تا عضویت تمام شود. رمز مشکی شما به رمزی که همین الان وارد کردید بهروز شد.',
|
'کد پیامکی را وارد کنید تا عضویت تمام شود. پس از تأیید، رمز مشکی شما به رمزی که همین الان وارد کردید بهروز میشود.',
|
||||||
'signup.toast.passwordUpdated':
|
'signup.toast.passwordUpdated':
|
||||||
'رمز مشکی شما به رمزی که در این وبسایت وارد کردید بهروز شد.',
|
'رمز مشکی شما به رمزی که در این وبسایت وارد کردید بهروز شد.',
|
||||||
'signup.cellExists.title': 'حساب موجود در مشکی',
|
'signup.cellExists.title': 'حساب موجود در مشکی',
|
||||||
|
|||||||
@@ -226,27 +226,30 @@ export function LoginPage() {
|
|||||||
|
|
||||||
setCellExistsSites(null)
|
setCellExistsSites(null)
|
||||||
|
|
||||||
|
// Password is applied only after OTP when passwordPending; toast then.
|
||||||
if (data.passwordUpdated) {
|
if (data.passwordUpdated) {
|
||||||
showToast(t('signup.toast.passwordUpdated'), 'info')
|
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) {
|
if (data.user.customerBusinesses.length === 0) {
|
||||||
logoutRequest()
|
logoutRequest()
|
||||||
setError(CUSTOMER_ACCESS_MESSAGE)
|
setError(CUSTOMER_ACCESS_MESSAGE)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data.user.cellVerifiedAt) {
|
|
||||||
logoutRequest()
|
|
||||||
await continueToSmsVerify(
|
|
||||||
cellNumber,
|
|
||||||
data.passwordUpdated
|
|
||||||
? t('signup.info.verifyExistingPasswordUpdated')
|
|
||||||
: t('signup.info.verify'),
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await login(cellNumber, password)
|
await login(cellNumber, password)
|
||||||
navigate(redirectTo)
|
navigate(redirectTo)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -356,7 +359,10 @@ export function LoginPage() {
|
|||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await loginWithOtp(cellNumber, smsCode)
|
const result = await loginWithOtp(cellNumber, smsCode)
|
||||||
|
if (result.passwordUpdated) {
|
||||||
|
showToast(t('signup.toast.passwordUpdated'), 'info')
|
||||||
|
}
|
||||||
navigate(redirectTo)
|
navigate(redirectTo)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
handleApiError(err, t('otp.error.signIn'))
|
handleApiError(err, t('otp.error.signIn'))
|
||||||
|
|||||||
@@ -117,7 +117,8 @@ export function CheckoutPaymentStep() {
|
|||||||
setError('')
|
setError('')
|
||||||
|
|
||||||
if (deliveryMode === 'delivery' && !selectedAddressId) {
|
if (deliveryMode === 'delivery' && !selectedAddressId) {
|
||||||
setError('لطفاً یک آدرس ارسال انتخاب کنید.')
|
showToast('لطفاً یک آدرس ارسال انتخاب کنید.', 'error')
|
||||||
|
navigate('/checkout/delivery', { replace: true })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -621,7 +621,7 @@
|
|||||||
|
|
||||||
.successMeta {
|
.successMeta {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 360px;
|
max-width: 540px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
@@ -645,6 +645,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.successMetaRow dt {
|
.successMetaRow dt {
|
||||||
|
flex: 0 0 auto;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
@@ -653,10 +654,13 @@
|
|||||||
|
|
||||||
.successMetaRow dd {
|
.successMetaRow dd {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.successMetaValue {
|
.successMetaValue {
|
||||||
|
|||||||
@@ -39,7 +39,9 @@ export async function register(input: {
|
|||||||
body: input,
|
body: input,
|
||||||
})
|
})
|
||||||
|
|
||||||
setTokens(data.accessToken, data.refreshToken)
|
if (data.accessToken && data.refreshToken) {
|
||||||
|
setTokens(data.accessToken, data.refreshToken)
|
||||||
|
}
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,18 @@ export interface OrderProcessStep {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_ORDER_PROCESS_STEPS: 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',
|
id: 'processing',
|
||||||
label: 'Under processing',
|
label: 'Under processing',
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
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 { fitFixedPopover } from '../utils/fitFixedPopover'
|
||||||
import styles from './BusinessActionsOverflowMenu.module.css'
|
import styles from './BusinessActionsOverflowMenu.module.css'
|
||||||
|
|
||||||
@@ -11,13 +20,32 @@ type PopoverPosition = {
|
|||||||
|
|
||||||
interface BusinessActionsOverflowMenuProps {
|
interface BusinessActionsOverflowMenuProps {
|
||||||
businessName: string
|
businessName: string
|
||||||
|
/** Primary apex domain host when set — enables website / dashboard links. */
|
||||||
|
host?: string | null
|
||||||
onAllUsers: () => void
|
onAllUsers: () => void
|
||||||
onWipeData: () => void
|
onWipeData: () => void
|
||||||
onRemove: () => 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({
|
export function BusinessActionsOverflowMenu({
|
||||||
businessName,
|
businessName,
|
||||||
|
host,
|
||||||
onAllUsers,
|
onAllUsers,
|
||||||
onWipeData,
|
onWipeData,
|
||||||
onRemove,
|
onRemove,
|
||||||
@@ -28,13 +56,40 @@ export function BusinessActionsOverflowMenu({
|
|||||||
const popoverRef = useRef<HTMLDivElement>(null)
|
const popoverRef = useRef<HTMLDivElement>(null)
|
||||||
const triggerRef = useRef<HTMLButtonElement>(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() {
|
function updatePopoverPosition() {
|
||||||
const rect = triggerRef.current?.getBoundingClientRect()
|
const rect = triggerRef.current?.getBoundingClientRect()
|
||||||
if (!rect) return
|
if (!rect) return
|
||||||
|
|
||||||
const size = popoverRef.current
|
const size = popoverRef.current
|
||||||
? { width: popoverRef.current.offsetWidth, height: popoverRef.current.offsetHeight }
|
? { width: popoverRef.current.offsetWidth, height: popoverRef.current.offsetHeight }
|
||||||
: { width: 220, height: 160 }
|
: { width: 220, height: linkItems.length ? 280 : 160 }
|
||||||
|
|
||||||
setPopoverPos(fitFixedPopover(rect, size, 'end'))
|
setPopoverPos(fitFixedPopover(rect, size, 'end'))
|
||||||
}
|
}
|
||||||
@@ -96,8 +151,8 @@ export function BusinessActionsOverflowMenu({
|
|||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
toggleOpen()
|
toggleOpen()
|
||||||
}}
|
}}
|
||||||
title="More actions"
|
title="عملیات بیشتر"
|
||||||
aria-label={`More actions for ${businessName}`}
|
aria-label={`عملیات بیشتر برای ${businessName}`}
|
||||||
aria-haspopup="menu"
|
aria-haspopup="menu"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
>
|
>
|
||||||
@@ -111,12 +166,36 @@ export function BusinessActionsOverflowMenu({
|
|||||||
ref={popoverRef}
|
ref={popoverRef}
|
||||||
className={styles.menu}
|
className={styles.menu}
|
||||||
role="menu"
|
role="menu"
|
||||||
aria-label={`More actions for ${businessName}`}
|
aria-label={`عملیات بیشتر برای ${businessName}`}
|
||||||
|
lang="fa"
|
||||||
|
dir="rtl"
|
||||||
style={{
|
style={{
|
||||||
top: popoverPos.top,
|
top: popoverPos.top,
|
||||||
left: popoverPos.left,
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.item}
|
className={styles.item}
|
||||||
@@ -127,7 +206,7 @@ export function BusinessActionsOverflowMenu({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Users size={16} />
|
<Users size={16} />
|
||||||
<span>All users</span>
|
<span>همه کاربران</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -141,7 +220,7 @@ export function BusinessActionsOverflowMenu({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Eraser size={16} />
|
<Eraser size={16} />
|
||||||
<span>Wipe data</span>
|
<span>پاکسازی داده</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -153,7 +232,7 @@ export function BusinessActionsOverflowMenu({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Trash2 size={16} />
|
<Trash2 size={16} />
|
||||||
<span>Remove</span>
|
<span>حذف</span>
|
||||||
</button>
|
</button>
|
||||||
</div>,
|
</div>,
|
||||||
document.body,
|
document.body,
|
||||||
|
|||||||
@@ -109,21 +109,21 @@ export function DomainLinksOverflowMenu({ host, onRemove }: DomainLinksOverflowM
|
|||||||
const items = [
|
const items = [
|
||||||
{
|
{
|
||||||
key: 'website',
|
key: 'website',
|
||||||
label: 'Open website',
|
label: 'باز کردن وبسایت',
|
||||||
detail: websiteHost,
|
detail: websiteHost,
|
||||||
icon: Globe,
|
icon: Globe,
|
||||||
onClick: () => openHost(websiteHost),
|
onClick: () => openHost(websiteHost),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'business',
|
key: 'business',
|
||||||
label: 'Business dashboard',
|
label: 'داشبورد کسبوکار',
|
||||||
detail: businessHost,
|
detail: businessHost,
|
||||||
icon: Building2,
|
icon: Building2,
|
||||||
onClick: () => openHost(businessHost),
|
onClick: () => openHost(businessHost),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'customer',
|
key: 'customer',
|
||||||
label: 'Customer dashboard',
|
label: 'داشبورد مشتری',
|
||||||
detail: customerHost,
|
detail: customerHost,
|
||||||
icon: UserRound,
|
icon: UserRound,
|
||||||
onClick: () => openHost(customerHost),
|
onClick: () => openHost(customerHost),
|
||||||
@@ -141,8 +141,8 @@ export function DomainLinksOverflowMenu({ host, onRemove }: DomainLinksOverflowM
|
|||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
toggleOpen()
|
toggleOpen()
|
||||||
}}
|
}}
|
||||||
title="More actions"
|
title="عملیات بیشتر"
|
||||||
aria-label={`More actions for ${apex}`}
|
aria-label={`عملیات بیشتر برای ${apex}`}
|
||||||
aria-haspopup="menu"
|
aria-haspopup="menu"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
>
|
>
|
||||||
@@ -156,7 +156,9 @@ export function DomainLinksOverflowMenu({ host, onRemove }: DomainLinksOverflowM
|
|||||||
ref={popoverRef}
|
ref={popoverRef}
|
||||||
className={styles.menu}
|
className={styles.menu}
|
||||||
role="menu"
|
role="menu"
|
||||||
aria-label={`More actions for ${apex}`}
|
aria-label={`عملیات بیشتر برای ${apex}`}
|
||||||
|
lang="fa"
|
||||||
|
dir="rtl"
|
||||||
style={{
|
style={{
|
||||||
top: popoverPos.top,
|
top: popoverPos.top,
|
||||||
left: popoverPos.left,
|
left: popoverPos.left,
|
||||||
@@ -176,7 +178,7 @@ export function DomainLinksOverflowMenu({ host, onRemove }: DomainLinksOverflowM
|
|||||||
<Icon size={16} />
|
<Icon size={16} />
|
||||||
<span className={styles.itemText}>
|
<span className={styles.itemText}>
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
<span className={styles.itemDetail}>
|
<span className={styles.itemDetail} dir="ltr">
|
||||||
{detail}
|
{detail}
|
||||||
<ExternalLink size={12} aria-hidden="true" />
|
<ExternalLink size={12} aria-hidden="true" />
|
||||||
</span>
|
</span>
|
||||||
@@ -194,7 +196,7 @@ export function DomainLinksOverflowMenu({ host, onRemove }: DomainLinksOverflowM
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Trash2 size={16} />
|
<Trash2 size={16} />
|
||||||
<span>Remove</span>
|
<span>حذف</span>
|
||||||
</button>
|
</button>
|
||||||
</div>,
|
</div>,
|
||||||
document.body,
|
document.body,
|
||||||
|
|||||||
@@ -1347,6 +1347,7 @@ export function BusinessesPage() {
|
|||||||
</button>
|
</button>
|
||||||
<BusinessActionsOverflowMenu
|
<BusinessActionsOverflowMenu
|
||||||
businessName={b.name}
|
businessName={b.name}
|
||||||
|
host={b.domain}
|
||||||
onAllUsers={() => goToBusinessUsers(b, 'all')}
|
onAllUsers={() => goToBusinessUsers(b, 'all')}
|
||||||
onWipeData={() => openPurge(b)}
|
onWipeData={() => openPurge(b)}
|
||||||
onRemove={() => setRemoveTarget(b)}
|
onRemove={() => setRemoveTarget(b)}
|
||||||
|
|||||||
@@ -65,3 +65,69 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
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,
|
RotateCcw,
|
||||||
Search,
|
Search,
|
||||||
ShieldPlus,
|
ShieldPlus,
|
||||||
|
X,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
|
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
|
||||||
import { DomainLinksOverflowMenu } from '../components/DomainLinksOverflowMenu'
|
import { DomainLinksOverflowMenu } from '../components/DomainLinksOverflowMenu'
|
||||||
@@ -107,6 +108,7 @@ export function WebsitesPage() {
|
|||||||
const [removeTarget, setRemoveTarget] = useState<DomainListItem | null>(null)
|
const [removeTarget, setRemoveTarget] = useState<DomainListItem | null>(null)
|
||||||
const [togglingActiveId, setTogglingActiveId] = useState<number | null>(null)
|
const [togglingActiveId, setTogglingActiveId] = useState<number | null>(null)
|
||||||
const [deployingId, setDeployingId] = useState<DomainListItem['id'] | null>(null)
|
const [deployingId, setDeployingId] = useState<DomainListItem['id'] | null>(null)
|
||||||
|
const [deployError, setDeployError] = useState('')
|
||||||
const [syncingSsl, setSyncingSsl] = useState(false)
|
const [syncingSsl, setSyncingSsl] = useState(false)
|
||||||
const [issuingWebsiteSsl, setIssuingWebsiteSsl] = useState(false)
|
const [issuingWebsiteSsl, setIssuingWebsiteSsl] = useState(false)
|
||||||
const [issuingSslId, setIssuingSslId] = useState<DomainListItem['id'] | null>(null)
|
const [issuingSslId, setIssuingSslId] = useState<DomainListItem['id'] | null>(null)
|
||||||
@@ -356,7 +358,7 @@ export function WebsitesPage() {
|
|||||||
flushSync(() => {
|
flushSync(() => {
|
||||||
setDeployingId(domain.id)
|
setDeployingId(domain.id)
|
||||||
})
|
})
|
||||||
setError('')
|
setDeployError('')
|
||||||
showToast(`Deploying "${domain.host}"…`, 'info')
|
showToast(`Deploying "${domain.host}"…`, 'info')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -376,15 +378,17 @@ export function WebsitesPage() {
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
showToast(
|
if (result.lastDeployStatus === 'success') {
|
||||||
result.lastDeployStatus === 'success'
|
showToast(`Deploy succeeded for "${result.host}".`, 'success')
|
||||||
? `Deploy succeeded for "${result.host}".`
|
return
|
||||||
: `Deploy finished for "${result.host}" (${result.lastDeployStatus ?? 'unknown'}).`,
|
}
|
||||||
result.lastDeployStatus === 'success' ? 'success' : 'info',
|
const detail =
|
||||||
)
|
result.message?.trim() ||
|
||||||
|
`Deploy finished for "${result.host}" with status ${result.lastDeployStatus ?? 'unknown'}.`
|
||||||
|
setDeployError(`${result.host}: ${detail}`)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof ApiError ? err.message : 'Deploy failed.'
|
const message = err instanceof ApiError ? err.message : 'Deploy failed.'
|
||||||
setError(message)
|
setDeployError(`${domain.host}: ${message}`)
|
||||||
setData((prev) => {
|
setData((prev) => {
|
||||||
if (!prev) return prev
|
if (!prev) return prev
|
||||||
return {
|
return {
|
||||||
@@ -400,7 +404,6 @@ export function WebsitesPage() {
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
showToast(message, 'error')
|
|
||||||
} finally {
|
} finally {
|
||||||
setDeployingId(null)
|
setDeployingId(null)
|
||||||
}
|
}
|
||||||
@@ -511,6 +514,24 @@ export function WebsitesPage() {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</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.tablePanel}>
|
||||||
<div className={tableStyles.tableWrap}>
|
<div className={tableStyles.tableWrap}>
|
||||||
<div className={tableStyles.tableHeader}>
|
<div className={tableStyles.tableHeader}>
|
||||||
@@ -527,10 +548,10 @@ export function WebsitesPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div style={{ padding: 16 }}>
|
<div className={styles.listErrorWrap}>
|
||||||
<div className={tableStyles.meta} style={{ color: '#b91c1c' }}>
|
<p className={tableStyles.alertError} role="alert">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -43,15 +43,25 @@ export interface LoginResponse {
|
|||||||
user: AuthUser
|
user: AuthUser
|
||||||
accessToken: string
|
accessToken: string
|
||||||
refreshToken: string
|
refreshToken: string
|
||||||
|
/** True when a pending cross-site password change was applied after OTP. */
|
||||||
|
passwordUpdated?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MeResponse {
|
export interface MeResponse {
|
||||||
user: AuthUser
|
user: AuthUser
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RegisterResponse extends LoginResponse {
|
export interface RegisterResponse {
|
||||||
|
message: string
|
||||||
|
user: AuthUser
|
||||||
|
accessToken?: string
|
||||||
|
refreshToken?: string
|
||||||
smsEnabled?: boolean
|
smsEnabled?: boolean
|
||||||
/** True when an existing Meshkee account was linked and its password was replaced. */
|
/** True when OTP is still required before the session is usable. */
|
||||||
|
requiresOtp?: boolean
|
||||||
|
/** True when a new password is held until OTP succeeds. */
|
||||||
|
passwordPending?: boolean
|
||||||
|
/** True when an existing Meshkee account password was already replaced. */
|
||||||
passwordUpdated?: boolean
|
passwordUpdated?: boolean
|
||||||
registeredBusiness?: { id: string; name: string; slug: string }
|
registeredBusiness?: { id: string; name: string; slug: string }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user