mirror of
https://git.meshkee.com/Meshkee/dashboards.git
synced 2026-08-11 22:30:58 +04:30
Initial commit: Meshkee dashboards monorepo.
Includes business, customer, and super-admin apps with shared packages and production deploy scripts. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,342 @@
|
||||
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 { 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(),
|
||||
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.color === other.color
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export function StoreSettingsPage() {
|
||||
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('Unable to load settings.')
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleOnlineSellChange(checked: boolean) {
|
||||
setSavingKey('onlineSell')
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const data = await updateStoreSettings({ onlineSellEnabled: checked })
|
||||
setSettings(data.settings.store)
|
||||
showToast('Settings saved.', 'success')
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to save settings.')
|
||||
}
|
||||
} finally {
|
||||
setSavingKey(null)
|
||||
}
|
||||
}
|
||||
|
||||
function updateStepLabel(id: string, label: string) {
|
||||
setDraftSteps((current) =>
|
||||
current.map((step) => (step.id === id ? { ...step, label } : 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: '',
|
||||
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)
|
||||
if (!normalized.length) {
|
||||
setError('Add at least one order process step.')
|
||||
return
|
||||
}
|
||||
if (hasEmptyLabel) {
|
||||
setError('Every order step needs a label.')
|
||||
return
|
||||
}
|
||||
|
||||
setSavingKey('orderSteps')
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const data = await updateStoreSettings({ orderProcessSteps: normalized })
|
||||
setSettings(data.settings.store)
|
||||
setDraftSteps(data.settings.store.orderProcessSteps)
|
||||
showToast('Order process steps saved.', 'success')
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to save order process steps.')
|
||||
}
|
||||
} 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)
|
||||
|
||||
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}>Store settings</h2>
|
||||
<p className={pageStyles.pageSubtitle}>
|
||||
Control online sales and define how orders move through fulfillment.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className={sharedStyles.alertError} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className={sharedStyles.panel}>
|
||||
<h3 className={sharedStyles.sectionTitle}>Sales</h3>
|
||||
|
||||
{isLoading ? (
|
||||
<p className={sharedStyles.status}>Loading settings...</p>
|
||||
) : (
|
||||
<div className={sharedStyles.list}>
|
||||
<div className={sharedStyles.row}>
|
||||
<div className={sharedStyles.rowText}>
|
||||
<label htmlFor="online-sell" className={sharedStyles.rowLabel}>
|
||||
Online sell
|
||||
</label>
|
||||
<p className={sharedStyles.rowDescription}>
|
||||
When disabled, all sales on your website are turned off. Customers
|
||||
will not be able to place new orders online.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="online-sell"
|
||||
checked={settings.onlineSellEnabled}
|
||||
disabled={savingKey === 'onlineSell'}
|
||||
aria-label="Online sell"
|
||||
onChange={(checked) => void handleOnlineSellChange(checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className={`${sharedStyles.panel} ${styles.stepsPanel}`}>
|
||||
<div className={styles.stepsHeader}>
|
||||
<div>
|
||||
<h3 className={sharedStyles.sectionTitle}>Order process</h3>
|
||||
<p className={styles.stepsDescription}>
|
||||
Define the steps an order can move through — for example: under
|
||||
processing, ready for shipping, shipped, delivered.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<p className={sharedStyles.status}>Loading settings...</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={`Color for step ${index + 1}`}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className={styles.stepInput}
|
||||
value={step.label}
|
||||
placeholder="Step label"
|
||||
aria-label={`Order step ${index + 1}`}
|
||||
onChange={(e) => updateStepLabel(step.id, e.target.value)}
|
||||
/>
|
||||
<div className={styles.stepControls}>
|
||||
<Tooltip label="Move step up">
|
||||
<button
|
||||
type="button"
|
||||
className={controlStyles.controlBtn}
|
||||
onClick={() => moveStep(step.id, -1)}
|
||||
disabled={index === 0}
|
||||
aria-label="Move step up"
|
||||
>
|
||||
<ChevronUp size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="Move step down">
|
||||
<button
|
||||
type="button"
|
||||
className={controlStyles.controlBtn}
|
||||
onClick={() => moveStep(step.id, 1)}
|
||||
disabled={index === draftSteps.length - 1}
|
||||
aria-label="Move step down"
|
||||
>
|
||||
<ChevronDown size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="Remove step">
|
||||
<button
|
||||
type="button"
|
||||
className={removeStyles.removeRowBtn}
|
||||
onClick={() => removeStep(step.id)}
|
||||
disabled={draftSteps.length <= 1}
|
||||
aria-label="Remove step"
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!draftSteps.length && (
|
||||
<p className={styles.emptyText}>
|
||||
No order steps yet. Add the first step to define your workflow.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button type="button" className={styles.addStepBtn} onClick={addStep}>
|
||||
<Plus size={18} />
|
||||
Add step
|
||||
</button>
|
||||
|
||||
<div className={styles.stepsActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.saveBtn}
|
||||
onClick={() => void handleSaveSteps()}
|
||||
disabled={!canSaveSteps || savingKey === 'orderSteps'}
|
||||
>
|
||||
{savingKey === 'orderSteps' ? 'Saving...' : 'Save steps'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user