mirror of
https://git.meshkee.com/Meshkee/dashboards.git
synced 2026-08-11 22:30:58 +04:30
Add customer and business user-product flows across dashboards.
Ship my-products / customer-products UI with gallery uploads, status controls, module gating, and related shared UI polish. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
c5bdaad16b
commit
1271d96539
@@ -43,12 +43,12 @@
|
||||
z-index: 1200;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
width: max-content;
|
||||
min-width: 196px;
|
||||
max-width: 280px;
|
||||
padding: 8px 10px;
|
||||
padding: 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid rgba(255, 255, 255, 0.85);
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
@@ -57,6 +57,63 @@
|
||||
box-shadow: 0 12px 40px rgba(15, 23, 42, 0.16);
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.sectionLabel {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.themeRow {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.themeOption {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
min-height: 32px;
|
||||
padding: 0 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
transition: border-color 0.15s, background 0.15s, color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.themeOption:hover {
|
||||
border-color: rgba(148, 163, 184, 0.45);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.themeOptionSelected {
|
||||
border-color: var(--primary);
|
||||
background: rgba(var(--primary-rgb) / 0.1);
|
||||
color: var(--primary);
|
||||
box-shadow: 0 0 0 2px rgba(var(--primary-rgb) / 0.12);
|
||||
}
|
||||
|
||||
.colorRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.option {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Moon, Sun } from 'lucide-react'
|
||||
import {
|
||||
BUSINESS_PRIMARY_COLOR_IDS,
|
||||
BUSINESS_PRIMARY_COLOR_PALETTE,
|
||||
type BusinessPrimaryColorId,
|
||||
} from '../utils/businessPrimaryColors'
|
||||
import type { DashboardThemeMode } from '../services/businessSettingsService'
|
||||
import styles from './PrimaryColorSwatchControl.module.css'
|
||||
|
||||
interface PrimaryColorSwatchControlProps {
|
||||
value: BusinessPrimaryColorId
|
||||
themeMode: DashboardThemeMode
|
||||
onChange: (value: BusinessPrimaryColorId) => void
|
||||
onThemeModeChange: (value: DashboardThemeMode) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
@@ -20,7 +24,9 @@ type PopoverPosition = {
|
||||
|
||||
export function PrimaryColorSwatchControl({
|
||||
value,
|
||||
themeMode,
|
||||
onChange,
|
||||
onThemeModeChange,
|
||||
disabled = false,
|
||||
}: PrimaryColorSwatchControlProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
@@ -60,6 +66,12 @@ export function PrimaryColorSwatchControl({
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
function selectThemeMode(mode: DashboardThemeMode) {
|
||||
if (mode !== themeMode) {
|
||||
onThemeModeChange(mode)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
@@ -99,10 +111,10 @@ export function PrimaryColorSwatchControl({
|
||||
className={`${styles.trigger} ${open ? styles.triggerOpen : ''}`}
|
||||
onClick={toggleOpen}
|
||||
disabled={disabled}
|
||||
aria-label={`Dashboard primary color: ${tokens.label}`}
|
||||
aria-haspopup="listbox"
|
||||
aria-label={`Dashboard theme: ${themeMode}, color: ${tokens.label}`}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
title={`Theme: ${tokens.label}`}
|
||||
title={`Theme: ${themeMode} · ${tokens.label}`}
|
||||
>
|
||||
<span
|
||||
className={styles.swatchDot}
|
||||
@@ -117,35 +129,66 @@ export function PrimaryColorSwatchControl({
|
||||
<div
|
||||
ref={popoverRef}
|
||||
className={styles.popover}
|
||||
role="listbox"
|
||||
aria-label="Choose primary color"
|
||||
role="dialog"
|
||||
aria-label="Choose dashboard theme and primary color"
|
||||
style={{
|
||||
top: popoverPos.top,
|
||||
left: popoverPos.left,
|
||||
}}
|
||||
>
|
||||
{BUSINESS_PRIMARY_COLOR_IDS.map((colorId) => {
|
||||
const option = BUSINESS_PRIMARY_COLOR_PALETTE[colorId]
|
||||
const selected = colorId === value
|
||||
|
||||
return (
|
||||
<div className={styles.section}>
|
||||
<p className={styles.sectionLabel}>Theme</p>
|
||||
<div className={styles.themeRow} role="radiogroup" aria-label="Theme mode">
|
||||
<button
|
||||
key={colorId}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
aria-label={option.label}
|
||||
className={`${styles.option} ${selected ? styles.optionSelected : ''}`}
|
||||
onClick={() => selectColor(colorId)}
|
||||
role="radio"
|
||||
aria-checked={themeMode === 'light'}
|
||||
className={`${styles.themeOption} ${themeMode === 'light' ? styles.themeOptionSelected : ''}`}
|
||||
onClick={() => selectThemeMode('light')}
|
||||
>
|
||||
<span
|
||||
className={styles.optionDot}
|
||||
style={{ backgroundColor: option.primary }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Sun size={14} aria-hidden="true" />
|
||||
<span>Light</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={themeMode === 'dark'}
|
||||
className={`${styles.themeOption} ${themeMode === 'dark' ? styles.themeOptionSelected : ''}`}
|
||||
onClick={() => selectThemeMode('dark')}
|
||||
>
|
||||
<Moon size={14} aria-hidden="true" />
|
||||
<span>Dark</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.section}>
|
||||
<p className={styles.sectionLabel}>Color</p>
|
||||
<div className={styles.colorRow} role="listbox" aria-label="Primary color">
|
||||
{BUSINESS_PRIMARY_COLOR_IDS.map((colorId) => {
|
||||
const option = BUSINESS_PRIMARY_COLOR_PALETTE[colorId]
|
||||
const selected = colorId === value
|
||||
|
||||
return (
|
||||
<button
|
||||
key={colorId}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
aria-label={option.label}
|
||||
className={`${styles.option} ${selected ? styles.optionSelected : ''}`}
|
||||
onClick={() => selectColor(colorId)}
|
||||
>
|
||||
<span
|
||||
className={styles.optionDot}
|
||||
style={{ backgroundColor: option.primary }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
|
||||
@@ -459,13 +459,27 @@
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent);
|
||||
}
|
||||
|
||||
.modulesSection {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.modulesSection .entityGroupLabel {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.modulesChecks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px 16px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.modulesChecks {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.modulesCharts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -53,11 +53,16 @@ import {
|
||||
updateBusinessDefaultLocale,
|
||||
updateBusinessModules,
|
||||
updateBusinessPrimaryColor,
|
||||
updateBusinessThemeMode,
|
||||
type DashboardLocale,
|
||||
type DashboardThemeMode,
|
||||
} from '../services/businessSettingsService'
|
||||
import {
|
||||
BUSINESS_DASHBOARD_MODULE_IDS,
|
||||
BUSINESS_DASHBOARD_MODULE_LABELS,
|
||||
BUSINESS_MODULE_IDS,
|
||||
BUSINESS_MODULE_LABELS,
|
||||
CUSTOMER_MODULE_IDS,
|
||||
CUSTOMER_MODULE_LABELS,
|
||||
DEFAULT_ENABLED_BUSINESS_MODULES,
|
||||
DEFAULT_HOME_CHARTS,
|
||||
HOME_CHART_IDS,
|
||||
@@ -74,11 +79,16 @@ import styles from './BusinessesPage.module.css'
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 10
|
||||
const DEFAULT_LOCALE: DashboardLocale = 'fa'
|
||||
const DEFAULT_THEME_MODE: DashboardThemeMode = 'light'
|
||||
|
||||
function normalizeDefaultLocale(value: unknown): DashboardLocale {
|
||||
return value === 'en' || value === 'fa' ? value : DEFAULT_LOCALE
|
||||
}
|
||||
|
||||
function normalizeThemeMode(value: unknown): DashboardThemeMode {
|
||||
return value === 'dark' || value === 'light' ? value : DEFAULT_THEME_MODE
|
||||
}
|
||||
|
||||
function emptyModuleSelection(
|
||||
enabled: BusinessModuleId[] = DEFAULT_ENABLED_BUSINESS_MODULES,
|
||||
): Record<BusinessModuleId, boolean> {
|
||||
@@ -400,7 +410,7 @@ export function BusinessesPage() {
|
||||
|
||||
try {
|
||||
await updateBusinessPrimaryColor(b.id, primaryColor)
|
||||
showToast(`Theme updated for "${b.name}".`, 'success')
|
||||
showToast(`Theme color updated for "${b.name}".`, 'success')
|
||||
} catch (err) {
|
||||
setData((prev) => {
|
||||
if (!prev) return prev
|
||||
@@ -413,7 +423,53 @@ export function BusinessesPage() {
|
||||
})
|
||||
|
||||
showToast(
|
||||
err instanceof ApiError ? err.message : 'Unable to update theme.',
|
||||
err instanceof ApiError ? err.message : 'Unable to update theme color.',
|
||||
'error',
|
||||
)
|
||||
} finally {
|
||||
setSavingColorId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleThemeModeChange(
|
||||
b: BusinessListItem,
|
||||
themeMode: DashboardThemeMode,
|
||||
) {
|
||||
const currentMode = normalizeThemeMode(b.themeMode)
|
||||
if (currentMode === themeMode) return
|
||||
|
||||
setSavingColorId(b.id)
|
||||
setError('')
|
||||
|
||||
setData((prev) => {
|
||||
if (!prev) return prev
|
||||
return {
|
||||
...prev,
|
||||
items: prev.items.map((item) =>
|
||||
item.id === b.id ? { ...item, themeMode } : item,
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
await updateBusinessThemeMode(b.id, themeMode)
|
||||
showToast(
|
||||
`${themeMode === 'dark' ? 'Dark' : 'Light'} theme set for "${b.name}".`,
|
||||
'success',
|
||||
)
|
||||
} catch (err) {
|
||||
setData((prev) => {
|
||||
if (!prev) return prev
|
||||
return {
|
||||
...prev,
|
||||
items: prev.items.map((item) =>
|
||||
item.id === b.id ? { ...item, themeMode: currentMode } : item,
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
showToast(
|
||||
err instanceof ApiError ? err.message : 'Unable to update theme mode.',
|
||||
'error',
|
||||
)
|
||||
} finally {
|
||||
@@ -1103,10 +1159,14 @@ export function BusinessesPage() {
|
||||
<td className={`${styles.td} ${styles.tdTheme}`}>
|
||||
<PrimaryColorSwatchControl
|
||||
value={normalizeBusinessPrimaryColorId(b.primaryColor)}
|
||||
themeMode={normalizeThemeMode(b.themeMode)}
|
||||
disabled={savingColorId === b.id}
|
||||
onChange={(primaryColor) =>
|
||||
void handlePrimaryColorChange(b, primaryColor)
|
||||
}
|
||||
onThemeModeChange={(themeMode) =>
|
||||
void handleThemeModeChange(b, themeMode)
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td className={`${styles.td} ${styles.tdLocale}`}>
|
||||
@@ -1384,26 +1444,50 @@ export function BusinessesPage() {
|
||||
</p>
|
||||
) : null}
|
||||
<p className={styles.helperText}>
|
||||
Choose which optional modules this business can use. Customers, website,
|
||||
and other general sections stay available for every business.
|
||||
Choose which optional modules this business can use. Home, profile,
|
||||
website, and other general sections stay available for every business.
|
||||
</p>
|
||||
<div className={styles.modulesChecks}>
|
||||
{BUSINESS_MODULE_IDS.map((id) => (
|
||||
<label key={id} className={styles.entityCheck}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={modulesSelected[id]}
|
||||
disabled={modulesSubmitting}
|
||||
onChange={(e) =>
|
||||
setModulesSelected((prev) => ({
|
||||
...prev,
|
||||
[id]: e.target.checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<span>{BUSINESS_MODULE_LABELS[id]}</span>
|
||||
</label>
|
||||
))}
|
||||
<div className={styles.modulesSection}>
|
||||
<p className={styles.entityGroupLabel}>Business modules</p>
|
||||
<div className={styles.modulesChecks}>
|
||||
{BUSINESS_DASHBOARD_MODULE_IDS.map((id) => (
|
||||
<label key={id} className={styles.entityCheck}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={modulesSelected[id]}
|
||||
disabled={modulesSubmitting}
|
||||
onChange={(e) =>
|
||||
setModulesSelected((prev) => ({
|
||||
...prev,
|
||||
[id]: e.target.checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<span>{BUSINESS_DASHBOARD_MODULE_LABELS[id]}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.modulesSection}>
|
||||
<p className={styles.entityGroupLabel}>Customer modules</p>
|
||||
<div className={styles.modulesChecks}>
|
||||
{CUSTOMER_MODULE_IDS.map((id) => (
|
||||
<label key={id} className={styles.entityCheck}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={modulesSelected[id]}
|
||||
disabled={modulesSubmitting}
|
||||
onChange={(e) =>
|
||||
setModulesSelected((prev) => ({
|
||||
...prev,
|
||||
[id]: e.target.checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<span>{CUSTOMER_MODULE_LABELS[id]}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.modulesCharts}>
|
||||
<p className={styles.entityGroupLabel}>Home charts</p>
|
||||
|
||||
@@ -4,9 +4,12 @@ import type { BusinessModuleId, HomeChartId } from '../utils/businessModules'
|
||||
|
||||
export type DashboardLocale = 'en' | 'fa'
|
||||
|
||||
export type DashboardThemeMode = 'light' | 'dark'
|
||||
|
||||
export interface BrandingSettings {
|
||||
primaryColor: BusinessPrimaryColorId
|
||||
defaultLocale: DashboardLocale
|
||||
themeMode: DashboardThemeMode
|
||||
}
|
||||
|
||||
export interface ModulesSettings {
|
||||
@@ -52,6 +55,19 @@ export async function updateBusinessPrimaryColor(
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateBusinessThemeMode(
|
||||
businessId: string,
|
||||
themeMode: DashboardThemeMode,
|
||||
) {
|
||||
return apiRequest<BusinessSettingsResponse>(`/businesses/${businessId}/settings`, {
|
||||
method: 'PATCH',
|
||||
auth: true,
|
||||
body: {
|
||||
branding: { themeMode },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateBusinessDefaultLocale(
|
||||
businessId: string,
|
||||
defaultLocale: DashboardLocale,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { BusinessPrimaryColorId } from '../utils/businessPrimaryColors'
|
||||
import type { BusinessModuleId, HomeChartId } from '../utils/businessModules'
|
||||
import type { DashboardLocale } from '../services/businessSettingsService'
|
||||
import type {
|
||||
DashboardLocale,
|
||||
DashboardThemeMode,
|
||||
} from '../services/businessSettingsService'
|
||||
|
||||
export interface BusinessOwnerInfo {
|
||||
name: string | null
|
||||
@@ -28,6 +31,7 @@ export interface BusinessListItem {
|
||||
ownerCellNumber: string | null
|
||||
isActive: boolean
|
||||
primaryColor: BusinessPrimaryColorId
|
||||
themeMode: DashboardThemeMode
|
||||
defaultLocale: DashboardLocale
|
||||
enabledModules: BusinessModuleId[]
|
||||
moduleCount: number
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** Optional CMS modules a business can have. Always-on areas are not listed. */
|
||||
export const BUSINESS_MODULE_IDS = [
|
||||
/** Optional business-dashboard CMS modules. Always-on areas are not listed. */
|
||||
export const BUSINESS_DASHBOARD_MODULE_IDS = [
|
||||
'products',
|
||||
'store',
|
||||
'portfolio',
|
||||
@@ -8,9 +8,24 @@ export const BUSINESS_MODULE_IDS = [
|
||||
'videos',
|
||||
] as const
|
||||
|
||||
/** Optional customer-dashboard modules. Always-on: home, profile, addresses, orders, favorites. */
|
||||
export const CUSTOMER_MODULE_IDS = ['customer_products'] as const
|
||||
|
||||
/** All optional modules stored in `settings.modules.enabled`. */
|
||||
export const BUSINESS_MODULE_IDS = [
|
||||
...BUSINESS_DASHBOARD_MODULE_IDS,
|
||||
...CUSTOMER_MODULE_IDS,
|
||||
] as const
|
||||
|
||||
export type BusinessDashboardModuleId =
|
||||
(typeof BUSINESS_DASHBOARD_MODULE_IDS)[number]
|
||||
export type CustomerModuleId = (typeof CUSTOMER_MODULE_IDS)[number]
|
||||
export type BusinessModuleId = (typeof BUSINESS_MODULE_IDS)[number]
|
||||
|
||||
export const BUSINESS_MODULE_LABELS: Record<BusinessModuleId, string> = {
|
||||
export const BUSINESS_DASHBOARD_MODULE_LABELS: Record<
|
||||
BusinessDashboardModuleId,
|
||||
string
|
||||
> = {
|
||||
products: 'Products',
|
||||
store: 'Store',
|
||||
portfolio: 'Portfolio',
|
||||
@@ -19,6 +34,15 @@ export const BUSINESS_MODULE_LABELS: Record<BusinessModuleId, string> = {
|
||||
videos: 'Videos',
|
||||
}
|
||||
|
||||
export const CUSTOMER_MODULE_LABELS: Record<CustomerModuleId, string> = {
|
||||
customer_products: 'Customer products',
|
||||
}
|
||||
|
||||
export const BUSINESS_MODULE_LABELS: Record<BusinessModuleId, string> = {
|
||||
...BUSINESS_DASHBOARD_MODULE_LABELS,
|
||||
...CUSTOMER_MODULE_LABELS,
|
||||
}
|
||||
|
||||
/** Home dashboard chart slots (super-admin selectable). */
|
||||
export const HOME_CHART_IDS = [
|
||||
'none',
|
||||
@@ -38,9 +62,12 @@ export const HOME_CHART_LABELS: Record<HomeChartId, string> = {
|
||||
products_added_1y: 'Added product in last year',
|
||||
}
|
||||
|
||||
/** Existing tenants without saved modules keep every module enabled. */
|
||||
/**
|
||||
* Existing tenants without saved modules keep every business-dashboard module
|
||||
* enabled. Customer modules stay opt-in.
|
||||
*/
|
||||
export const DEFAULT_ENABLED_BUSINESS_MODULES: BusinessModuleId[] = [
|
||||
...BUSINESS_MODULE_IDS,
|
||||
...BUSINESS_DASHBOARD_MODULE_IDS,
|
||||
]
|
||||
|
||||
export const DEFAULT_HOME_CHARTS: [HomeChartId, HomeChartId] = [
|
||||
|
||||
Reference in New Issue
Block a user