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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-26 00:21:48 +03:30
co-authored by Cursor
parent dc570c1e7f
commit 5e799eddf9
30 changed files with 779 additions and 169 deletions
@@ -13,14 +13,22 @@ interface AddVariationModalProps {
open: boolean 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)
if (initial) {
setType(initial.type)
setCustomName(initial.type === 'custom' ? initial.name : '')
setSelectedColors(initial.type === 'color' ? [...initial.values] : [])
setSelectedSizes(initial.type === 'size' ? [...initial.values] : [])
setCustomValues(
initial.type === 'custom' ? seedCustomValues(initial.values) : [''],
)
} else {
setType('color') setType('color')
setCustomName('') setCustomName('')
setSelectedColors([]) setSelectedColors([])
setSelectedSizes([]) setSelectedSizes([])
setCustomValues(['']) 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%;
} }
+34 -13
View File
@@ -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,
+106
View File
@@ -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));
+9
View File
@@ -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',
+2
View File
@@ -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);
}
+5 -1
View File
@@ -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(
+91 -7
View File
@@ -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>(
+6 -2
View File
@@ -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': 'حساب موجود در مشکی',
+18 -12
View File
@@ -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,
}) })
if (data.accessToken && data.refreshToken) {
setTokens(data.accessToken, data.refreshToken) setTokens(data.accessToken, data.refreshToken)
}
return data return data
} }
+12
View File
@@ -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;
}
+33 -12
View File
@@ -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>
)} )}
+12 -2
View File
@@ -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 }
} }