Files
dashboards/apps/business/src/pages/StoreSettingsPage.tsx
T
Alireza HassaniandCursor d8c9c0b017 Polish business dashboard profile, FA layout, and pagination.
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>
2026-08-03 10:40:56 +03:30

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>
)
}