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:
Alireza Hassani
2026-08-10 00:23:03 +03:30
co-authored by Cursor
parent c5bdaad16b
commit 1271d96539
96 changed files with 10532 additions and 345 deletions
@@ -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;
+106 -22
View File
@@ -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,
+5 -1
View File
@@ -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
+32 -5
View File
@@ -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] = [