diff --git a/apps/customer/src/components/Toast.module.css b/apps/customer/src/components/Toast.module.css
index 48138b7..7d71701 100644
--- a/apps/customer/src/components/Toast.module.css
+++ b/apps/customer/src/components/Toast.module.css
@@ -2,7 +2,7 @@
position: fixed;
bottom: 24px;
left: 24px;
- z-index: 300;
+ z-index: var(--toast-z-index, 11000);
display: flex;
flex-direction: column;
align-items: flex-start;
@@ -19,12 +19,12 @@
font-weight: 500;
line-height: 1.4;
color: var(--text-primary);
- background: var(--elevated-surface);
+ background: var(--toast-bg, rgba(255, 255, 255, 0.94));
backdrop-filter: blur(var(--blur-glass));
-webkit-backdrop-filter: blur(var(--blur-glass));
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
- box-shadow: var(--glass-shadow);
+ box-shadow: var(--toast-shadow, 0 12px 40px rgba(15, 23, 42, 0.14));
}
.toastIn {
diff --git a/apps/customer/src/index.css b/apps/customer/src/index.css
index e8b18ea..dd32b0f 100644
--- a/apps/customer/src/index.css
+++ b/apps/customer/src/index.css
@@ -27,6 +27,7 @@
--surface: rgba(255, 255, 255, 0.82);
--card-media-bg: #ffffff;
--elevated-surface: rgba(255, 255, 255, 0.96);
+ --toast-bg: rgba(255, 255, 255, 0.94);
--icon-bg: color-mix(in srgb, var(--primary) 14%, #ffffff);
--icon-bg-end: color-mix(in srgb, var(--primary) 8%, #f1f5f9);
}
@@ -55,6 +56,7 @@ html[data-theme='dark'] {
--surface: rgba(55, 59, 68, 0.95);
--card-media-bg: #32363f;
--elevated-surface: #3a3e48;
+ --toast-bg: rgba(58, 62, 72, 0.96);
--card-hover-shadow: 0 16px 48px rgba(0, 0, 0, 0.45);
diff --git a/apps/customer/src/pages/AddressesPage.tsx b/apps/customer/src/pages/AddressesPage.tsx
index 7b49f74..17397e6 100644
--- a/apps/customer/src/pages/AddressesPage.tsx
+++ b/apps/customer/src/pages/AddressesPage.tsx
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from 'react'
import { MapPin, Pencil, Plus, Trash2 } from 'lucide-react'
import { Breadcrumbs, useToast } from '@meshkee/dashboard-ui'
import { AddressFormModal } from '../components/AddressFormModal'
+import { EmptyState } from '../components/EmptyState'
import { Tooltip } from '../components/Tooltip'
import { useT } from '../i18n/useT'
import { ApiError, isAbortError } from '../lib/api'
@@ -125,10 +126,12 @@ export function AddressesPage() {
{loading &&
{t('addresses.loading')}
}
{!loading && addresses.length === 0 && (
-
-
-
{t('addresses.empty')}
-
+
)}
{!loading && addresses.length > 0 && (
diff --git a/apps/customer/src/pages/FavoritesPage.tsx b/apps/customer/src/pages/FavoritesPage.tsx
index a7c2af4..eb3046f 100644
--- a/apps/customer/src/pages/FavoritesPage.tsx
+++ b/apps/customer/src/pages/FavoritesPage.tsx
@@ -2,6 +2,7 @@ import { PageTitle } from '../components/PageTitle'
import { useEffect, useState } from 'react'
import { Heart } from 'lucide-react'
import { Breadcrumbs, Pagination, useToast } from '@meshkee/dashboard-ui'
+import { EmptyState } from '../components/EmptyState'
import { FavoriteStoreItemCard } from '../components/FavoriteStoreItemCard'
import { useT } from '../i18n/useT'
import { ApiError, isAbortError } from '../lib/api'
@@ -97,10 +98,7 @@ export function FavoritesPage() {
{loading &&
{t('favorites.loading')}
}
{!loading && data?.items.length === 0 && (
-
-
-
{t('favorites.empty')}
-
+
)}
{!!data?.items.length && (
diff --git a/apps/customer/src/pages/LoginPage.module.css b/apps/customer/src/pages/LoginPage.module.css
index e72241c..5ff2507 100644
--- a/apps/customer/src/pages/LoginPage.module.css
+++ b/apps/customer/src/pages/LoginPage.module.css
@@ -71,10 +71,10 @@
}
.domain {
- font-size: 20px;
+ font-size: 17px;
font-weight: 700;
color: var(--text-primary);
- line-height: 1.25;
+ line-height: 1.3;
}
.appName {
diff --git a/apps/customer/src/pages/MyProductsPage.tsx b/apps/customer/src/pages/MyProductsPage.tsx
index 8d93487..1fbe170 100644
--- a/apps/customer/src/pages/MyProductsPage.tsx
+++ b/apps/customer/src/pages/MyProductsPage.tsx
@@ -3,6 +3,7 @@ import { useEffect, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { Package, Plus } from 'lucide-react'
import { Breadcrumbs, Pagination, useToast } from '@meshkee/dashboard-ui'
+import { EmptyState } from '../components/EmptyState'
import { UserProductCard } from '../components/UserProductCard'
import { useT } from '../i18n/useT'
import { ApiError, isAbortError } from '../lib/api'
@@ -141,13 +142,12 @@ export function MyProductsPage() {
{loading ?
{t('myProducts.loading')}
: null}
{!loading && !error && products.length === 0 ? (
-
-
-
{t('myProducts.empty')}
-
- {t('myProducts.add')}
-
-
+
) : null}
{!loading && products.length > 0 ? (
diff --git a/apps/customer/src/pages/OrdersPage.tsx b/apps/customer/src/pages/OrdersPage.tsx
index 0f87e05..0240bc3 100644
--- a/apps/customer/src/pages/OrdersPage.tsx
+++ b/apps/customer/src/pages/OrdersPage.tsx
@@ -1,6 +1,7 @@
import { PageTitle } from '../components/PageTitle'
import { useEffect, useMemo, useState } from 'react'
import { Breadcrumbs, Pagination, useLocale } from '@meshkee/dashboard-ui'
+import { EmptyTableRow } from '../components/EmptyState'
import { OrderItemsModal } from '../components/OrderItemsModal'
import { OrderRow } from '../components/OrderRow'
import { useT } from '../i18n/useT'
@@ -131,11 +132,7 @@ export function OrdersPage() {
)}
{!loading && data?.items.length === 0 && (
-
- |
- {t('orders.empty')}
- |
-
+
)}
{!loading &&
diff --git a/apps/super-admin/src/components/EmptyState.tsx b/apps/super-admin/src/components/EmptyState.tsx
new file mode 100644
index 0000000..eb6edf2
--- /dev/null
+++ b/apps/super-admin/src/components/EmptyState.tsx
@@ -0,0 +1,6 @@
+export {
+ EmptyState,
+ EmptyStateEmphasis,
+ EmptyTableRow,
+ type EmptyStateProps,
+} from '@meshkee/dashboard-ui'
diff --git a/apps/super-admin/src/components/RichTextEditor.module.css b/apps/super-admin/src/components/RichTextEditor.module.css
deleted file mode 100644
index d8a323b..0000000
--- a/apps/super-admin/src/components/RichTextEditor.module.css
+++ /dev/null
@@ -1,83 +0,0 @@
-.wrapper {
- border: 1px solid rgba(148, 163, 184, 0.35);
- border-radius: var(--radius-sm);
- background: rgba(255, 255, 255, 0.7);
- overflow: hidden;
- transition: border-color 0.2s, box-shadow 0.2s;
-}
-
-.wrapper:focus-within {
- border-color: var(--primary);
- box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
-}
-
-.toolbar {
- display: flex;
- align-items: center;
- gap: 2px;
- padding: 3px 6px;
- border-bottom: 1px solid rgba(148, 163, 184, 0.2);
- background: rgba(248, 250, 252, 0.8);
-}
-
-.toolbar button {
- width: 26px;
- height: 26px;
- display: flex;
- align-items: center;
- justify-content: center;
- border-radius: 6px;
- color: var(--text-secondary);
- transition: background 0.15s, color 0.15s;
-}
-
-.toolbar button:hover {
- background: rgba(var(--primary-rgb) / 0.1);
- color: var(--primary);
-}
-
-.divider {
- width: 1px;
- height: 16px;
- background: rgba(148, 163, 184, 0.3);
- margin: 0 4px;
-}
-
-.editor {
- min-height: 88px;
- max-height: 160px;
- overflow: auto;
- padding: var(--field-padding-y) var(--field-padding-x);
- font-size: var(--field-font-size);
- line-height: 1.45;
- color: var(--text-primary);
- outline: none;
-}
-
-.editor:empty::before {
- content: attr(data-placeholder);
- color: var(--text-muted);
- pointer-events: none;
-}
-
-.editor ul,
-.editor ol {
- padding-left: 1.4em;
- margin: 0.35em 0;
-}
-
-.editor p {
- margin: 0 0 0.35em;
-}
-
-.editor p:last-child {
- margin-bottom: 0;
-}
-
-.editor blockquote {
- margin: 0.35em 0;
- margin-inline-start: 1.4em;
- margin-inline-end: 0;
- padding: 0;
- border: 0;
-}
diff --git a/apps/super-admin/src/components/RichTextEditor.tsx b/apps/super-admin/src/components/RichTextEditor.tsx
index d357f6a..8e0bf47 100644
--- a/apps/super-admin/src/components/RichTextEditor.tsx
+++ b/apps/super-admin/src/components/RichTextEditor.tsx
@@ -1,177 +1,6 @@
-import { useCallback, useEffect, useRef } from 'react'
-import {
- Bold,
- IndentDecrease,
- IndentIncrease,
- Italic,
- List,
- ListOrdered,
- Underline,
-} from 'lucide-react'
-import styles from './RichTextEditor.module.css'
-
-const INDENT_STEP_PX = 24
-const INDENT_BLOCK_TAGS = new Set(['P', 'DIV', 'LI', 'BLOCKQUOTE', 'H1', 'H2', 'H3', 'H4'])
-
-interface RichTextEditorProps {
- value: string
- onChange: (value: string) => void
- placeholder?: string
- /** Compact editor height for short fields like invoice top text. */
- editorMinHeight?: number
-}
-
-export function RichTextEditor({
- value,
- onChange,
- placeholder = '',
- editorMinHeight = 88,
-}: RichTextEditorProps) {
- const editorRef = useRef
(null)
-
- const syncChange = useCallback(() => {
- if (editorRef.current) {
- onChange(editorRef.current.innerHTML)
- }
- }, [onChange])
-
- useEffect(() => {
- if (editorRef.current && editorRef.current.innerHTML !== value) {
- editorRef.current.innerHTML = value
- }
- }, [value])
-
- function exec(command: string) {
- editorRef.current?.focus()
- document.execCommand(command)
- syncChange()
- }
-
- function findIndentBlock(editor: HTMLElement): HTMLElement | null {
- const selection = window.getSelection()
- if (!selection || selection.rangeCount === 0) return null
-
- let node: Node | null = selection.anchorNode
- while (node && node !== editor) {
- if (node instanceof HTMLElement && INDENT_BLOCK_TAGS.has(node.tagName)) {
- return node
- }
- node = node.parentNode
- }
- return null
- }
-
- function changeIndent(direction: 1 | -1) {
- const editor = editorRef.current
- if (!editor) return
-
- editor.focus()
- const block = findIndentBlock(editor)
- if (!block) {
- document.execCommand(direction > 0 ? 'indent' : 'outdent')
- syncChange()
- return
- }
-
- const current =
- Number.parseFloat(block.style.paddingInlineStart) ||
- Number.parseFloat(getComputedStyle(block).paddingInlineStart) ||
- 0
- const next = Math.max(0, Math.round(current + direction * INDENT_STEP_PX))
- if (next === 0) {
- block.style.paddingInlineStart = ''
- } else {
- block.style.paddingInlineStart = `${next}px`
- }
- syncChange()
- }
-
- function onEditorKeyDown(e: React.KeyboardEvent) {
- if (e.key !== 'Tab' || e.altKey || e.ctrlKey || e.metaKey) return
- e.preventDefault()
- changeIndent(e.shiftKey ? -1 : 1)
- }
-
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- )
-}
-
-/** True when HTML is empty or only whitespace / empty tags. */
-export function isEmptyRichText(html: string | null | undefined): boolean {
- return richTextToPlain(html).length === 0
-}
-
-/** Strip tags for single-line list previews. */
-export function richTextToPlain(html: string | null | undefined): string {
- if (!html) return ''
- return html
- .replace(/
/gi, ' ')
- .replace(/ /gi, ' ')
- .replace(/<[^>]+>/g, '')
- .replace(/\s+/g, ' ')
- .trim()
-}
+export {
+ RichTextEditor,
+ isEmptyRichText,
+ richTextToPlain,
+ type RichTextEditorProps,
+} from '@meshkee/dashboard-ui'
diff --git a/apps/super-admin/src/components/Toast.module.css b/apps/super-admin/src/components/Toast.module.css
index 130eaae..7d71701 100644
--- a/apps/super-admin/src/components/Toast.module.css
+++ b/apps/super-admin/src/components/Toast.module.css
@@ -1,12 +1,11 @@
.container {
position: fixed;
bottom: 24px;
- right: 24px;
- /* Above Modal overlay (z-index 300) so success/error feedback is visible while dialogs are open */
- z-index: 400;
+ left: 24px;
+ z-index: var(--toast-z-index, 11000);
display: flex;
flex-direction: column;
- align-items: flex-end;
+ align-items: flex-start;
gap: 8px;
pointer-events: none;
}
@@ -20,12 +19,12 @@
font-weight: 500;
line-height: 1.4;
color: var(--text-primary);
- background: rgba(255, 255, 255, 0.55);
+ background: var(--toast-bg, rgba(255, 255, 255, 0.94));
backdrop-filter: blur(var(--blur-glass));
-webkit-backdrop-filter: blur(var(--blur-glass));
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
- box-shadow: var(--glass-shadow);
+ box-shadow: var(--toast-shadow, 0 12px 40px rgba(15, 23, 42, 0.14));
}
.toastIn {
diff --git a/apps/super-admin/src/context/ToastContext.tsx b/apps/super-admin/src/context/ToastContext.tsx
index cc0002e..39800a7 100644
--- a/apps/super-admin/src/context/ToastContext.tsx
+++ b/apps/super-admin/src/context/ToastContext.tsx
@@ -8,6 +8,7 @@ import {
useState,
type ReactNode,
} from 'react'
+import { createPortal } from 'react-dom'
import styles from '../components/Toast.module.css'
export type ToastVariant = 'success' | 'error' | 'info'
@@ -74,17 +75,20 @@ export function ToastProvider({ children }: { children: ReactNode }) {
return (
{children}
-
- {toast && (
-
- {toast.message}
-
- )}
-
+ {createPortal(
+
+ {toast && (
+
+ {toast.message}
+
+ )}
+
,
+ document.body,
+ )}
)
}
diff --git a/apps/super-admin/src/index.css b/apps/super-admin/src/index.css
index 008b41f..7c209c8 100644
--- a/apps/super-admin/src/index.css
+++ b/apps/super-admin/src/index.css
@@ -36,6 +36,9 @@
--blur-glass: 28px;
--modal-overlay-bg: rgba(15, 23, 42, 0.4);
--modal-overlay-blur: 16px;
+ --toast-z-index: 11000;
+ --toast-bg: rgba(255, 255, 255, 0.94);
+ --toast-shadow: 0 12px 40px rgba(15, 23, 42, 0.14);
--select-arrow-size: 16px;
--select-arrow-offset: 12px;
--select-padding-end: 2.5rem;
diff --git a/apps/super-admin/src/pages/SettingsPage.tsx b/apps/super-admin/src/pages/SettingsPage.tsx
index 45e5b0e..3263120 100644
--- a/apps/super-admin/src/pages/SettingsPage.tsx
+++ b/apps/super-admin/src/pages/SettingsPage.tsx
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { FileStack, FileText, Pencil, Plus, Settings as SettingsIcon, Trash2 } from 'lucide-react'
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
+import { EmptyTableRow } from '../components/EmptyState'
import { Modal } from '../components/Modal'
import { isEmptyRichText, richTextToPlain } from '../components/RichTextEditor'
import { useToast } from '../context/ToastContext'
@@ -236,14 +237,13 @@ export function SettingsPage() {
{!loading && invoiceTemplates.length === 0 ? (
-
- |
-
-
- No invoice templates yet. Add one to speed up issuing invoices.
-
- |
-
+
) : null}
{invoiceTemplates.map((template) => (
@@ -330,14 +330,13 @@ export function SettingsPage() {
{!loading && itemTemplates.length === 0 ? (
-
- |
-
-
- No predefined items yet.
-
- |
-
+
) : null}
{itemTemplates.map((template) => (
diff --git a/apps/super-admin/src/utils/businessModules.ts b/apps/super-admin/src/utils/businessModules.ts
index 52c4508..5ede19a 100644
--- a/apps/super-admin/src/utils/businessModules.ts
+++ b/apps/super-admin/src/utils/businessModules.ts
@@ -7,6 +7,7 @@ export const BUSINESS_DASHBOARD_MODULE_IDS = [
'warehouse',
'videos',
'instructions',
+ 'workshops',
'finance',
] as const
@@ -35,6 +36,7 @@ export const BUSINESS_DASHBOARD_MODULE_LABELS: Record<
warehouse: 'Warehouse',
videos: 'Videos',
instructions: 'Instructions',
+ workshops: 'Workshops',
finance: 'Finance',
}
@@ -68,11 +70,10 @@ export const HOME_CHART_LABELS: Record = {
/**
* Existing tenants without saved modules keep every business-dashboard module
- * enabled. Customer modules stay opt-in.
+ * enabled except opt-in modules. Customer modules stay opt-in.
*/
-export const DEFAULT_ENABLED_BUSINESS_MODULES: BusinessModuleId[] = [
- ...BUSINESS_DASHBOARD_MODULE_IDS,
-]
+export const DEFAULT_ENABLED_BUSINESS_MODULES: BusinessModuleId[] =
+ BUSINESS_DASHBOARD_MODULE_IDS.filter((id) => id !== 'workshops')
export const DEFAULT_HOME_CHARTS: [HomeChartId, HomeChartId] = [
'orders_30d',
diff --git a/package-lock.json b/package-lock.json
index 85a0650..c3f4159 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -19,6 +19,7 @@
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-easy-crop": "^6.2.0",
+ "react-multi-date-picker": "^4.5.2",
"react-router-dom": "^7.18.1"
},
"devDependencies": {
@@ -1275,6 +1276,12 @@
"node": ">=0.10.0"
}
},
+ "node_modules/react-date-object": {
+ "version": "2.1.9",
+ "resolved": "https://registry.npmjs.org/react-date-object/-/react-date-object-2.1.9.tgz",
+ "integrity": "sha512-BHxD/quWOTo9fLKV/cfL/M31ePoj4a1JaJ/CnOf8Ndg3mrkh4x9wEMMkCfTrzduxDOgU8ZgR8uarhqI5G71sTg==",
+ "license": "MIT"
+ },
"node_modules/react-dom": {
"version": "19.2.7",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
@@ -1300,6 +1307,30 @@
"react-dom": ">=16.4.0"
}
},
+ "node_modules/react-element-popper": {
+ "version": "2.1.7",
+ "resolved": "https://registry.npmjs.org/react-element-popper/-/react-element-popper-2.1.7.tgz",
+ "integrity": "sha512-tuM2OxKlW32h+6uFSK6EENHPeZ2OGgOipHfOAl+VLWEv9/j3QkSGbD+ADX3A9uJlmq24i37n28RjJmAbGTfpEg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": ">=16.8.0",
+ "react-dom": ">=16.8.0"
+ }
+ },
+ "node_modules/react-multi-date-picker": {
+ "version": "4.5.2",
+ "resolved": "https://registry.npmjs.org/react-multi-date-picker/-/react-multi-date-picker-4.5.2.tgz",
+ "integrity": "sha512-FgWjZB3Z6IA6XpcWiLPk85PwcRUhOiYhKK42o5k672gD/n2I6rzPfQ8bUrldOIiF/Z7FfOCdH7a6FeubzqteLg==",
+ "license": "MIT",
+ "dependencies": {
+ "react-date-object": "^2.1.8",
+ "react-element-popper": "^2.1.6"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0",
+ "react-dom": ">=16.8.0"
+ }
+ },
"node_modules/react-router": {
"version": "7.18.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz",
diff --git a/packages/dashboard-core/src/styles/tokens.css b/packages/dashboard-core/src/styles/tokens.css
index 3b67db5..44bdc2d 100644
--- a/packages/dashboard-core/src/styles/tokens.css
+++ b/packages/dashboard-core/src/styles/tokens.css
@@ -38,6 +38,9 @@
--blur-glass: 28px;
--modal-overlay-bg: rgba(15, 23, 42, 0.4);
--modal-overlay-blur: 16px;
+ --toast-z-index: 11000;
+ --toast-bg: rgba(255, 255, 255, 0.94);
+ --toast-shadow: 0 12px 40px rgba(15, 23, 42, 0.14);
--text-primary: #1e293b;
--text-secondary: #64748b;
--text-muted: #94a3b8;
diff --git a/packages/dashboard-ui/src/components/EmptyState.module.css b/packages/dashboard-ui/src/components/EmptyState.module.css
new file mode 100644
index 0000000..577803d
--- /dev/null
+++ b/packages/dashboard-ui/src/components/EmptyState.module.css
@@ -0,0 +1,130 @@
+.layoutPage {
+ display: flex;
+ justify-content: center;
+ width: 100%;
+ padding: 32px 0 48px;
+}
+
+.rootDashed {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 24px;
+ width: 100%;
+ max-width: 880px;
+ margin-inline: auto;
+ padding: 0 16px;
+ text-align: center;
+}
+
+.rootDashedInline {
+ max-width: 100%;
+ padding: 24px 12px;
+}
+
+.messageDashed {
+ margin: 0;
+ width: 100%;
+ max-width: 880px;
+ font-family: var(--font-ui);
+ font-size: 15px;
+ font-weight: 500;
+ line-height: 1.6;
+ color: var(--text-secondary);
+ text-align: center;
+ white-space: nowrap;
+}
+
+.messageOnly {
+ margin: 0;
+ width: 100%;
+ max-width: 880px;
+ font-family: var(--font-ui);
+ font-size: 15px;
+ font-weight: 500;
+ line-height: 1.85;
+ color: var(--text-secondary);
+ text-align: center;
+}
+
+.messageEmphasis {
+ font-weight: 700;
+ color: var(--text-primary);
+}
+
+.addBox {
+ appearance: none;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 12px;
+ width: 100%;
+ max-width: 880px;
+ min-height: 180px;
+ padding: 32px 28px;
+ background: var(--glass-bg);
+ backdrop-filter: blur(20px);
+ -webkit-backdrop-filter: blur(20px);
+ border: 2px dashed rgba(var(--primary-rgb) / 0.38);
+ border-radius: var(--radius);
+ color: var(--primary);
+ font-family: var(--font-ui);
+ text-decoration: none;
+ cursor: pointer;
+ transition:
+ background 0.2s ease,
+ border-color 0.2s ease,
+ transform 0.2s ease,
+ box-shadow 0.2s ease;
+}
+
+.addBox:hover {
+ background: rgba(var(--primary-rgb) / 0.08);
+ border-color: rgba(var(--primary-rgb) / 0.58);
+ transform: translateY(-3px);
+ box-shadow: var(--card-hover-shadow);
+}
+
+.addBox:focus-visible {
+ outline: 2px solid var(--primary);
+ outline-offset: 3px;
+}
+
+.addIcon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 52px;
+ height: 52px;
+ border-radius: 50%;
+ background: rgba(var(--primary-rgb) / 0.12);
+}
+
+.addLabel {
+ font-size: 14px;
+ font-weight: 600;
+ line-height: 1.4;
+}
+
+.cell {
+ padding: 0 !important;
+ background: transparent !important;
+ border-bottom: none !important;
+ vertical-align: middle;
+}
+
+@media (max-width: 1100px) {
+ .messageDashed {
+ white-space: normal;
+ line-height: 1.85;
+ }
+
+ .rootDashed,
+ .messageDashed,
+ .messageOnly,
+ .addBox {
+ max-width: min(100%, 640px);
+ }
+}
diff --git a/packages/dashboard-ui/src/components/EmptyState.tsx b/packages/dashboard-ui/src/components/EmptyState.tsx
new file mode 100644
index 0000000..40f87f4
--- /dev/null
+++ b/packages/dashboard-ui/src/components/EmptyState.tsx
@@ -0,0 +1,129 @@
+import type { LucideIcon } from 'lucide-react'
+import type { ReactNode } from 'react'
+import { Link } from 'react-router-dom'
+import { Plus } from 'lucide-react'
+import styles from './EmptyState.module.css'
+
+export interface EmptyStateProps {
+ /** Plain-text fallback for accessibility and tables. */
+ message: string
+ /** Rich message content; used instead of `message` when provided. */
+ messageNode?: ReactNode
+ actionLabel?: string
+ actionTo?: string
+ onAction?: () => void
+ /** Unused visually; kept for call-site compatibility. */
+ icon?: LucideIcon
+ /** Page lists use top padding; tables/modals use inline. */
+ layout?: 'page' | 'inline'
+}
+
+export function EmptyStateEmphasis({ children }: { children: ReactNode }) {
+ return {children}
+}
+
+export interface ListEmptyMessageProps {
+ part1: string
+ noneLabel: string
+ part2: string
+ actionLabel: string
+ part3: string
+}
+
+/** Standard list empty copy: part1 + bold none + part2 + bold action + part3. */
+export function ListEmptyMessage({
+ part1,
+ noneLabel,
+ part2,
+ actionLabel,
+ part3,
+}: ListEmptyMessageProps) {
+ return (
+ <>
+ {part1}
+ {noneLabel}
+ {part2}
+ {actionLabel}
+ {part3}
+ >
+ )
+}
+
+export function buildListEmptyPlainMessage(parts: ListEmptyMessageProps): string {
+ return `${parts.part1}${parts.noneLabel}${parts.part2}${parts.actionLabel}${parts.part3}`
+}
+
+function EmptyStateContent({
+ message,
+ messageNode,
+ actionLabel,
+ actionTo,
+ onAction,
+ layout = 'page',
+}: EmptyStateProps) {
+ const showAction = Boolean(actionLabel && (actionTo || onAction))
+ const messageContent = messageNode ?? message
+ const isInline = layout === 'inline'
+
+ if (!showAction) {
+ return (
+
+ )
+ }
+
+ const addContent = (
+ <>
+
+
+
+ {actionLabel ? {actionLabel} : null}
+ >
+ )
+
+ return (
+
+
{messageContent}
+ {actionTo ? (
+
+ {addContent}
+
+ ) : (
+
+ )}
+
+ )
+}
+
+export function EmptyState(props: EmptyStateProps) {
+ const content =
+
+ if (props.layout === 'inline') {
+ return content
+ }
+
+ return {content}
+}
+
+export function EmptyTableRow({
+ colSpan,
+ layout: _layout,
+ ...props
+}: EmptyStateProps & { colSpan: number }) {
+ return (
+
+ |
+
+ |
+
+ )
+}
diff --git a/apps/business/src/components/RichTextEditor.module.css b/packages/dashboard-ui/src/components/RichTextEditor.module.css
similarity index 55%
rename from apps/business/src/components/RichTextEditor.module.css
rename to packages/dashboard-ui/src/components/RichTextEditor.module.css
index 4d4f8eb..86d0c6a 100644
--- a/apps/business/src/components/RichTextEditor.module.css
+++ b/packages/dashboard-ui/src/components/RichTextEditor.module.css
@@ -5,7 +5,7 @@
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.7);
- overflow: hidden;
+ overflow: visible;
transition: border-color 0.2s, box-shadow 0.2s;
box-sizing: border-box;
}
@@ -17,11 +17,14 @@
.toolbar {
display: flex;
+ flex-wrap: wrap;
align-items: center;
gap: 2px;
padding: 4px 6px;
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
background: rgba(248, 250, 252, 0.8);
+ position: relative;
+ z-index: 2;
}
.toolbar button {
@@ -35,11 +38,25 @@
transition: background 0.15s, color 0.15s;
}
-.toolbar button:hover {
+.toolbar button:hover:not(:disabled) {
background: rgba(var(--primary-rgb) / 0.1);
color: var(--primary);
}
+.toolbar button:disabled {
+ opacity: 0.45;
+ cursor: not-allowed;
+}
+
+.dirBtn {
+ width: auto;
+ min-width: 34px;
+ padding: 0 6px;
+ font-size: 11px;
+ font-weight: 600;
+ letter-spacing: 0.02em;
+}
+
.divider {
width: 1px;
height: 20px;
@@ -47,6 +64,97 @@
margin: 0 4px;
}
+.colorPickerWrap {
+ position: relative;
+ width: 28px;
+ height: 28px;
+}
+
+.colorBtn {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 3px;
+ width: 28px;
+ height: 28px;
+ border-radius: 6px;
+ color: var(--text-secondary);
+ transition: background 0.15s, color 0.15s;
+}
+
+.colorBtn:hover,
+.colorBtnActive {
+ background: rgba(var(--primary-rgb) / 0.1);
+ color: var(--primary);
+}
+
+.colorSwatch {
+ width: 14px;
+ height: 3px;
+ margin-top: 1px;
+ border-radius: 2px;
+ background: var(--text-primary);
+}
+
+.colorPopover {
+ position: absolute;
+ top: calc(100% + 6px);
+ left: 50%;
+ transform: translateX(-50%);
+ z-index: 20;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ min-width: 168px;
+ padding: 10px;
+ border: 1px solid rgba(148, 163, 184, 0.35);
+ border-radius: var(--radius-sm);
+ background: rgba(255, 255, 255, 0.98);
+ box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);
+}
+
+.colorVisualInput {
+ width: 100%;
+ height: 32px;
+ padding: 0;
+ border: 1px solid rgba(148, 163, 184, 0.35);
+ border-radius: 6px;
+ cursor: pointer;
+ background: transparent;
+}
+
+.hexField {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.hexLabel {
+ font-size: 11px;
+ font-weight: 600;
+ letter-spacing: 0.04em;
+ color: var(--text-muted);
+}
+
+.hexInput {
+ min-height: var(--field-height);
+ padding: var(--field-padding-y) var(--field-padding-x);
+ border: 1px solid rgba(148, 163, 184, 0.35);
+ border-radius: var(--radius-sm);
+ background: rgba(255, 255, 255, 0.9);
+ font-size: var(--field-font-size);
+ font-family: var(--font-ui);
+ color: var(--text-primary);
+ text-transform: uppercase;
+}
+
+.hexInput:focus {
+ outline: none;
+ border-color: var(--primary);
+ box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
+}
+
.editorShell {
position: relative;
width: 100%;
diff --git a/packages/dashboard-ui/src/components/RichTextEditor.tsx b/packages/dashboard-ui/src/components/RichTextEditor.tsx
new file mode 100644
index 0000000..d864316
--- /dev/null
+++ b/packages/dashboard-ui/src/components/RichTextEditor.tsx
@@ -0,0 +1,530 @@
+import { useRef, useEffect, useState, useCallback, useContext } from 'react'
+import {
+ Bold,
+ IndentDecrease,
+ IndentIncrease,
+ Italic,
+ Underline,
+ List,
+ ListOrdered,
+ ImagePlus,
+ Palette,
+} from 'lucide-react'
+import type { DashboardLocale } from '@meshkee/dashboard-core'
+import { LocaleContext } from '../context/LocaleContext'
+import styles from './RichTextEditor.module.css'
+
+const INDENT_STEP_PX = 24
+const INDENT_BLOCK_TAGS = new Set(['P', 'DIV', 'LI', 'BLOCKQUOTE', 'H1', 'H2', 'H3', 'H4'])
+const DEFAULT_TEXT_COLOR = '#0f172a'
+const SELECTED_IMAGE_CLASS = 'richTextImageSelected'
+const HEX_COLOR_PATTERN = /^#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})$/
+
+function normalizeHexColor(value: string): string | null {
+ const trimmed = value.trim()
+ const withHash = trimmed.startsWith('#') ? trimmed : `#${trimmed}`
+ if (!HEX_COLOR_PATTERN.test(withHash)) return null
+
+ if (withHash.length === 4) {
+ const [, r, g, b] = withHash
+ return `#${r}${r}${g}${g}${b}${b}`.toLowerCase()
+ }
+
+ return withHash.toLowerCase()
+}
+
+function toHexInputValue(color: string): string {
+ return normalizeHexColor(color) ?? DEFAULT_TEXT_COLOR
+}
+
+export interface RichTextEditorProps {
+ value: string
+ onChange: (value: string) => void
+ placeholder?: string
+ allowImages?: boolean
+ /** Resolve uploaded image file to a public URL. Required when `allowImages` is true. */
+ onImageUpload?: (file: File) => Promise
+ editorMinHeight?: number
+ locale?: DashboardLocale
+ dir?: 'ltr' | 'rtl'
+}
+
+export function RichTextEditor({
+ value,
+ onChange,
+ placeholder,
+ allowImages = false,
+ onImageUpload,
+ editorMinHeight = 140,
+ locale: localeProp,
+ dir: dirProp,
+}: RichTextEditorProps) {
+ const localeContext = useContext(LocaleContext)
+ const locale = localeProp ?? localeContext?.locale ?? 'en'
+ const dir = dirProp ?? localeContext?.dir ?? 'ltr'
+
+ const editorShellRef = useRef(null)
+ const editorRef = useRef(null)
+ const fileInputRef = useRef(null)
+ const colorPickerRef = useRef(null)
+ const dragRef = useRef<{ startX: number; startWidth: number; img: HTMLImageElement } | null>(
+ null,
+ )
+ const [uploadingImage, setUploadingImage] = useState(false)
+ const [uploadError, setUploadError] = useState('')
+ const [selectedImageEl, setSelectedImageEl] = useState(null)
+ const [handlePos, setHandlePos] = useState<{ top: number; left: number } | null>(null)
+ const [textColor, setTextColor] = useState(DEFAULT_TEXT_COLOR)
+ const [colorOpen, setColorOpen] = useState(false)
+ const [hexInput, setHexInput] = useState(DEFAULT_TEXT_COLOR)
+
+ const syncChange = useCallback(() => {
+ if (editorRef.current) {
+ onChange(editorRef.current.innerHTML)
+ }
+ }, [onChange])
+
+ const updateHandlePosition = useCallback((img: HTMLImageElement | null) => {
+ if (!img || !editorShellRef.current) {
+ setHandlePos(null)
+ return
+ }
+
+ const shellRect = editorShellRef.current.getBoundingClientRect()
+ const imgRect = img.getBoundingClientRect()
+ setHandlePos({
+ top: imgRect.bottom - shellRect.top - 6,
+ left: imgRect.right - shellRect.left - 6,
+ })
+ }, [])
+
+ const clearImageSelection = useCallback(() => {
+ editorRef.current?.querySelectorAll(`img.${SELECTED_IMAGE_CLASS}`).forEach((img) => {
+ img.classList.remove(SELECTED_IMAGE_CLASS)
+ })
+ setSelectedImageEl(null)
+ setHandlePos(null)
+ }, [])
+
+ const selectImage = useCallback(
+ (img: HTMLImageElement) => {
+ editorRef.current?.querySelectorAll(`img.${SELECTED_IMAGE_CLASS}`).forEach((node) => {
+ node.classList.remove(SELECTED_IMAGE_CLASS)
+ })
+ img.classList.add(SELECTED_IMAGE_CLASS)
+ setSelectedImageEl(img)
+ updateHandlePosition(img)
+ },
+ [updateHandlePosition],
+ )
+
+ useEffect(() => {
+ if (editorRef.current && editorRef.current.innerHTML !== value) {
+ editorRef.current.innerHTML = value
+ clearImageSelection()
+ }
+ }, [value, clearImageSelection])
+
+ useEffect(() => {
+ if (!allowImages) return
+
+ const editor = editorRef.current
+ if (!editor) return
+
+ function onEditorClick(e: MouseEvent) {
+ const target = e.target
+ if (!(target instanceof HTMLImageElement) || !editor!.contains(target)) return
+
+ e.preventDefault()
+ selectImage(target)
+ }
+
+ function onDocumentClick(e: MouseEvent) {
+ const target = e.target as Node
+ if (editor!.contains(target)) return
+ if (target instanceof Element && target.closest(`.${styles.resizeHandle}`)) return
+ clearImageSelection()
+ }
+
+ editor.addEventListener('click', onEditorClick)
+ document.addEventListener('click', onDocumentClick)
+ return () => {
+ editor.removeEventListener('click', onEditorClick)
+ document.removeEventListener('click', onDocumentClick)
+ }
+ }, [allowImages, selectImage, clearImageSelection])
+
+ useEffect(() => {
+ if (!selectedImageEl) return
+
+ function refreshHandle() {
+ if (selectedImageEl?.isConnected) {
+ updateHandlePosition(selectedImageEl)
+ }
+ }
+
+ window.addEventListener('resize', refreshHandle)
+ editorRef.current?.addEventListener('scroll', refreshHandle)
+ return () => {
+ window.removeEventListener('resize', refreshHandle)
+ editorRef.current?.removeEventListener('scroll', refreshHandle)
+ }
+ }, [selectedImageEl, updateHandlePosition])
+
+ useEffect(() => {
+ if (!colorOpen) return
+
+ function onDocumentClick(e: MouseEvent) {
+ const target = e.target as Node
+ if (colorPickerRef.current?.contains(target)) return
+ setColorOpen(false)
+ }
+
+ document.addEventListener('mousedown', onDocumentClick)
+ return () => document.removeEventListener('mousedown', onDocumentClick)
+ }, [colorOpen])
+
+ function exec(cmd: string, arg?: string) {
+ document.execCommand(cmd, false, arg)
+ editorRef.current?.focus()
+ syncChange()
+ }
+
+ function findIndentBlock(editor: HTMLElement): HTMLElement | null {
+ const selection = window.getSelection()
+ if (!selection || selection.rangeCount === 0) return null
+
+ let node: Node | null = selection.anchorNode
+ while (node && node !== editor) {
+ if (node instanceof HTMLElement && INDENT_BLOCK_TAGS.has(node.tagName)) {
+ return node
+ }
+ node = node.parentNode
+ }
+ return null
+ }
+
+ function changeIndent(direction: 1 | -1) {
+ const editor = editorRef.current
+ if (!editor) return
+
+ editor.focus()
+ const block = findIndentBlock(editor)
+ if (!block) {
+ document.execCommand(direction > 0 ? 'indent' : 'outdent')
+ syncChange()
+ return
+ }
+
+ const current =
+ Number.parseFloat(block.style.paddingInlineStart) ||
+ Number.parseFloat(getComputedStyle(block).paddingInlineStart) ||
+ 0
+ const next = Math.max(0, Math.round(current + direction * INDENT_STEP_PX))
+ if (next === 0) {
+ block.style.paddingInlineStart = ''
+ } else {
+ block.style.paddingInlineStart = `${next}px`
+ }
+ syncChange()
+ }
+
+ function setBlockDirection(direction: 'rtl' | 'ltr') {
+ const editor = editorRef.current
+ if (!editor) return
+
+ editor.focus()
+ const block = findIndentBlock(editor)
+ if (block) {
+ block.setAttribute('dir', direction)
+ syncChange()
+ return
+ }
+
+ document.execCommand('dir', false, direction)
+ syncChange()
+ }
+
+ function applyTextColor(color: string) {
+ const normalized = normalizeHexColor(color) ?? DEFAULT_TEXT_COLOR
+ setTextColor(normalized)
+ setHexInput(normalized)
+ document.execCommand('styleWithCSS', false, 'true')
+ exec('foreColor', normalized)
+ }
+
+ function toggleColorPicker() {
+ setColorOpen((open) => {
+ const nextOpen = !open
+ if (nextOpen) {
+ setHexInput(toHexInputValue(textColor))
+ }
+ return nextOpen
+ })
+ }
+
+ function commitHexInput(rawValue?: string) {
+ const normalized = normalizeHexColor(rawValue ?? hexInput)
+ if (!normalized) {
+ setHexInput(toHexInputValue(textColor))
+ return
+ }
+ applyTextColor(normalized)
+ }
+
+ function onHexInputKeyDown(e: React.KeyboardEvent) {
+ if (e.key === 'Enter') {
+ e.preventDefault()
+ commitHexInput()
+ editorRef.current?.focus()
+ }
+ if (e.key === 'Escape') {
+ e.preventDefault()
+ setHexInput(toHexInputValue(textColor))
+ setColorOpen(false)
+ }
+ }
+
+ function onEditorKeyDown(e: React.KeyboardEvent) {
+ if (e.key !== 'Tab' || e.altKey || e.ctrlKey || e.metaKey) return
+ e.preventDefault()
+ changeIndent(e.shiftKey ? -1 : 1)
+ }
+
+ function styleInsertedImage(img: HTMLImageElement) {
+ img.style.width = '100%'
+ img.style.maxWidth = '100%'
+ img.style.height = 'auto'
+ img.draggable = false
+ }
+
+ async function handleImageSelect(e: React.ChangeEvent) {
+ const file = e.target.files?.[0]
+ e.target.value = ''
+ if (!file || !editorRef.current || !onImageUpload) return
+
+ setUploadingImage(true)
+ setUploadError('')
+
+ try {
+ const url = await onImageUpload(file)
+ if (!url) throw new Error('Image upload failed.')
+
+ editorRef.current.focus()
+ document.execCommand('insertImage', false, url)
+
+ const imgs = editorRef.current.querySelectorAll('img')
+ const lastImg = imgs[imgs.length - 1]
+ if (lastImg instanceof HTMLImageElement) {
+ styleInsertedImage(lastImg)
+ selectImage(lastImg)
+ }
+
+ syncChange()
+ } catch (err) {
+ setUploadError(err instanceof Error ? err.message : 'Unable to upload image.')
+ } finally {
+ setUploadingImage(false)
+ }
+ }
+
+ function onResizeStart(e: React.MouseEvent) {
+ if (!selectedImageEl || !editorRef.current) return
+
+ e.preventDefault()
+ e.stopPropagation()
+
+ dragRef.current = {
+ startX: e.clientX,
+ startWidth: selectedImageEl.offsetWidth,
+ img: selectedImageEl,
+ }
+
+ function onMove(ev: MouseEvent) {
+ if (!dragRef.current || !editorRef.current) return
+ const delta = ev.clientX - dragRef.current.startX
+ const maxWidth = editorRef.current.clientWidth - 16
+ const nextWidth = Math.max(80, Math.min(maxWidth, dragRef.current.startWidth + delta))
+ dragRef.current.img.style.width = `${nextWidth}px`
+ dragRef.current.img.style.height = 'auto'
+ dragRef.current.img.style.maxWidth = '100%'
+ updateHandlePosition(dragRef.current.img)
+ }
+
+ function onUp() {
+ dragRef.current = null
+ syncChange()
+ document.removeEventListener('mousemove', onMove)
+ document.removeEventListener('mouseup', onUp)
+ }
+
+ document.addEventListener('mousemove', onMove)
+ document.addEventListener('mouseup', onUp)
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {allowImages && onImageUpload && (
+ <>
+
+
+
+ >
+ )}
+
+ {uploadError && (
+
+ {uploadError}
+
+ )}
+
+
+ {allowImages && selectedImageEl && handlePos && (
+
+ )}
+
+
+ )
+}
+
+/** Strip tags for single-line list previews. */
+export function richTextToPlain(html: string | null | undefined): string {
+ if (!html) return ''
+ return html
+ .replace(/
/gi, ' ')
+ .replace(/ /gi, ' ')
+ .replace(/<[^>]+>/g, '')
+ .replace(/\s+/g, ' ')
+ .trim()
+}
+
+export function isEmptyRichText(html: string | null | undefined): boolean {
+ return richTextToPlain(html).length === 0
+}
diff --git a/packages/dashboard-ui/src/context/Toast.module.css b/packages/dashboard-ui/src/context/Toast.module.css
index 48138b7..7d71701 100644
--- a/packages/dashboard-ui/src/context/Toast.module.css
+++ b/packages/dashboard-ui/src/context/Toast.module.css
@@ -2,7 +2,7 @@
position: fixed;
bottom: 24px;
left: 24px;
- z-index: 300;
+ z-index: var(--toast-z-index, 11000);
display: flex;
flex-direction: column;
align-items: flex-start;
@@ -19,12 +19,12 @@
font-weight: 500;
line-height: 1.4;
color: var(--text-primary);
- background: var(--elevated-surface);
+ background: var(--toast-bg, rgba(255, 255, 255, 0.94));
backdrop-filter: blur(var(--blur-glass));
-webkit-backdrop-filter: blur(var(--blur-glass));
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
- box-shadow: var(--glass-shadow);
+ box-shadow: var(--toast-shadow, 0 12px 40px rgba(15, 23, 42, 0.14));
}
.toastIn {
diff --git a/packages/dashboard-ui/src/context/ToastContext.tsx b/packages/dashboard-ui/src/context/ToastContext.tsx
index 758c2e6..6cef98b 100644
--- a/packages/dashboard-ui/src/context/ToastContext.tsx
+++ b/packages/dashboard-ui/src/context/ToastContext.tsx
@@ -8,6 +8,7 @@ import {
useState,
type ReactNode,
} from 'react'
+import { createPortal } from 'react-dom'
import styles from './Toast.module.css'
export type ToastVariant = 'success' | 'error' | 'info'
@@ -74,17 +75,20 @@ export function ToastProvider({ children }: { children: ReactNode }) {
return (
{children}
-
- {toast && (
-
- {toast.message}
-
- )}
-
+ {createPortal(
+
+ {toast && (
+
+ {toast.message}
+
+ )}
+
,
+ document.body,
+ )}
)
}
diff --git a/packages/dashboard-ui/src/index.ts b/packages/dashboard-ui/src/index.ts
index 87d0e2d..92c562d 100644
--- a/packages/dashboard-ui/src/index.ts
+++ b/packages/dashboard-ui/src/index.ts
@@ -10,8 +10,23 @@ export {
type CityOption,
} from './components/AddressListEditor'
export { Breadcrumbs, type BreadcrumbItem } from './components/Breadcrumbs'
+export {
+ EmptyState,
+ EmptyStateEmphasis,
+ EmptyTableRow,
+ ListEmptyMessage,
+ buildListEmptyPlainMessage,
+ type EmptyStateProps,
+ type ListEmptyMessageProps,
+} from './components/EmptyState'
export { Pagination } from './components/Pagination'
export type { PaginationProps } from './components/Pagination'
+export {
+ RichTextEditor,
+ isEmptyRichText,
+ richTextToPlain,
+ type RichTextEditorProps,
+} from './components/RichTextEditor'
export { SectionCard } from './components/SectionCard'
export { RouteLoader } from './components/RouteLoader'
export { createDomainGuard, type DomainGuardConfig } from './components/createDomainGuard'