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

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

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