Expand SuperAdmin web for SMS, servers, plugins, admins, and UI config.

Align SMS tenant panel with Hamkar, add Jalali dates, and support hamkar/khanegi appearance editing.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-30 18:19:54 +03:30
co-authored by Cursor
parent 3371015b96
commit cfee6b0546
27 changed files with 4528 additions and 408 deletions
+139
View File
@@ -0,0 +1,139 @@
export type HamkarPricingBoxTheme = '1' | '2' | '3' | '4'
export type HamkarOrderUi = 'new' | 'old'
export type HamkarPriceFontSize = 'کوچک' | 'متوسط' | 'بزرگ' | 'بزرگ‌تر'
export type HamkarUiConfig = {
kind: 'hamkar'
/**
* When false, GET /get_user will NOT emit ui_settings —
* Laravel settings stay source of truth (prevents visual jumps).
*/
settingsManaged: boolean
/** Named CSS mode from live registry (Montpar, limangold, …) or dynamicMode */
registryMode: string
brand: {
/** Registry `color` → CSS `--bg2` (only when mode=dynamicMode) */
primaryColor: string
/** Registry `secondary_color` → CSS `--colorPrimary` (dynamicMode) */
secondaryColor: string
/** Registry `background_color` → CSS `--bg` (dynamicMode) */
backgroundColor: string
}
settings: {
pricing_box_theme: HamkarPricingBoxTheme
price_font_size: HamkarPriceFontSize
orderUI: HamkarOrderUi
}
}
export const HAMKAR_PRICING_BOX_THEME_OPTIONS: {
value: HamkarPricingBoxTheme
label: string
}[] = [
{ value: '1', label: 'تم ۱ (پیش‌فرض)' },
{ value: '2', label: 'تم ۲' },
{ value: '3', label: 'تم ۳' },
{ value: '4', label: 'تم ۴' },
]
export const HAMKAR_ORDER_UI_OPTIONS: { value: HamkarOrderUi; label: string }[] = [
{ value: 'new', label: 'جدید (کارتی)' },
{ value: 'old', label: 'قدیمی (جدولی)' },
]
export const HAMKAR_PRICE_FONT_SIZE_OPTIONS: {
value: HamkarPriceFontSize
label: string
}[] = [
{ value: 'کوچک', label: 'کوچک' },
{ value: 'متوسط', label: 'متوسط' },
{ value: 'بزرگ', label: 'بزرگ' },
{ value: 'بزرگ‌تر', label: 'بزرگ‌تر' },
]
function asColor(value: unknown): string {
if (value == null) return ''
const s = String(value).trim()
if (!s || s === 'null' || s === 'undefined') return ''
return s
}
export function createDefaultHamkarUiConfig(): HamkarUiConfig {
return {
kind: 'hamkar',
settingsManaged: false,
registryMode: 'dynamicMode',
brand: {
primaryColor: '',
secondaryColor: '',
backgroundColor: '',
},
settings: {
pricing_box_theme: '1',
price_font_size: 'بزرگ',
orderUI: 'old',
},
}
}
export function normalizeHamkarUiConfig(raw: unknown): HamkarUiConfig {
const base = createDefaultHamkarUiConfig()
if (!raw || typeof raw !== 'object') return base
const input = raw as Record<string, any>
const brand = input.brand && typeof input.brand === 'object' ? input.brand : {}
const settings =
input.settings && typeof input.settings === 'object' ? input.settings : {}
const theme = String(settings.pricing_box_theme ?? base.settings.pricing_box_theme)
const orderUI = String(settings.orderUI ?? base.settings.orderUI)
const font = String(settings.price_font_size ?? base.settings.price_font_size)
const registryMode = String(input.registryMode || base.registryMode).trim() || 'dynamicMode'
return {
kind: 'hamkar',
settingsManaged: Boolean(input.settingsManaged),
registryMode,
brand: {
// Do not invent colors — empty means null in registry/get_user
primaryColor: asColor(brand.primaryColor),
secondaryColor: asColor(brand.secondaryColor),
backgroundColor: asColor(brand.backgroundColor),
},
settings: {
pricing_box_theme: (['1', '2', '3', '4'].includes(theme)
? theme
: base.settings.pricing_box_theme) as HamkarPricingBoxTheme,
orderUI: (orderUI === 'old' || orderUI === 'new'
? orderUI
: base.settings.orderUI) as HamkarOrderUi,
price_font_size: (HAMKAR_PRICE_FONT_SIZE_OPTIONS.some((o) => o.value === font)
? font
: base.settings.price_font_size) as HamkarPriceFontSize,
},
}
}
/** Payload for API: empty color strings → null */
export function hamkarUiToApiPayload(config: HamkarUiConfig) {
const normalized = normalizeHamkarUiConfig(config)
const emptyToNull = (v: string) => (v.trim() ? v.trim() : null)
return {
...normalized,
brand: {
primaryColor: emptyToNull(normalized.brand.primaryColor),
secondaryColor: emptyToNull(normalized.brand.secondaryColor),
backgroundColor: emptyToNull(normalized.brand.backgroundColor),
},
}
}
/** Build Laravel-compatible settings overlay for Hamkar user app. */
export function hamkarUiToSettingsList(config: HamkarUiConfig) {
if (!config.settingsManaged) return []
return [
{ key: 'pricing_box_theme', value: config.settings.pricing_box_theme },
{ key: 'price_font_size', value: config.settings.price_font_size },
{ key: 'orderUI', value: config.settings.orderUI },
]
}
+67 -20
View File
@@ -1,3 +1,63 @@
export type ThemeColors = {
primary: string
secondary: string
tertiary: string
background: string
surface: string
surfaceHover: string
textPrimary: string
textSecondary: string
textSubtle: string
border: string
borderSubtle: string
pageBg: string
pageBgMobile: string
onAccent: string
success: string
warning: string
danger: string
}
const navyGoldDark: ThemeColors = {
primary: '#0A1B37',
secondary: '#e3b056',
tertiary: '#ce9d43',
background: '#ffffff',
surface: '#222228',
surfaceHover: '#2c2c34',
textPrimary: '#ffffff',
textSecondary: 'rgba(255, 255, 255, 0.45)',
textSubtle: 'rgba(255, 255, 255, 0.35)',
border: 'rgba(255, 255, 255, 0.16)',
borderSubtle: 'rgba(255, 255, 255, 0.12)',
pageBg: '#121216',
pageBgMobile: '#0e0e12',
onAccent: '#0a0a0a',
success: '#16A34A',
warning: '#F59E0B',
danger: '#DC2626',
}
const navyGoldLight: ThemeColors = {
primary: '#0A1B37',
secondary: '#c9973a',
tertiary: '#b98633',
background: '#faf8f5',
surface: '#ffffff',
surfaceHover: '#fffefb',
textPrimary: '#1c1916',
textSecondary: '#5c564d',
textSubtle: '#8a8278',
border: 'rgba(184, 153, 88, 0.22)',
borderSubtle: 'rgba(0, 0, 0, 0.08)',
pageBg: '#f5f2ed',
pageBgMobile: '#faf8f5',
onAccent: '#ffffff',
success: '#16A34A',
warning: '#F59E0B',
danger: '#DC2626',
}
/** Default Khanegi app.theme + navigation shell for new UI configs */
export function createDefaultKhanegiUiConfig() {
return {
@@ -10,25 +70,12 @@ export function createDefaultKhanegiUiConfig() {
locale: 'fa-IR',
startRoute: 'home',
theme: {
mode: 'dark',
colors: {
primary: '#0A1B37',
secondary: '#e3b056',
tertiary: '#ce9d43',
background: '#ffffff',
surface: '#222228',
surfaceHover: '#2c2c34',
textPrimary: '#ffffff',
textSecondary: 'rgba(255, 255, 255, 0.45)',
textSubtle: 'rgba(255, 255, 255, 0.35)',
border: 'rgba(255, 255, 255, 0.16)',
borderSubtle: 'rgba(255, 255, 255, 0.12)',
pageBg: '#121216',
pageBgMobile: '#0e0e12',
onAccent: '#0a0a0a',
success: '#16A34A',
warning: '#F59E0B',
danger: '#DC2626',
presetId: 'navy-gold' as string,
mode: 'dark' as 'dark' | 'light' | 'system',
colors: { ...navyGoldDark },
schemes: {
dark: { ...navyGoldDark },
light: { ...navyGoldLight },
},
borderRadius: { sm: 8, md: 12, lg: 16 },
},
@@ -65,7 +112,7 @@ export function createDefaultKhanegiUiConfig() {
brandSubtitle: 'پنل کاربری',
},
},
pages: {},
pages: {} as Record<string, unknown>,
}
}
+80
View File
@@ -0,0 +1,80 @@
/**
* Mirrors Hamkar user `apply-theme.ts` + default CSS in `main.less` / `maindynamic.less`.
* Named modes (Montpar, limangold, …) do NOT paint registry color fields onto the chrome.
*/
export type HamkarEffectiveTheme = {
mode: string
isDynamic: boolean
/** What paints the top header / mobile menu bar */
headerBg: string
/** Selected menu / text_Secondary / icon accent the user actually sees */
accent: string
/** Ant Design colorPrimary token (buttons) */
antPrimary: string
/** Page / body background */
pageBg: string
/** CSS vars apply-theme writes on :root */
cssVars: {
bg: string
bg2: string
colorPrimary: string
}
note: string
}
const NAMED_DEFAULT = {
headerBg: '#ffffff',
accent: '#2563EB',
antPrimary: '#1d4ed8',
pageBg: '#fafafa',
cssVars: {
bg: '',
bg2: '#ffffff',
colorPrimary: '#1d4ed8',
},
} as const
function hexOr(value: string | null | undefined, fallback: string) {
const s = (value || '').trim()
if (!s || s === 'null' || s === 'undefined') return fallback
return s
}
export function resolveHamkarEffectiveTheme(input: {
registryMode?: string | null
primaryColor?: string | null
secondaryColor?: string | null
backgroundColor?: string | null
}): HamkarEffectiveTheme {
const mode = String(input.registryMode || 'dynamicMode').trim() || 'dynamicMode'
const isDynamic = mode === 'dynamicMode'
if (!isDynamic) {
return {
mode,
isDynamic: false,
...NAMED_DEFAULT,
note: `حالت «${mode}»: هدر و منو از CSS پیش‌فرض همکار می‌آیند؛ فیلدهای color / secondary / bg رجیستری روی ظاهر اعمال نمی‌شوند.`,
}
}
const panel = hexOr(input.primaryColor, '#ffffff')
const accent = hexOr(input.secondaryColor, '#1d4ed8')
const pageBg = hexOr(input.backgroundColor, '#ffffff')
return {
mode,
isDynamic: true,
headerBg: panel,
accent,
antPrimary: accent,
pageBg,
cssVars: {
bg: pageBg,
bg2: panel,
colorPrimary: accent,
},
note: 'حالت dynamicMode: color → هدر (--bg2)، secondary_color → اکسنت (--colorPrimary)، background_color → پس‌زمینه (--bg).',
}
}
+222
View File
@@ -0,0 +1,222 @@
import { createDefaultKhanegiUiConfig, type ThemeColors, type UiConfig } from './defaultKhanegiUiConfig'
export type KhanegiPresetId = 'navy-gold' | 'emerald' | 'warm-bronze' | 'custom'
export type ThemeSchemes = {
dark: ThemeColors
light: ThemeColors
}
export type KhanegiThemePreset = {
id: Exclude<KhanegiPresetId, 'custom'>
nameFa: string
description: string
borderRadius: { sm: number; md: number; lg: number }
schemes: ThemeSchemes
/** رنگ‌های پیش‌نمایش کارت (حالت dark) */
preview: ThemeColors
}
const navyGoldDark: ThemeColors = {
primary: '#0A1B37',
secondary: '#e3b056',
tertiary: '#ce9d43',
background: '#ffffff',
surface: '#222228',
surfaceHover: '#2c2c34',
textPrimary: '#ffffff',
textSecondary: 'rgba(255, 255, 255, 0.45)',
textSubtle: 'rgba(255, 255, 255, 0.35)',
border: 'rgba(255, 255, 255, 0.16)',
borderSubtle: 'rgba(255, 255, 255, 0.12)',
pageBg: '#121216',
pageBgMobile: '#0e0e12',
onAccent: '#0a0a0a',
success: '#16A34A',
warning: '#F59E0B',
danger: '#DC2626',
}
const navyGoldLight: ThemeColors = {
primary: '#0A1B37',
secondary: '#c9973a',
tertiary: '#b98633',
background: '#faf8f5',
surface: '#ffffff',
surfaceHover: '#fffefb',
textPrimary: '#1c1916',
textSecondary: '#5c564d',
textSubtle: '#8a8278',
border: 'rgba(184, 153, 88, 0.22)',
borderSubtle: 'rgba(0, 0, 0, 0.08)',
pageBg: '#f5f2ed',
pageBgMobile: '#faf8f5',
onAccent: '#ffffff',
success: '#16A34A',
warning: '#F59E0B',
danger: '#DC2626',
}
const emeraldDark: ThemeColors = {
primary: '#064e3b',
secondary: '#fbbf24',
tertiary: '#d97706',
background: '#ffffff',
surface: '#14201c',
surfaceHover: '#1c2c26',
textPrimary: '#ffffff',
textSecondary: 'rgba(255, 255, 255, 0.5)',
textSubtle: 'rgba(255, 255, 255, 0.35)',
border: 'rgba(255, 255, 255, 0.14)',
borderSubtle: 'rgba(255, 255, 255, 0.1)',
pageBg: '#0b1411',
pageBgMobile: '#08100d',
onAccent: '#052e24',
success: '#22c55e',
warning: '#f59e0b',
danger: '#ef4444',
}
const emeraldLight: ThemeColors = {
primary: '#065f46',
secondary: '#059669',
tertiary: '#047857',
background: '#ecfdf5',
surface: '#ffffff',
surfaceHover: '#d1fae5',
textPrimary: '#064e3b',
textSecondary: '#047857',
textSubtle: '#6b8578',
border: 'rgba(5, 150, 105, 0.28)',
borderSubtle: 'rgba(6, 95, 70, 0.12)',
pageBg: '#d1fae5',
pageBgMobile: '#ecfdf5',
onAccent: '#ffffff',
success: '#16a34a',
warning: '#f59e0b',
danger: '#dc2626',
}
const warmBronzeDark: ThemeColors = {
primary: '#3f2a1a',
secondary: '#d4a373',
tertiary: '#b08968',
background: '#fffaf5',
surface: '#2a211c',
surfaceHover: '#352a23',
textPrimary: '#ffffff',
textSecondary: 'rgba(255, 255, 255, 0.5)',
textSubtle: 'rgba(255, 255, 255, 0.35)',
border: 'rgba(255, 255, 255, 0.14)',
borderSubtle: 'rgba(255, 255, 255, 0.1)',
pageBg: '#16110e',
pageBgMobile: '#120e0b',
onAccent: '#1c1410',
success: '#16a34a',
warning: '#f59e0b',
danger: '#dc2626',
}
const warmBronzeLight: ThemeColors = {
primary: '#7c2d12',
secondary: '#c2410c',
tertiary: '#9a3412',
background: '#fff7ed',
surface: '#ffffff',
surfaceHover: '#ffedd5',
textPrimary: '#7c2d12',
textSecondary: '#9a3412',
textSubtle: '#c2410c',
border: 'rgba(194, 65, 12, 0.28)',
borderSubtle: 'rgba(124, 45, 18, 0.12)',
pageBg: '#ffedd5',
pageBgMobile: '#fff7ed',
onAccent: '#ffffff',
success: '#16a34a',
warning: '#f59e0b',
danger: '#dc2626',
}
export const khanegiThemePresets: KhanegiThemePreset[] = [
{
id: 'navy-gold',
nameFa: 'سرمه‌ای طلایی',
description: 'تم اصلی خانگی — دارک و لایت',
borderRadius: { sm: 8, md: 12, lg: 16 },
schemes: { dark: navyGoldDark, light: navyGoldLight },
preview: navyGoldDark,
},
{
id: 'emerald',
nameFa: 'زمردی طلایی',
description: 'تم سبز تیره با اکسنت طلایی',
borderRadius: { sm: 8, md: 12, lg: 16 },
schemes: { dark: emeraldDark, light: emeraldLight },
preview: emeraldDark,
},
{
id: 'warm-bronze',
nameFa: 'برنز گرم',
description: 'تم برنز/کرم با گوشه‌های گردتر',
borderRadius: { sm: 12, md: 16, lg: 24 },
schemes: { dark: warmBronzeDark, light: warmBronzeLight },
preview: warmBronzeDark,
},
]
export function getKhanegiPreset(id: string): KhanegiThemePreset | undefined {
return khanegiThemePresets.find((p) => p.id === id)
}
/** اعمال preset روی config فعلی؛ pages موجود حفظ می‌شوند */
export function applyKhanegiPreset(config: UiConfig, presetId: Exclude<KhanegiPresetId, 'custom'>): UiConfig {
const preset = getKhanegiPreset(presetId)
if (!preset) return config
const mode = config.app.theme.mode === 'light' ? 'light' : 'dark'
const activeColors = { ...preset.schemes[mode] }
config.app.theme.presetId = presetId
config.app.theme.mode = mode
config.app.theme.colors = activeColors
config.app.theme.schemes = {
dark: { ...preset.schemes.dark },
light: { ...preset.schemes.light },
}
config.app.theme.borderRadius = { ...preset.borderRadius }
return config
}
/** ساخت config تازه از preset (برای نسخه جدید) */
export function createConfigFromPreset(presetId: Exclude<KhanegiPresetId, 'custom'> = 'navy-gold'): UiConfig {
const config = createDefaultKhanegiUiConfig()
return applyKhanegiPreset(config, presetId)
}
export function resolvePresetId(config: UiConfig): KhanegiPresetId {
const id = config.app?.theme?.presetId
if (id === 'navy-gold' || id === 'emerald' || id === 'warm-bronze' || id === 'custom') {
return id
}
return 'custom'
}
export function syncActiveColorsFromMode(config: UiConfig) {
const schemes = config.app.theme.schemes
if (!schemes?.dark || !schemes?.light) return
const mode = config.app.theme.mode === 'light' ? 'light' : 'dark'
Object.assign(config.app.theme.colors, schemes[mode])
}
export function markCustomAndSyncSchemes(config: UiConfig) {
config.app.theme.presetId = 'custom'
const mode = config.app.theme.mode === 'light' ? 'light' : 'dark'
if (!config.app.theme.schemes) {
config.app.theme.schemes = {
dark: { ...config.app.theme.colors },
light: { ...config.app.theme.colors },
}
}
Object.assign(config.app.theme.schemes[mode], config.app.theme.colors)
}