mirror of
https://git.meshkee.com/Meshkee/dashboards.git
synced 2026-08-11 22:30:58 +04:30
Add a real user profile page with multi-address CRUD, localize business profile, fix RTL category tree and login/sidebar logo fallbacks, and keep pagination controls centered with aligned page meta. Co-authored-by: Cursor <cursoragent@cursor.com>
362 lines
12 KiB
TypeScript
362 lines
12 KiB
TypeScript
import { useEffect, useState } from 'react'
|
|
import { ChevronDown, ChevronUp, Plus, Trash2 } from 'lucide-react'
|
|
import { Breadcrumbs } from '../components/Breadcrumbs'
|
|
import { StepColorPicker } from '../components/StepColorPicker'
|
|
import { Switch } from '../components/Switch'
|
|
import { Tooltip } from '../components/Tooltip'
|
|
import { useToast } from '../context/ToastContext'
|
|
import { useT } from '../i18n/useT'
|
|
import { ApiError } from '../lib/api'
|
|
import {
|
|
getSettings,
|
|
updateStoreSettings,
|
|
DEFAULT_ORDER_PROCESS_STEPS,
|
|
type OrderProcessStep,
|
|
type StoreSettings,
|
|
} from '../services/settingsService'
|
|
import { createId } from '../utils/id'
|
|
import {
|
|
defaultStepColorForId,
|
|
normalizeStepColor,
|
|
type StepColorPreset,
|
|
} from '../utils/stepColors'
|
|
import controlStyles from '../components/CategoryRow.module.css'
|
|
import removeStyles from '../components/VariationsModal.module.css'
|
|
import pageStyles from '../components/PageContent.module.css'
|
|
import sharedStyles from './ProductSettingsPage.module.css'
|
|
import styles from './StoreSettingsPage.module.css'
|
|
|
|
const DEFAULT_STORE_SETTINGS: StoreSettings = {
|
|
onlineSellEnabled: true,
|
|
orderProcessSteps: DEFAULT_ORDER_PROCESS_STEPS,
|
|
}
|
|
|
|
function normalizeSteps(steps: OrderProcessStep[]) {
|
|
return steps.map((step, index) => ({
|
|
id: step.id,
|
|
label: step.label.trim(),
|
|
labelFa: (step.labelFa ?? '').trim(),
|
|
color: normalizeStepColor(step.color, defaultStepColorForId(step.id, index)),
|
|
}))
|
|
}
|
|
|
|
function stepsAreEqual(a: OrderProcessStep[], b: OrderProcessStep[]) {
|
|
if (a.length !== b.length) return false
|
|
return a.every((step, index) => {
|
|
const other = b[index]
|
|
return (
|
|
step.id === other.id &&
|
|
step.label === other.label &&
|
|
step.labelFa === other.labelFa &&
|
|
step.color === other.color
|
|
)
|
|
})
|
|
}
|
|
|
|
export function StoreSettingsPage() {
|
|
const t = useT()
|
|
const { showToast } = useToast()
|
|
const [settings, setSettings] = useState<StoreSettings>(DEFAULT_STORE_SETTINGS)
|
|
const [draftSteps, setDraftSteps] = useState<OrderProcessStep[]>(
|
|
DEFAULT_STORE_SETTINGS.orderProcessSteps,
|
|
)
|
|
const [isLoading, setIsLoading] = useState(true)
|
|
const [savingKey, setSavingKey] = useState<string | null>(null)
|
|
const [error, setError] = useState('')
|
|
|
|
useEffect(() => {
|
|
const controller = new AbortController()
|
|
void loadSettings(controller.signal)
|
|
return () => controller.abort()
|
|
}, [])
|
|
|
|
async function loadSettings(signal?: AbortSignal) {
|
|
setIsLoading(true)
|
|
setError('')
|
|
|
|
try {
|
|
const data = await getSettings(signal)
|
|
setSettings(data.settings.store)
|
|
setDraftSteps(data.settings.store.orderProcessSteps)
|
|
} catch (err) {
|
|
if (err instanceof DOMException && err.name === 'AbortError') return
|
|
if (err instanceof ApiError) {
|
|
setError(err.message)
|
|
} else {
|
|
setError(t('productSettings.errorLoad'))
|
|
}
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}
|
|
|
|
async function handleOnlineSellChange(checked: boolean) {
|
|
setSavingKey('onlineSell')
|
|
setError('')
|
|
|
|
try {
|
|
const data = await updateStoreSettings({ onlineSellEnabled: checked })
|
|
setSettings(data.settings.store)
|
|
showToast(t('productSettings.saved'), 'success')
|
|
} catch (err) {
|
|
if (err instanceof ApiError) {
|
|
setError(err.message)
|
|
} else {
|
|
setError(t('productSettings.errorSave'))
|
|
}
|
|
} finally {
|
|
setSavingKey(null)
|
|
}
|
|
}
|
|
|
|
function updateStepLabel(id: string, label: string) {
|
|
setDraftSteps((current) =>
|
|
current.map((step) => (step.id === id ? { ...step, label } : step)),
|
|
)
|
|
}
|
|
|
|
function updateStepLabelFa(id: string, labelFa: string) {
|
|
setDraftSteps((current) =>
|
|
current.map((step) => (step.id === id ? { ...step, labelFa } : step)),
|
|
)
|
|
}
|
|
|
|
function updateStepColor(id: string, color: StepColorPreset) {
|
|
setDraftSteps((current) =>
|
|
current.map((step) => (step.id === id ? { ...step, color } : step)),
|
|
)
|
|
}
|
|
|
|
function addStep() {
|
|
setDraftSteps((current) => {
|
|
const id = createId()
|
|
return [
|
|
...current,
|
|
{
|
|
id,
|
|
label: '',
|
|
labelFa: '',
|
|
color: defaultStepColorForId(id, current.length),
|
|
},
|
|
]
|
|
})
|
|
}
|
|
|
|
function removeStep(id: string) {
|
|
setDraftSteps((current) => current.filter((step) => step.id !== id))
|
|
}
|
|
|
|
function moveStep(id: string, direction: -1 | 1) {
|
|
setDraftSteps((current) => {
|
|
const index = current.findIndex((step) => step.id === id)
|
|
if (index < 0) return current
|
|
|
|
const targetIndex = index + direction
|
|
if (targetIndex < 0 || targetIndex >= current.length) return current
|
|
|
|
const next = [...current]
|
|
const [item] = next.splice(index, 1)
|
|
next.splice(targetIndex, 0, item)
|
|
return next
|
|
})
|
|
}
|
|
|
|
async function handleSaveSteps() {
|
|
const normalized = normalizeSteps(draftSteps)
|
|
const hasEmptyLabel = normalized.some((step) => !step.label || !step.labelFa)
|
|
if (!normalized.length) {
|
|
setError(t('storeSettings.error.minSteps'))
|
|
return
|
|
}
|
|
if (hasEmptyLabel) {
|
|
setError(t('storeSettings.error.labels'))
|
|
return
|
|
}
|
|
|
|
setSavingKey('orderSteps')
|
|
setError('')
|
|
|
|
try {
|
|
const data = await updateStoreSettings({ orderProcessSteps: normalized })
|
|
setSettings(data.settings.store)
|
|
setDraftSteps(data.settings.store.orderProcessSteps)
|
|
showToast(t('storeSettings.stepsSaved'), 'success')
|
|
} catch (err) {
|
|
if (err instanceof ApiError) {
|
|
setError(err.message)
|
|
} else {
|
|
setError(t('storeSettings.error.saveSteps'))
|
|
}
|
|
} finally {
|
|
setSavingKey(null)
|
|
}
|
|
}
|
|
|
|
const stepsDirty = !stepsAreEqual(
|
|
normalizeSteps(draftSteps),
|
|
normalizeSteps(settings.orderProcessSteps),
|
|
)
|
|
const canSaveSteps =
|
|
stepsDirty &&
|
|
draftSteps.length > 0 &&
|
|
draftSteps.every(
|
|
(step) => step.label.trim().length > 0 && step.labelFa.trim().length > 0,
|
|
)
|
|
|
|
return (
|
|
<main className={pageStyles.content}>
|
|
<Breadcrumbs
|
|
items={[
|
|
{ label: 'Dashboard', href: '/' },
|
|
{ label: 'Store', href: '/store' },
|
|
{ label: 'Settings' },
|
|
]}
|
|
/>
|
|
|
|
<div className={pageStyles.pageHeader}>
|
|
<div>
|
|
<h2 className={pageStyles.pageTitle}>{t('storeSettings.title')}</h2>
|
|
<p className={pageStyles.pageSubtitle}>{t('storeSettings.subtitle')}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{error && (
|
|
<div className={sharedStyles.alertError} role="alert">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
<section className={sharedStyles.panel}>
|
|
{isLoading ? (
|
|
<p className={sharedStyles.status}>{t('productSettings.loading')}</p>
|
|
) : (
|
|
<div className={sharedStyles.list}>
|
|
<div className={sharedStyles.row}>
|
|
<div className={sharedStyles.rowText}>
|
|
<label htmlFor="online-sell" className={sharedStyles.rowLabel}>
|
|
{t('storeSettings.onlineSell')}
|
|
</label>
|
|
<p className={sharedStyles.rowDescription}>
|
|
{t('storeSettings.onlineSellDesc')}
|
|
</p>
|
|
</div>
|
|
<Switch
|
|
id="online-sell"
|
|
checked={settings.onlineSellEnabled}
|
|
disabled={savingKey === 'onlineSell'}
|
|
aria-label={t('storeSettings.onlineSell')}
|
|
onChange={(checked) => void handleOnlineSellChange(checked)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
<section className={`${sharedStyles.panel} ${styles.stepsPanel}`}>
|
|
<div className={styles.stepsHeader}>
|
|
<div>
|
|
<h3 className={sharedStyles.sectionTitle}>{t('storeSettings.orderProcess')}</h3>
|
|
<p className={styles.stepsDescription}>{t('storeSettings.orderProcessDesc')}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{isLoading ? (
|
|
<p className={sharedStyles.status}>{t('productSettings.loading')}</p>
|
|
) : (
|
|
<>
|
|
<div className={styles.stepsList}>
|
|
{draftSteps.map((step, index) => (
|
|
<div key={step.id} className={styles.stepRow}>
|
|
<span className={styles.stepIndex}>{index + 1}</span>
|
|
<StepColorPicker
|
|
value={normalizeStepColor(step.color, defaultStepColorForId(step.id, index))}
|
|
onChange={(color) => updateStepColor(step.id, color)}
|
|
ariaLabel={t('storeSettings.stepColorAria', { index: index + 1 })}
|
|
/>
|
|
<input
|
|
type="text"
|
|
className={styles.stepInput}
|
|
value={step.label}
|
|
placeholder={t('storeSettings.labelEn')}
|
|
aria-label={t('storeSettings.stepEnAria', { index: index + 1 })}
|
|
dir="ltr"
|
|
lang="en"
|
|
onChange={(e) => updateStepLabel(step.id, e.target.value)}
|
|
/>
|
|
<input
|
|
type="text"
|
|
className={`${styles.stepInput} ${styles.stepInputFa}`}
|
|
value={step.labelFa ?? ''}
|
|
placeholder={t('storeSettings.labelFa')}
|
|
aria-label={t('storeSettings.stepFaAria', { index: index + 1 })}
|
|
dir="rtl"
|
|
lang="fa"
|
|
onChange={(e) => updateStepLabelFa(step.id, e.target.value)}
|
|
/>
|
|
<div className={styles.stepControls}>
|
|
<Tooltip label={t('storeSettings.moveUp')}>
|
|
<button
|
|
type="button"
|
|
className={controlStyles.controlBtn}
|
|
onClick={() => moveStep(step.id, -1)}
|
|
disabled={index === 0}
|
|
aria-label={t('storeSettings.moveUp')}
|
|
>
|
|
<ChevronUp size={16} />
|
|
</button>
|
|
</Tooltip>
|
|
<Tooltip label={t('storeSettings.moveDown')}>
|
|
<button
|
|
type="button"
|
|
className={controlStyles.controlBtn}
|
|
onClick={() => moveStep(step.id, 1)}
|
|
disabled={index === draftSteps.length - 1}
|
|
aria-label={t('storeSettings.moveDown')}
|
|
>
|
|
<ChevronDown size={16} />
|
|
</button>
|
|
</Tooltip>
|
|
<Tooltip label={t('storeSettings.removeStep')}>
|
|
<button
|
|
type="button"
|
|
className={removeStyles.removeRowBtn}
|
|
onClick={() => removeStep(step.id)}
|
|
disabled={draftSteps.length <= 1}
|
|
aria-label={t('storeSettings.removeStep')}
|
|
>
|
|
<Trash2 size={15} />
|
|
</button>
|
|
</Tooltip>
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
{!draftSteps.length && (
|
|
<p className={styles.emptyText}>{t('storeSettings.emptySteps')}</p>
|
|
)}
|
|
</div>
|
|
|
|
<button type="button" className={styles.addStepBtn} onClick={addStep}>
|
|
<Plus size={18} />
|
|
{t('storeSettings.addStep')}
|
|
</button>
|
|
|
|
<div className={styles.stepsActions}>
|
|
<button
|
|
type="button"
|
|
className={styles.saveBtn}
|
|
onClick={() => void handleSaveSteps()}
|
|
disabled={!canSaveSteps || savingKey === 'orderSteps'}
|
|
>
|
|
{savingKey === 'orderSteps'
|
|
? t('storeSettings.saving')
|
|
: t('storeSettings.saveSteps')}
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</section>
|
|
</main>
|
|
)
|
|
}
|