feat(showcase): add PouchDB sample component and storage page
- Implemented PouchSample component for managing POS configurations and item inventories using PouchDB. - Created StoragePage to encapsulate the PouchSample component. - Added UI components page with various UI elements including buttons, forms, and data grids. - Defined Electron type declarations for printing and auto-update functionalities. - Extended event registry with custom application events for printing and stock updates. - Configured Vite for the showcase application with React and Tailwind CSS support. - Updated package.json and pnpm-lock.yaml to include necessary dependencies for the showcase app.
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
/** @type {import("eslint").Linter.Config} */
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ['@repo/eslint-config/react.js'],
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Showcase</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "showcase",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --clearScreen false",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint \"src/**/*.ts\"",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest --watch",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.0.1",
|
||||
"@repo/core-api": "workspace:*",
|
||||
"@repo/core-events": "workspace:*",
|
||||
"@repo/core-i18n": "workspace:*",
|
||||
"@repo/core-storage": "workspace:*",
|
||||
"@repo/ui": "workspace:*",
|
||||
"@repo/utils": "workspace:*",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"dayjs": "^1.11.19",
|
||||
"events": "^3.3.0",
|
||||
"i18next": "^24.2.2",
|
||||
"lucide-react": "^1.22.0",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"react-hook-form": "^7.56.4",
|
||||
"react-i18next": "^15.4.0",
|
||||
"react-router-dom": "^7.11.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"zod": "^3.25.36",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/eslint-config": "workspace:*",
|
||||
"@repo/typescript-config": "workspace:*",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
"eslint": "^8.57.1",
|
||||
"typescript": "5.5.4",
|
||||
"vite": "^5.1.4",
|
||||
"vitest": "^4.0.17"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useState, Suspense } from 'react';
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { ThemeProvider, DensityType } from '@repo/ui/provider';
|
||||
import { AgGridProvider } from '@repo/ui/components';
|
||||
import { useThemeStore } from './core/stores/theme.store';
|
||||
import ShowcaseLayout from './layouts/ShowcaseLayout';
|
||||
|
||||
// Pages
|
||||
import UiComponentsPage from './pages/ui-components';
|
||||
import FormsPage from './pages/forms';
|
||||
import StoragePage from './pages/storage';
|
||||
import EventsPage from './pages/events';
|
||||
import HardwarePage from './pages/hardware';
|
||||
import RbacPage from './pages/rbac';
|
||||
import AuthPage from './pages/auth';
|
||||
import ShellDemoPage from './pages/shell-demo';
|
||||
import ActionToolsPage from './pages/action-tools';
|
||||
import AgGridPage from './pages/ag-grid';
|
||||
|
||||
export default function App() {
|
||||
const colorScheme = useThemeStore((s) => s.colorScheme);
|
||||
const [density, setDensity] = useState<DensityType>('compact');
|
||||
|
||||
return (
|
||||
<ThemeProvider colorScheme={colorScheme} density={density}>
|
||||
<AgGridProvider bypassLicense>
|
||||
<BrowserRouter>
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<Routes>
|
||||
<Route path="/" element={<ShowcaseLayout density={density} setDensity={setDensity} />}>
|
||||
<Route index element={<Navigate to="/ui-components" replace />} />
|
||||
<Route path="ui-components" element={<UiComponentsPage />} />
|
||||
<Route path="forms" element={<FormsPage />} />
|
||||
<Route path="storage" element={<StoragePage />} />
|
||||
<Route path="events" element={<EventsPage />} />
|
||||
<Route path="hardware" element={<HardwarePage />} />
|
||||
<Route path="rbac" element={<RbacPage />} />
|
||||
<Route path="auth" element={<AuthPage />} />
|
||||
<Route path="action-tools" element={<ActionToolsPage />} />
|
||||
<Route path="ag-grid" element={<AgGridPage />} />
|
||||
</Route>
|
||||
<Route path="/shell-demo" element={<ShellDemoPage />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</BrowserRouter>
|
||||
</AgGridProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export const LAYOUT_EVENTS = {
|
||||
TOGGLE_HISTORY_DRAWER: 'LAYOUT:TOGGLE_HISTORY_DRAWER',
|
||||
TOGGLE_BOOKMARK_DRAWER: 'LAYOUT:TOGGLE_BOOKMARK_DRAWER',
|
||||
} as const;
|
||||
|
||||
export const DEVICE_EVENTS = {
|
||||
PRINT_RECEIPT: 'DEVICE:PRINT_RECEIPT',
|
||||
} as const;
|
||||
|
||||
export const WS_EVENTS = {
|
||||
STOCK_UPDATE: 'WS:STOCK_UPDATE',
|
||||
} as const;
|
||||
|
||||
export const AUTH_EVENTS = {
|
||||
PROFILE_UPDATED: 'AUTH:PROFILE_UPDATED',
|
||||
} as const;
|
||||
|
||||
export const APP_EVENTS = {
|
||||
INITIALIZED: 'APP:INITIALIZED',
|
||||
ERROR: 'APP:ERROR',
|
||||
} as const;
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useIsElectron } from './use-is-electron';
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────
|
||||
|
||||
export interface UseElectronPrinterReturn {
|
||||
/** List of available printers (populated after calling `refreshPrinters`) */
|
||||
printers: ElectronPrinterInfo[];
|
||||
/** Whether a printer operation is in progress */
|
||||
loading: boolean;
|
||||
/** Last error message, if any */
|
||||
error: string | null;
|
||||
/** Whether the app is running inside Electron */
|
||||
isElectron: boolean;
|
||||
/** Fetch the current list of available printers */
|
||||
refreshPrinters: () => Promise<ElectronPrinterInfo[]>;
|
||||
/** Print with the given options. Returns success/failure. */
|
||||
print: (options?: ElectronPrintOptions) => Promise<ElectronPrintResult>;
|
||||
}
|
||||
|
||||
/**
|
||||
* React hook for Electron printer integration.
|
||||
*
|
||||
* Provides methods to list available printers and trigger print jobs
|
||||
* via the secure `window.electronAPI` bridge.
|
||||
*
|
||||
* Safe to use in both Electron and browser environments.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function PrintButton() {
|
||||
* const { printers, refreshPrinters, print, loading } = useElectronPrinter();
|
||||
*
|
||||
* useEffect(() => { refreshPrinters(); }, []);
|
||||
*
|
||||
* const handlePrint = async () => {
|
||||
* const result = await print({ silent: true, deviceName: printers[0]?.name });
|
||||
* if (!result.success) alert(`Print failed: ${result.failureReason}`);
|
||||
* };
|
||||
*
|
||||
* return (
|
||||
* <button onClick={handlePrint} disabled={loading || printers.length === 0}>
|
||||
* Print
|
||||
* </button>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useElectronPrinter(): UseElectronPrinterReturn {
|
||||
const [printers, setPrinters] = useState<ElectronPrinterInfo[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const isElectron = useIsElectron();
|
||||
|
||||
const refreshPrinters = useCallback(async (): Promise<ElectronPrinterInfo[]> => {
|
||||
if (!window.electronAPI) {
|
||||
setError('Not running in Electron');
|
||||
return [];
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await window.electronAPI.getPrinters();
|
||||
setPrinters(result);
|
||||
return result;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to get printers';
|
||||
setError(message);
|
||||
return [];
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const print = useCallback(
|
||||
async (options?: ElectronPrintOptions): Promise<ElectronPrintResult> => {
|
||||
if (!window.electronAPI) {
|
||||
return { success: false, failureReason: 'Not running in Electron' };
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await window.electronAPI.print(options);
|
||||
if (!result.success && result.failureReason) {
|
||||
setError(result.failureReason);
|
||||
}
|
||||
return result;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Print failed';
|
||||
setError(message);
|
||||
return { success: false, failureReason: message };
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
printers,
|
||||
loading,
|
||||
error,
|
||||
isElectron,
|
||||
refreshPrinters,
|
||||
print,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useIsElectron } from './use-is-electron';
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────
|
||||
|
||||
export type UpdateStatus =
|
||||
| 'idle'
|
||||
| 'checking'
|
||||
| 'available'
|
||||
| 'not-available'
|
||||
| 'downloading'
|
||||
| 'ready'
|
||||
| 'error';
|
||||
|
||||
export interface UseElectronUpdaterReturn {
|
||||
/** Current status of the auto-updater lifecycle */
|
||||
status: UpdateStatus;
|
||||
/** Download progress percentage (0–100) */
|
||||
progress: number;
|
||||
/** Download speed in bytes per second */
|
||||
bytesPerSecond: number;
|
||||
/** Information about the available/downloaded update */
|
||||
updateInfo: ElectronUpdateInfo | null;
|
||||
/** Error message if the updater encountered an issue */
|
||||
errorMessage: string | null;
|
||||
/** Whether the app is running inside Electron */
|
||||
isElectron: boolean;
|
||||
/** Trigger a manual update check */
|
||||
checkForUpdates: () => void;
|
||||
/** Quit the app and install the downloaded update */
|
||||
installUpdate: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* React hook for the Electron auto-updater.
|
||||
*
|
||||
* Subscribes to all update lifecycle events via `window.electronAPI`
|
||||
* and provides reactive state for building an update notification UI.
|
||||
*
|
||||
* Safe to use in both Electron and browser environments — all
|
||||
* Electron-specific calls are gated behind `window.electronAPI` checks.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function UpdateBanner() {
|
||||
* const { status, progress, updateInfo, checkForUpdates, installUpdate } = useElectronUpdater();
|
||||
*
|
||||
* if (status === 'available') {
|
||||
* return <div>Update {updateInfo?.version} available! Downloading...</div>;
|
||||
* }
|
||||
* if (status === 'downloading') {
|
||||
* return <div>Downloading... {progress.toFixed(0)}%</div>;
|
||||
* }
|
||||
* if (status === 'ready') {
|
||||
* return <button onClick={installUpdate}>Restart to update</button>;
|
||||
* }
|
||||
* return <button onClick={checkForUpdates}>Check for updates</button>;
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useElectronUpdater(): UseElectronUpdaterReturn {
|
||||
const [status, setStatus] = useState<UpdateStatus>('idle');
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [bytesPerSecond, setBytesPerSecond] = useState(0);
|
||||
const [updateInfo, setUpdateInfo] = useState<ElectronUpdateInfo | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const isElectron = useIsElectron();
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.electronAPI) return;
|
||||
|
||||
const api = window.electronAPI;
|
||||
|
||||
const unsubChecking = api.onUpdateChecking(() => {
|
||||
setStatus('checking');
|
||||
setErrorMessage(null);
|
||||
});
|
||||
|
||||
const unsubAvailable = api.onUpdateAvailable((info) => {
|
||||
setStatus('available');
|
||||
setUpdateInfo(info);
|
||||
});
|
||||
|
||||
const unsubNotAvailable = api.onUpdateNotAvailable((info) => {
|
||||
setStatus('not-available');
|
||||
setUpdateInfo(info);
|
||||
});
|
||||
|
||||
const unsubProgress = api.onDownloadProgress((progressInfo) => {
|
||||
setStatus('downloading');
|
||||
setProgress(progressInfo.percent);
|
||||
setBytesPerSecond(progressInfo.bytesPerSecond);
|
||||
});
|
||||
|
||||
const unsubDownloaded = api.onUpdateDownloaded((info) => {
|
||||
setStatus('ready');
|
||||
setProgress(100);
|
||||
setUpdateInfo(info);
|
||||
});
|
||||
|
||||
const unsubError = api.onUpdateError((error) => {
|
||||
setStatus('error');
|
||||
setErrorMessage(error);
|
||||
});
|
||||
|
||||
// Cleanup all listeners on unmount
|
||||
return () => {
|
||||
unsubChecking();
|
||||
unsubAvailable();
|
||||
unsubNotAvailable();
|
||||
unsubProgress();
|
||||
unsubDownloaded();
|
||||
unsubError();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const checkForUpdates = useCallback(() => {
|
||||
if (!window.electronAPI) return;
|
||||
setStatus('checking');
|
||||
setErrorMessage(null);
|
||||
window.electronAPI.checkForUpdates();
|
||||
}, []);
|
||||
|
||||
const installUpdate = useCallback(() => {
|
||||
if (!window.electronAPI) return;
|
||||
window.electronAPI.installUpdate();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
status,
|
||||
progress,
|
||||
bytesPerSecond,
|
||||
updateInfo,
|
||||
errorMessage,
|
||||
isElectron,
|
||||
checkForUpdates,
|
||||
installUpdate,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* A utility hook to determine if the React application is running
|
||||
* inside the Electron desktop wrapper or a standard web browser.
|
||||
*/
|
||||
export function useIsElectron(): boolean {
|
||||
return typeof window !== 'undefined' && !!window.electronAPI;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { createHttpClient } from '@repo/core-api/http-client';
|
||||
import { faroAdapter } from '@repo/core-api/observability';
|
||||
import { AppStorageKey, secureStorage } from '../storage/local';
|
||||
|
||||
/**
|
||||
* Enterprise HTTP client for showcase.
|
||||
*/
|
||||
export const apiClient = createHttpClient(
|
||||
{
|
||||
baseURL: 'http://localhost:8000/api',
|
||||
timeout: 15000,
|
||||
observability: faroAdapter,
|
||||
},
|
||||
{
|
||||
// ── Auth Interceptor ──────────────────────────────────────────
|
||||
onRequest: async (config) => {
|
||||
config.headers['ex-app-name'] = 'showcase';
|
||||
config.headers['ex-app-version'] = '1.0.0';
|
||||
config.headers['ex-timezone-offset-minutes'] = new Date().getTimezoneOffset();
|
||||
config.headers['ex-timezone-offset-hours'] = Math.floor(new Date().getTimezoneOffset() / 60);
|
||||
|
||||
const language = await secureStorage.getItem<string>(AppStorageKey.LANGUAGE);
|
||||
config.headers['ex-language'] = language ?? 'en';
|
||||
|
||||
// const token = localStorage.getItem('access_token');
|
||||
const token =
|
||||
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImM1OWY4MTFlLTg3M2MtNDQ3Mi1iZDU4LTIxYzExMTkwMjExNCIsIm5hbWUiOiJzdXBlcmFkbWluIiwidXNlcm5hbWUiOiJzdXBlcmFkbWluIiwicm9sZSI6InN1cGVyYWRtaW4iLCJ1c2VyX3ByaXZpbGVnZV9pZCI6bnVsbCwic291cmNlIjoiUE9TX0FETUlOIiwiaWF0IjoxNzg0Nzc4MDM5LCJleHAiOjE3ODQ4MjEyMzl9.5Wa0Ypz4mM8jYFkdGHxXi1YQzRN3JWcmAl3kCvcLDTo';
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`;
|
||||
|
||||
return config;
|
||||
},
|
||||
|
||||
// ── Error Interceptor ─────────────────────────────────────────
|
||||
onResponseError: async (error) => {
|
||||
if (error.response?.status === 401) {
|
||||
// Clear stale token and redirect to login
|
||||
localStorage.removeItem('access_token');
|
||||
window.location.href = '/auth';
|
||||
}
|
||||
throw error;
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,44 @@
|
||||
import { createLocalStorage, createIndexedDB } from '@repo/core-storage';
|
||||
|
||||
export const AppStorageKey = {
|
||||
USER_PROFILE: 'user_profile',
|
||||
LANGUAGE: 'app_language',
|
||||
THEME: 'app_theme',
|
||||
ACCESS_TOKEN: 'access_token',
|
||||
REFRESH_TOKEN: 'refresh_token',
|
||||
MOCK_DB_COMPANY_A: 'mock_db_company_a',
|
||||
OFFLINE_DRAFT: 'offline_draft',
|
||||
SYSTEM_SETTINGS: 'system_settings',
|
||||
HISTORY_PAGE: 'history_page',
|
||||
BOOKMARK_PAGE: 'bookmark_page',
|
||||
} as const;
|
||||
|
||||
export type AppStorageKeyValue = (typeof AppStorageKey)[keyof typeof AppStorageKey];
|
||||
|
||||
export const ENCRYPTED_KEYS = new Set<AppStorageKeyValue>([
|
||||
AppStorageKey.USER_PROFILE,
|
||||
AppStorageKey.ACCESS_TOKEN,
|
||||
AppStorageKey.REFRESH_TOKEN,
|
||||
]);
|
||||
|
||||
export const PLAIN_KEYS = new Set<AppStorageKeyValue>([
|
||||
AppStorageKey.LANGUAGE,
|
||||
AppStorageKey.THEME,
|
||||
AppStorageKey.MOCK_DB_COMPANY_A,
|
||||
AppStorageKey.OFFLINE_DRAFT,
|
||||
AppStorageKey.SYSTEM_SETTINGS,
|
||||
AppStorageKey.HISTORY_PAGE,
|
||||
AppStorageKey.BOOKMARK_PAGE,
|
||||
]);
|
||||
|
||||
export const secureStorage = createLocalStorage<AppStorageKeyValue>({
|
||||
encryptedKeys: ENCRYPTED_KEYS,
|
||||
plainTextKeys: PLAIN_KEYS,
|
||||
});
|
||||
|
||||
export const secureIndexedDB = createIndexedDB<AppStorageKeyValue>({
|
||||
dbName: 'e_apps_db',
|
||||
storeName: 'web_store',
|
||||
encryptedKeys: ENCRYPTED_KEYS,
|
||||
plainTextKeys: PLAIN_KEYS,
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './item.pouchdb.entity';
|
||||
export * from './pos-configuration.pouchdb.entity';
|
||||
export * from './new-item.pouchdb.entity';
|
||||
@@ -0,0 +1,27 @@
|
||||
interface ItemRateEntity {
|
||||
season_period?: string | null;
|
||||
price: string | number;
|
||||
}
|
||||
|
||||
interface ItemCategoryEntity {
|
||||
_id?: string;
|
||||
name?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a sellable product or service.
|
||||
*/
|
||||
export interface ItemEntity {
|
||||
_id: string;
|
||||
_rev?: string;
|
||||
name: string;
|
||||
base_price: string | number;
|
||||
item_type: string;
|
||||
usage_type?: string;
|
||||
item_category?: ItemCategoryEntity[] | ItemCategoryEntity | string;
|
||||
item_rates?: ItemRateEntity[];
|
||||
|
||||
// Allow for other ERP-specific fields
|
||||
[key: string]: any;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
export interface SeasonType {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface SeasonPeriod {
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
season_type: SeasonType;
|
||||
}
|
||||
|
||||
export interface ItemRate {
|
||||
id: string;
|
||||
price: string;
|
||||
season_period: SeasonPeriod;
|
||||
}
|
||||
|
||||
export interface ItemCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detailed interface for an Item entity, based on the provided JSON sample.
|
||||
* This represents the business data payload (`data` property in the envelope).
|
||||
*/
|
||||
export interface NewItemData {
|
||||
id: string;
|
||||
creator_id: string;
|
||||
creator_name: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
status: 'active' | 'inactive' | string;
|
||||
item_type: string;
|
||||
hpp: string;
|
||||
sales_margin: string;
|
||||
share_profit: string;
|
||||
total_price: number;
|
||||
base_price: string;
|
||||
play_estimation: number;
|
||||
use_queue: boolean;
|
||||
show_to_booking: boolean;
|
||||
breakdown_bundling: boolean;
|
||||
limit_type: string;
|
||||
limit_value: number;
|
||||
item_category_id: string;
|
||||
item_category: ItemCategory;
|
||||
item_rates: ItemRate[];
|
||||
price: string;
|
||||
qty: number;
|
||||
name: string;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { ItemEntity } from './item.pouchdb.entity';
|
||||
|
||||
/**
|
||||
* Represents the configuration and assigned data for a specific Point of Sale terminal.
|
||||
*/
|
||||
export interface POSConfigurationEntity {
|
||||
_id: string;
|
||||
_rev?: string;
|
||||
pos_number: string;
|
||||
pos_name: string;
|
||||
items: ItemEntity[];
|
||||
payment_methods?: any[];
|
||||
|
||||
// Allow for other ERP-specific fields
|
||||
[key: string]: any;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Multi-Database PouchDB Configuration for apps/web.
|
||||
*
|
||||
* This module demonstrates the IoC pattern: the consuming app decides
|
||||
* which databases to create and where they sync to. The core engine
|
||||
* (`PouchDBManager` and `PouchEnvelopeManager`) has zero knowledge of business domains.
|
||||
*/
|
||||
import { PouchDBManager, PouchEnvelopeDBManager } from '@repo/core-storage';
|
||||
import { ItemEntity, POSConfigurationEntity } from './entities';
|
||||
|
||||
// ─── Manager Singletons ──────────────────────────────────────────
|
||||
|
||||
export const dbManager = new PouchDBManager();
|
||||
export const envelopeDbManager = new PouchEnvelopeDBManager();
|
||||
|
||||
// ─── Helper: Build Secure Remote URL ────────────────────────────
|
||||
|
||||
function buildRemoteUrl(dbName: string): string | undefined {
|
||||
// In Showcase, we default to offline-only mode since there's no ENV
|
||||
console.info(`[DB Config] Showcase running "${dbName}" in offline-only mode.`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ─── Register Application Databases ─────────────────────────────
|
||||
|
||||
/** POS Configuration database — stores device settings, theme, etc. */
|
||||
export const posConfigDB = dbManager.register<POSConfigurationEntity>({
|
||||
localName: 'pos_configuration',
|
||||
remoteUrl: buildRemoteUrl('pos_configuration'),
|
||||
});
|
||||
|
||||
/** Items database — products available for sale in POS. */
|
||||
export const itemDB = dbManager.register<ItemEntity>({
|
||||
localName: 'item',
|
||||
remoteUrl: buildRemoteUrl('item'),
|
||||
});
|
||||
|
||||
/** New Items database using the Envelope Service pattern. */
|
||||
export const newItemDB = envelopeDbManager.register(
|
||||
{
|
||||
localName: 'master_data',
|
||||
remoteUrl: buildRemoteUrl('master_data'),
|
||||
},
|
||||
'item',
|
||||
);
|
||||
@@ -0,0 +1,30 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import type { ColorSchemeType } from '@repo/ui/provider';
|
||||
import { AppStorageKey } from '../storage/local';
|
||||
|
||||
interface ThemeState {
|
||||
colorScheme: ColorSchemeType;
|
||||
setColorScheme: (scheme: ColorSchemeType) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Global theme store with localStorage persistence.
|
||||
*
|
||||
* Uses the Zustand `persist` middleware so the chosen color scheme
|
||||
* survives page refreshes. The storage key (`app_theme`) is
|
||||
* intentionally kept in sync with `AppStorageKey.THEME` — both
|
||||
* write to the same localStorage entry.
|
||||
*/
|
||||
export const useThemeStore = create<ThemeState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
colorScheme: 'light',
|
||||
setColorScheme: (scheme) => set({ colorScheme: scheme }),
|
||||
}),
|
||||
{
|
||||
name: AppStorageKey.THEME, // matches AppStorageKey.THEME
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,131 @@
|
||||
import { AppShell, NavLink, Group, Title, Box, Select, Text, Paper } from '@repo/ui/components';
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from '@repo/core-i18n';
|
||||
import { Layout, FileText, Database, ShieldCheck, Lock, Activity, Printer, Globe, Layers, Table2 } from 'lucide-react';
|
||||
import { DensityType } from '@repo/ui/provider';
|
||||
import { useThemeStore } from '../core/stores/theme.store';
|
||||
import { ColorSchemeType } from '@repo/ui/provider';
|
||||
|
||||
interface ShowcaseLayoutProps {
|
||||
density: DensityType;
|
||||
setDensity: (val: DensityType) => void;
|
||||
}
|
||||
|
||||
export default function ShowcaseLayout({ density, setDensity }: ShowcaseLayoutProps) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { i18n } = useTranslation();
|
||||
const { colorScheme, setColorScheme } = useThemeStore();
|
||||
|
||||
const navItems = [
|
||||
{ label: 'UI Components', path: '/ui-components', icon: Layout },
|
||||
{ label: 'Layout Engine', path: '/shell-demo', icon: Layout },
|
||||
{ label: 'Form Engine', path: '/forms', icon: FileText },
|
||||
{ label: 'Offline Storage', path: '/storage', icon: Database },
|
||||
{ label: 'RBAC Engine', path: '/rbac', icon: ShieldCheck },
|
||||
{ label: 'Auth & Security', path: '/auth', icon: Lock },
|
||||
{ label: 'Events', path: '/events', icon: Activity },
|
||||
{ label: 'Hardware', path: '/hardware', icon: Printer },
|
||||
{ label: 'Action Tools', path: '/action-tools', icon: Layers },
|
||||
{ label: 'AG Grid', path: '/ag-grid', icon: Table2 },
|
||||
];
|
||||
|
||||
const getSubtitle = () => {
|
||||
const currentPath = location.pathname.replace('/', '');
|
||||
|
||||
switch (currentPath) {
|
||||
case 'rbac': return 'Role-Based Access Control and Permissions';
|
||||
case 'storage': return 'Offline-First PouchDB Synchronization';
|
||||
case 'auth': return 'Authentication & Security Layers';
|
||||
case 'ui-components': return 'Theme, Typography, Forms & Data Grids';
|
||||
case 'forms': return 'Enterprise Form Engine & Zod Validation';
|
||||
case 'events': return 'Global Event Bus Synchronization';
|
||||
case 'hardware': return 'Hardware Integration & Printers';
|
||||
case 'layout-engine': return 'Core Layout Engine & Variants';
|
||||
case 'action-tools': return 'Showcase for PageActions and RowActions components';
|
||||
case 'ag-grid': return 'Enterprise Data Grid with Mantine Theme Integration';
|
||||
default: return 'Architecture Showcase';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
navbar={{
|
||||
width: 260,
|
||||
breakpoint: 'sm',
|
||||
}}
|
||||
padding="0"
|
||||
>
|
||||
<AppShell.Navbar p="md">
|
||||
<Box mb="xl">
|
||||
<Title order={3} c="brand.7">Eigen ERP</Title>
|
||||
<Text size="xs" c="dimmed">Architecture Showcase</Text>
|
||||
</Box>
|
||||
<Box style={{ flex: 1 }}>
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
active={location.pathname === item.path}
|
||||
label={item.label}
|
||||
leftSection={<item.icon size={18} />}
|
||||
onClick={() => navigate(item.path)}
|
||||
variant="filled"
|
||||
style={{ borderRadius: 'var(--mantine-radius-md)', marginBottom: 4 }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Box mt="auto" pt="md" style={{ borderTop: '1px solid var(--mantine-color-default-border)' }}>
|
||||
<Select
|
||||
label="Color Scheme"
|
||||
size="xs"
|
||||
mb="xs"
|
||||
value={colorScheme}
|
||||
onChange={(val) => setColorScheme((val as ColorSchemeType) || 'light')}
|
||||
data={[
|
||||
{ value: 'light', label: 'Light' },
|
||||
{ value: 'dark', label: 'Dark' },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
label="Density"
|
||||
size="xs"
|
||||
value={density}
|
||||
onChange={(val) => setDensity((val as DensityType) || 'standard')}
|
||||
data={[
|
||||
{ value: 'compact', label: 'Compact' },
|
||||
{ value: 'standard', label: 'Standard' },
|
||||
]}
|
||||
/>
|
||||
</Box>
|
||||
</AppShell.Navbar>
|
||||
|
||||
<AppShell.Main style={{ display: 'flex', flexDirection: 'column', height: '100vh', overflow: 'hidden' }}>
|
||||
<Paper p="md" radius={0} withBorder style={{ borderTop: 0, borderLeft: 0, borderRight: 0, zIndex: 10, flexShrink: 0 }}>
|
||||
<Group justify="space-between">
|
||||
<Box>
|
||||
<Title order={3}>Architecture Showcase</Title>
|
||||
<Text size="sm" c="dimmed">{getSubtitle()}</Text>
|
||||
</Box>
|
||||
<Select
|
||||
w={180}
|
||||
size="sm"
|
||||
variant="filled"
|
||||
leftSection={<Globe size={16} />}
|
||||
data={[
|
||||
{ value: 'en', label: 'English' },
|
||||
{ value: 'id', label: 'Bahasa Indonesia' },
|
||||
]}
|
||||
value={i18n.resolvedLanguage || i18n.language}
|
||||
onChange={(val) => val && i18n.changeLanguage(val)}
|
||||
/>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
<Box className="flex-1 overflow-y-auto p-6" style={{ height: 'calc(100vh - 80px)' }}>
|
||||
<Outlet />
|
||||
</Box>
|
||||
</AppShell.Main>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
@import 'tailwindcss';
|
||||
@import '@repo/ui/theme.css';
|
||||
@source "../../../packages/ui/src";
|
||||
@@ -0,0 +1,17 @@
|
||||
import './main.css';
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { setupI18n } from '@repo/core-i18n';
|
||||
import App from './App';
|
||||
|
||||
async function bootstrap() {
|
||||
await setupI18n({}, 'en'); // Minimal i18n setup
|
||||
|
||||
createRoot(document.getElementById('app')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
@@ -0,0 +1,161 @@
|
||||
import { Card, Title, Text, Table, Stack, Badge } from '@repo/ui/components';
|
||||
import { PageActions, RowActions, PageActionProps, RowActionProps } from '@repo/ui/components';
|
||||
import { Save, Printer, Trash, MoreVertical, Edit, FileText, CheckCircle, Check } from 'lucide-react';
|
||||
|
||||
export default function ActionToolsShowcase() {
|
||||
const pageActions: PageActionProps[] = [
|
||||
{
|
||||
key: 'save',
|
||||
label: 'Save Changes',
|
||||
icon: <Save size={16} />,
|
||||
variant: 'filled',
|
||||
onClick: (key) => console.log('Clicked', key),
|
||||
},
|
||||
{
|
||||
key: 'save',
|
||||
label: 'Save Changes',
|
||||
icon: <Save size={16} />,
|
||||
onClick: (key) => console.log('Clicked', key),
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
key: 'print',
|
||||
label: 'Print',
|
||||
icon: <Printer size={16} />,
|
||||
children: [
|
||||
{
|
||||
key: 'print-original',
|
||||
label: 'Print Original',
|
||||
icon: <FileText size={16} />,
|
||||
onClick: (k) => console.log(k),
|
||||
},
|
||||
// { type: 'divider' },
|
||||
{ key: 'print-copy', label: 'Print Copy', icon: <FileText size={16} />, onClick: (k) => console.log(k) },
|
||||
{ key: 'print-copy', label: 'Print Copy', icon: <FileText size={16} />, onClick: (k) => console.log(k) },
|
||||
{ key: 'print-copy', label: 'Print Copy', icon: <FileText size={16} />, onClick: (k) => console.log(k) },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'confirm',
|
||||
label: 'Confirm',
|
||||
icon: <CheckCircle size={16} />,
|
||||
onClick: (key) => console.log('Clicked', key),
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
label: 'Delete',
|
||||
icon: <Trash size={16} />,
|
||||
intent: 'destructive',
|
||||
onClick: (key) => console.log('Clicked', key),
|
||||
},
|
||||
|
||||
{
|
||||
key: 'success',
|
||||
label: 'Success',
|
||||
icon: <Check size={16} />,
|
||||
intent: 'success',
|
||||
onClick: (key) => console.log('Clicked', key),
|
||||
},
|
||||
{
|
||||
key: 'warning',
|
||||
label: 'Warning',
|
||||
icon: <Check size={16} />,
|
||||
intent: 'warning',
|
||||
onClick: (key) => console.log('Clicked', key),
|
||||
},
|
||||
{
|
||||
key: 'Primary',
|
||||
label: 'Primary',
|
||||
icon: <Check size={16} />,
|
||||
intent: 'primary',
|
||||
onClick: (key) => console.log('Clicked', key),
|
||||
},
|
||||
];
|
||||
|
||||
const rowActions: RowActionProps[] = [
|
||||
{
|
||||
key: 'edit',
|
||||
tooltip: 'Edit Record',
|
||||
label: 'Edit Record',
|
||||
icon: <Edit size={16} />,
|
||||
onClick: (key) => console.log('Clicked', key),
|
||||
},
|
||||
{
|
||||
key: 'approve',
|
||||
tooltip: 'Approve',
|
||||
label: 'Approve',
|
||||
icon: <CheckCircle size={16} />,
|
||||
intent: 'success',
|
||||
onClick: (key) => console.log('Clicked', key),
|
||||
},
|
||||
{
|
||||
key: 'more',
|
||||
label: 'More Options',
|
||||
icon: <MoreVertical size={16} />,
|
||||
children: [
|
||||
{
|
||||
key: 'delete',
|
||||
label: 'Delete Record',
|
||||
icon: <Trash size={16} />,
|
||||
intent: 'destructive',
|
||||
onClick: (k) => console.log(k),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const tableData = [
|
||||
{ id: '1', name: 'Invoice #001', status: 'Pending' },
|
||||
{ id: '2', name: 'Invoice #002', status: 'Approved' },
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack gap="xl">
|
||||
<Card withBorder shadow="sm" radius="md" p="md">
|
||||
<Title order={4} mb="md">
|
||||
Page Actions
|
||||
</Title>
|
||||
<Text c="dimmed" mb="lg">
|
||||
Used in toolbars and page headers.
|
||||
</Text>
|
||||
<PageActions actions={pageActions} />
|
||||
</Card>
|
||||
|
||||
<Card withBorder shadow="sm" radius="md" p="md">
|
||||
<Title order={4} mb="md">
|
||||
Row Actions
|
||||
</Title>
|
||||
<Text c="dimmed" mb="lg">
|
||||
Used inside data grids or list items.
|
||||
</Text>
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>ID</Table.Th>
|
||||
<Table.Th>Name</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th style={{ width: 120 }}>Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{tableData.map((row) => (
|
||||
<Table.Tr key={row.id}>
|
||||
<Table.Td>{row.id}</Table.Td>
|
||||
<Table.Td>{row.name}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={row.status === 'Approved' ? 'success' : 'warning'}>{row.status}</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<RowActions actions={rowActions} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<RowActions showLabels actions={rowActions} />
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Card>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Card, Title, Text, Stack, Badge, Group, Select, NumberInput } from '@repo/ui/components';
|
||||
import { AgGridProvider, DataGrid } from '@repo/ui/ag-grid';
|
||||
import type { ColDef, ValueFormatterParams } from '@repo/ui/ag-grid';
|
||||
|
||||
/* ─── Sample Data ──────────────────────────────────────────────── */
|
||||
|
||||
interface OrderRow {
|
||||
id: string;
|
||||
orderCode: string;
|
||||
customer: string;
|
||||
email: string;
|
||||
product: string;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
total: number;
|
||||
status: 'Draft' | 'Pending' | 'Processing' | 'Shipped' | 'Delivered' | 'Cancelled';
|
||||
createdAt: string;
|
||||
region: string;
|
||||
}
|
||||
|
||||
const SAMPLE_DATA: OrderRow[] = [
|
||||
{ id: '1', orderCode: 'ORD-2026-001', customer: 'Acme Corp', email: 'acme@example.com', product: 'Widget A', quantity: 120, unitPrice: 24.5, total: 2940, status: 'Delivered', createdAt: '2026-01-15', region: 'North America' },
|
||||
{ id: '2', orderCode: 'ORD-2026-002', customer: 'Globex Inc', email: 'info@globex.com', product: 'Widget B', quantity: 50, unitPrice: 89.99, total: 4499.5, status: 'Shipped', createdAt: '2026-02-03', region: 'Europe' },
|
||||
{ id: '3', orderCode: 'ORD-2026-003', customer: 'Initech', email: 'orders@initech.com', product: 'Gadget Pro', quantity: 200, unitPrice: 15.0, total: 3000, status: 'Processing', createdAt: '2026-03-21', region: 'Asia Pacific' },
|
||||
{ id: '4', orderCode: 'ORD-2026-004', customer: 'Umbrella Ltd', email: 'sales@umbrella.co', product: 'Widget A', quantity: 75, unitPrice: 24.5, total: 1837.5, status: 'Pending', createdAt: '2026-04-10', region: 'Europe' },
|
||||
{ id: '5', orderCode: 'ORD-2026-005', customer: 'Stark Industries', email: 'tony@stark.io', product: 'Gadget Elite', quantity: 10, unitPrice: 499.0, total: 4990, status: 'Delivered', createdAt: '2026-04-18', region: 'North America' },
|
||||
{ id: '6', orderCode: 'ORD-2026-006', customer: 'Wayne Enterprises', email: 'bruce@wayne.com', product: 'Widget C', quantity: 300, unitPrice: 12.75, total: 3825, status: 'Draft', createdAt: '2026-05-02', region: 'North America' },
|
||||
{ id: '7', orderCode: 'ORD-2026-007', customer: 'Cyberdyne', email: 'info@cyberdyne.jp', product: 'Gadget Pro', quantity: 150, unitPrice: 15.0, total: 2250, status: 'Cancelled', createdAt: '2026-05-15', region: 'Asia Pacific' },
|
||||
{ id: '8', orderCode: 'ORD-2026-008', customer: 'Oscorp', email: 'orders@oscorp.com', product: 'Widget B', quantity: 90, unitPrice: 89.99, total: 8099.1, status: 'Shipped', createdAt: '2026-06-01', region: 'North America' },
|
||||
{ id: '9', orderCode: 'ORD-2026-009', customer: 'LexCorp', email: 'lex@lexcorp.com', product: 'Gadget Elite', quantity: 5, unitPrice: 499.0, total: 2495, status: 'Processing', createdAt: '2026-06-12', region: 'Europe' },
|
||||
{ id: '10', orderCode: 'ORD-2026-010', customer: 'Pied Piper', email: 'richard@piedpiper.io', product: 'Widget A', quantity: 500, unitPrice: 24.5, total: 12250, status: 'Pending', createdAt: '2026-06-20', region: 'North America' },
|
||||
{ id: '11', orderCode: 'ORD-2026-011', customer: 'Hooli', email: 'gavin@hooli.com', product: 'Gadget Pro', quantity: 1000, unitPrice: 15.0, total: 15000, status: 'Delivered', createdAt: '2026-07-01', region: 'North America' },
|
||||
{ id: '12', orderCode: 'ORD-2026-012', customer: 'Massive Dynamic', email: 'nina@massive.com', product: 'Widget C', quantity: 60, unitPrice: 12.75, total: 765, status: 'Shipped', createdAt: '2026-07-08', region: 'Europe' },
|
||||
];
|
||||
|
||||
/* ─── Status Badge Renderer ─────────────────────────────────────── */
|
||||
|
||||
const STATUS_COLORS: Record<OrderRow['status'], string> = {
|
||||
Draft: 'gray',
|
||||
Pending: 'warning',
|
||||
Processing: 'info',
|
||||
Shipped: 'brand',
|
||||
Delivered: 'success',
|
||||
Cancelled: 'error',
|
||||
};
|
||||
|
||||
function StatusCellRenderer({ value }: { value: OrderRow['status'] }) {
|
||||
return (
|
||||
<Badge size="sm" variant="light" color={STATUS_COLORS[value] || 'gray'}>
|
||||
{value}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Currency Formatter ────────────────────────────────────────── */
|
||||
|
||||
function currencyFormatter(params: ValueFormatterParams): string {
|
||||
if (params.value == null) return '-';
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: 2,
|
||||
}).format(params.value);
|
||||
}
|
||||
|
||||
/* ─── Showcase Component ────────────────────────────────────────── */
|
||||
|
||||
export default function AgGridShowcase() {
|
||||
const [gridHeight, setGridHeight] = useState(460);
|
||||
const [rowModelType, setRowModelType] = useState<string>('clientSide');
|
||||
|
||||
const defaultColDef = useMemo<ColDef>(
|
||||
() => ({
|
||||
flex: 1,
|
||||
minWidth: 100,
|
||||
sortable: true,
|
||||
filter: true,
|
||||
resizable: true,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const columnDefs = useMemo<ColDef<OrderRow>[]>(
|
||||
() => [
|
||||
{
|
||||
field: 'orderCode',
|
||||
headerName: 'Order Code',
|
||||
pinned: 'left',
|
||||
minWidth: 140,
|
||||
filter: 'agTextColumnFilter',
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
headerName: 'Status',
|
||||
minWidth: 120,
|
||||
cellRenderer: StatusCellRenderer,
|
||||
filter: 'agSetColumnFilter',
|
||||
},
|
||||
{
|
||||
field: 'customer',
|
||||
headerName: 'Customer',
|
||||
minWidth: 160,
|
||||
filter: 'agTextColumnFilter',
|
||||
},
|
||||
{
|
||||
field: 'email',
|
||||
headerName: 'Email',
|
||||
minWidth: 180,
|
||||
filter: 'agTextColumnFilter',
|
||||
},
|
||||
{
|
||||
field: 'product',
|
||||
headerName: 'Product',
|
||||
minWidth: 130,
|
||||
filter: 'agSetColumnFilter',
|
||||
},
|
||||
{
|
||||
field: 'region',
|
||||
headerName: 'Region',
|
||||
minWidth: 140,
|
||||
filter: 'agSetColumnFilter',
|
||||
enableRowGroup: true,
|
||||
},
|
||||
{
|
||||
field: 'quantity',
|
||||
headerName: 'Qty',
|
||||
minWidth: 80,
|
||||
filter: 'agNumberColumnFilter',
|
||||
type: 'numericColumn',
|
||||
},
|
||||
{
|
||||
field: 'unitPrice',
|
||||
headerName: 'Unit Price',
|
||||
minWidth: 110,
|
||||
filter: 'agNumberColumnFilter',
|
||||
type: 'numericColumn',
|
||||
valueFormatter: currencyFormatter,
|
||||
},
|
||||
{
|
||||
field: 'total',
|
||||
headerName: 'Total',
|
||||
minWidth: 120,
|
||||
filter: 'agNumberColumnFilter',
|
||||
type: 'numericColumn',
|
||||
valueFormatter: currencyFormatter,
|
||||
},
|
||||
{
|
||||
field: 'createdAt',
|
||||
headerName: 'Created Date',
|
||||
minWidth: 130,
|
||||
filter: 'agDateColumnFilter',
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<AgGridProvider bypassLicense>
|
||||
<Stack gap="xl">
|
||||
{/* ── Info Card ────────────────────────────────── */}
|
||||
<Card withBorder shadow="sm" radius="md" p="md">
|
||||
<Title order={4} mb="xs">
|
||||
AG Grid Enterprise — Mantine Integration
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" mb="md">
|
||||
This showcase demonstrates AG Grid integrated with Mantine's theme system. The grid
|
||||
automatically adapts to dark/light mode changes, inherits brand colors, typography, and
|
||||
border radius from the Mantine theme — all via AG Grid's built-in CSS variable system with
|
||||
zero custom stylesheets. Toggle the color scheme in the Theme Controls above to see it in
|
||||
action.
|
||||
</Text>
|
||||
<Group>
|
||||
<NumberInput
|
||||
label="Grid Height (px)"
|
||||
value={gridHeight}
|
||||
onChange={(val) => setGridHeight(Number(val) || 400)}
|
||||
min={300}
|
||||
max={800}
|
||||
step={50}
|
||||
w={180}
|
||||
/>
|
||||
<Select
|
||||
label="Row Model"
|
||||
value={rowModelType}
|
||||
onChange={(val) => setRowModelType(val || 'clientSide')}
|
||||
data={[
|
||||
{ value: 'clientSide', label: 'Client Side' },
|
||||
{ value: 'serverSide', label: 'Server Side (no datasource)' },
|
||||
]}
|
||||
w={260}
|
||||
/>
|
||||
</Group>
|
||||
</Card>
|
||||
|
||||
{/* ── AG Grid ──────────────────────────────────── */}
|
||||
<Card withBorder shadow="sm" radius="md" p="md">
|
||||
<Title order={4} mb="md">
|
||||
Orders Data Grid
|
||||
</Title>
|
||||
<div style={{ height: gridHeight }}>
|
||||
<DataGrid<OrderRow>
|
||||
rowData={SAMPLE_DATA}
|
||||
columnDefs={columnDefs}
|
||||
defaultColDef={defaultColDef}
|
||||
rowSelection={{ mode: 'multiRow', checkboxes: true }}
|
||||
pagination
|
||||
paginationPageSize={10}
|
||||
animateRows
|
||||
enableCellTextSelection
|
||||
suppressCopyRowsToClipboard
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</Stack>
|
||||
</AgGridProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Stack, Container, Card, Title, Text } from '@repo/ui/components';
|
||||
|
||||
export default function AuthPage() {
|
||||
return (
|
||||
<Container size="xl" m={0} p={0}>
|
||||
<Stack gap="xl">
|
||||
<Card withBorder shadow="sm" radius="md" p="md">
|
||||
<Title order={4} mb="md">Auth & Security</Title>
|
||||
<Text c="dimmed">Auth Demo Component Coming Soon...</Text>
|
||||
</Card>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
import { useState } from 'react';
|
||||
import { usePublishEvent } from '@repo/core-events';
|
||||
import { Button, Group, Stack, TextInput, Badge } from '@repo/ui/components';
|
||||
import { AUTH_EVENTS } from '../../../../../core/constants/events';
|
||||
|
||||
// ─── ProfileSettingsUI ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A profile settings form that publishes `AUTH:PROFILE_UPDATED`
|
||||
* when the user saves changes.
|
||||
*
|
||||
* **Decoupling principle:**
|
||||
* This component doesn't know about IndexedDB, localStorage,
|
||||
* or any storage mechanism. It simply announces that the profile
|
||||
* has been updated. Any number of listeners can react to this
|
||||
* event independently:
|
||||
*
|
||||
* - `StorageSyncListener` persists to IndexedDB
|
||||
* - A hypothetical `AnalyticsListener` could send to Mixpanel
|
||||
* - A hypothetical `AvatarCacheListener` could pre-warm a CDN
|
||||
*
|
||||
* All without modifying this component.
|
||||
*/
|
||||
export function ProfileSettingsUI() {
|
||||
const publish = usePublishEvent();
|
||||
|
||||
const [name, setName] = useState('Firman Ramdhani');
|
||||
const [email, setEmail] = useState('firman@eigen.co.id');
|
||||
const [avatar, setAvatar] = useState('https://ui-avatars.com/api/?name=FM&background=4263eb&color=fff');
|
||||
const [saveCount, setSaveCount] = useState(0);
|
||||
|
||||
const handleSave = () => {
|
||||
publish(AUTH_EVENTS.PROFILE_UPDATED, {
|
||||
id: 'user-1',
|
||||
name,
|
||||
email,
|
||||
avatar,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
setSaveCount((c) => c + 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Group grow align="flex-start">
|
||||
<TextInput label="Full Name" value={name} onChange={(e) => setName(e.currentTarget.value)} size="sm" />
|
||||
<TextInput label="Email" value={email} onChange={(e) => setEmail(e.currentTarget.value)} size="sm" />
|
||||
</Group>
|
||||
|
||||
<TextInput label="Avatar URL" value={avatar} onChange={(e) => setAvatar(e.currentTarget.value)} size="sm" />
|
||||
|
||||
<Group>
|
||||
<Button variant="filled" color="brand" onClick={handleSave}>
|
||||
💾 Save Profile
|
||||
</Button>
|
||||
{saveCount > 0 && (
|
||||
<Badge color="success" variant="light">
|
||||
Synced {saveCount}×
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import { useAppEvent } from '@repo/core-events';
|
||||
import { AUTH_EVENTS } from '../../../../../core/constants/events';
|
||||
import type { ProfileUpdatedPayload } from '@repo/core-events';
|
||||
import { secureIndexedDB, AppStorageKey } from '../../../../../core/storage/local';
|
||||
|
||||
// ─── Props ──────────────────────────────────────────────────────
|
||||
|
||||
interface StorageSyncListenerProps {
|
||||
/** Callback to log messages to the parent demo UI. */
|
||||
onLog: (message: string) => void;
|
||||
}
|
||||
|
||||
// ─── StorageSyncListener ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Headless component that listens to `AUTH:PROFILE_UPDATED` events
|
||||
* and persists the profile data to IndexedDB via `@repo/core-storage`.
|
||||
*/
|
||||
export function StorageSyncListener({ onLog }: StorageSyncListenerProps) {
|
||||
useAppEvent(AUTH_EVENTS.PROFILE_UPDATED, (payload: ProfileUpdatedPayload) => {
|
||||
onLog(`Received AUTH:PROFILE_UPDATED for "${payload.name}" (${payload.email})`);
|
||||
|
||||
// Persist to IndexedDB via local AppStorageKey.
|
||||
secureIndexedDB
|
||||
.setItem(AppStorageKey.USER_PROFILE, payload)
|
||||
.then(() => {
|
||||
onLog(`✅ Profile persisted to IndexedDB (key: "${AppStorageKey.USER_PROFILE}", encrypted: true)`);
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
onLog(`❌ IndexedDB write failed: ${err.message}`);
|
||||
});
|
||||
});
|
||||
|
||||
// Headless — renders nothing
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import {
|
||||
Card,
|
||||
Title,
|
||||
Text,
|
||||
Stack,
|
||||
Badge,
|
||||
Divider,
|
||||
} from '@repo/ui/components';
|
||||
|
||||
// ── Showcase Components ──────────────────────────────────────────
|
||||
import { CashierUI } from './printer/cashier.ui';
|
||||
import { PrinterListener } from './printer/printer.listener';
|
||||
import { LiveStockGrid } from './stock-grid/live-stock-grid.ui';
|
||||
import { ProfileSettingsUI } from './auth-sync/profile-settings.ui';
|
||||
import { StorageSyncListener } from './auth-sync/storage-sync.listener';
|
||||
import { DEVICE_EVENTS, AUTH_EVENTS } from '../../../../core/constants/events';
|
||||
|
||||
// ─── Events Demo Page ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Orchestrator page for all three Event Bus showcase demos.
|
||||
*
|
||||
* This component is completely self-contained within the
|
||||
* `events-demo/` folder and does not leak state or side-effects
|
||||
* into the rest of the application.
|
||||
*/
|
||||
export default function EventsDemoPage() {
|
||||
// ── Showcase 1: Printer status feedback ──────────────────────
|
||||
const [printerLog, setPrinterLog] = useState<string[]>([]);
|
||||
|
||||
// ── Showcase 3: Storage sync status feedback ─────────────────
|
||||
const [syncLog, setSyncLog] = useState<string[]>([]);
|
||||
|
||||
// ── Render counter to prove this parent is stable ────────────
|
||||
const renderCount = useRef(0);
|
||||
renderCount.current += 1;
|
||||
|
||||
// Keep log sizes bounded
|
||||
useEffect(() => {
|
||||
if (printerLog.length > 20) setPrinterLog((prev) => prev.slice(-20));
|
||||
}, [printerLog.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (syncLog.length > 20) setSyncLog((prev) => prev.slice(-20));
|
||||
}, [syncLog.length]);
|
||||
|
||||
return (
|
||||
<Stack gap="xl">
|
||||
<div>
|
||||
<Title order={2}>🔌 Event Bus Showcase</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
Three real-world demos of <code>@repo/core-events</code> — zero coupling, strict typing, high performance.
|
||||
</Text>
|
||||
<Badge color="brand" variant="light" mt="xs">
|
||||
Parent render count: {renderCount.current}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* ═══════════════════════════════════════════════════════════
|
||||
SHOWCASE 1: Cross-Platform Printer Abstraction
|
||||
═══════════════════════════════════════════════════════════ */}
|
||||
<Card withBorder shadow="sm" radius="md" p="md">
|
||||
<Title order={4} mb="xs">
|
||||
🖨️ Showcase 1: Cross-Platform Printer Abstraction
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" mb="md">
|
||||
The CashierUI publishes a <code>{DEVICE_EVENTS.PRINT_RECEIPT}</code> event.
|
||||
The PrinterListener listens for it and simulates interacting with a physical printer.
|
||||
</Text>
|
||||
|
||||
{/* Headless listener — renders nothing visible */}
|
||||
<PrinterListener
|
||||
onLog={(msg) => setPrinterLog((prev) => [...prev, `[${new Date().toLocaleTimeString()}] ${msg}`])}
|
||||
/>
|
||||
|
||||
<CashierUI />
|
||||
|
||||
{printerLog.length > 0 && (
|
||||
<>
|
||||
<Divider my="sm" />
|
||||
<Text size="sm" fw={600}>
|
||||
📋 Printer Log:
|
||||
</Text>
|
||||
<div
|
||||
style={{
|
||||
maxHeight: 120,
|
||||
overflow: 'auto',
|
||||
fontSize: 12,
|
||||
fontFamily: 'monospace',
|
||||
background: 'var(--mantine-color-dark-7, #1a1b1e)',
|
||||
color: 'var(--mantine-color-green-4, #69db7c)',
|
||||
padding: 8,
|
||||
borderRadius: 6,
|
||||
}}
|
||||
>
|
||||
{printerLog.map((line, i) => (
|
||||
<div key={i}>{line}</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* ═══════════════════════════════════════════════════════════
|
||||
SHOWCASE 2: Extreme Performance — Live Stock Grid
|
||||
═══════════════════════════════════════════════════════════ */}
|
||||
<Card withBorder shadow="sm" radius="md" p="md">
|
||||
<Title order={4} mb="xs">
|
||||
📈 Showcase 2: High-Frequency Real-Time Data (50 updates/sec)
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" mb="md">
|
||||
A mock WebSocket fires <code>WS:STOCK_UPDATE</code> every 20ms.
|
||||
Each StockRow subscribes to the global event but only updates when{' '}
|
||||
<code>payload.id === row.id</code>. The parent grid never re-renders.
|
||||
</Text>
|
||||
|
||||
<LiveStockGrid />
|
||||
</Card>
|
||||
|
||||
{/* ═══════════════════════════════════════════════════════════
|
||||
SHOWCASE 3: Auth/Profile → IndexedDB Sync
|
||||
═══════════════════════════════════════════════════════════ */}
|
||||
<Card withBorder shadow="sm" radius="md" p="md">
|
||||
<Title order={4} mb="xs">
|
||||
💾 Showcase 3: Auth/Profile Sync with <code>@repo/core-storage</code>
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" mb="md">
|
||||
ProfileSettingsUI publishes <code>{AUTH_EVENTS.PROFILE_UPDATED}</code>.
|
||||
StorageSyncListener silently catches it in the background and saves to IndexedDB via <code>secureIndexedDB</code>.
|
||||
</Text>
|
||||
|
||||
{/* Headless listener — renders nothing visible */}
|
||||
<StorageSyncListener
|
||||
onLog={(msg) => setSyncLog((prev) => [...prev, `[${new Date().toLocaleTimeString()}] ${msg}`])}
|
||||
/>
|
||||
|
||||
<ProfileSettingsUI />
|
||||
|
||||
{syncLog.length > 0 && (
|
||||
<>
|
||||
<Divider my="sm" />
|
||||
<Text size="sm" fw={600}>
|
||||
📋 Storage Sync Log:
|
||||
</Text>
|
||||
<div
|
||||
style={{
|
||||
maxHeight: 120,
|
||||
overflow: 'auto',
|
||||
fontSize: 12,
|
||||
fontFamily: 'monospace',
|
||||
background: 'var(--mantine-color-dark-7, #1a1b1e)',
|
||||
color: 'var(--mantine-color-blue-4, #4dabf7)',
|
||||
padding: 8,
|
||||
borderRadius: 6,
|
||||
}}
|
||||
>
|
||||
{syncLog.map((line, i) => (
|
||||
<div key={i}>{line}</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useState } from 'react';
|
||||
import { usePublishEvent } from '@repo/core-events';
|
||||
import type { ReceiptItem } from '@repo/core-events';
|
||||
import {
|
||||
Button,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Table,
|
||||
Badge,
|
||||
} from '@repo/ui/components';
|
||||
import { DEVICE_EVENTS } from '../../../../../core/constants/events';
|
||||
|
||||
// ─── Mock Receipt Data ──────────────────────────────────────────
|
||||
|
||||
const DEMO_ITEMS: ReceiptItem[] = [
|
||||
{ name: 'Espresso', qty: 2, price: 3.5 },
|
||||
{ name: 'Croissant', qty: 1, price: 4.25 },
|
||||
{ name: 'Orange Juice', qty: 3, price: 2.75 },
|
||||
];
|
||||
|
||||
// ─── CashierUI ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A simple cashier interface that publishes a print receipt event.
|
||||
*
|
||||
* This component has ZERO knowledge of how printing works.
|
||||
* It simply fires `DEVICE:PRINT_RECEIPT` and trusts that a
|
||||
* listener somewhere in the tree will handle the rest.
|
||||
*
|
||||
* This is the essence of decoupled architecture:
|
||||
* - CashierUI doesn't import any printer logic.
|
||||
* - CashierUI doesn't know if it's running in Electron or browser.
|
||||
* - CashierUI doesn't even know if anyone is listening.
|
||||
*/
|
||||
export function CashierUI() {
|
||||
const publish = usePublishEvent();
|
||||
const [cashierName, setCashierName] = useState('Firman');
|
||||
const [items] = useState<ReceiptItem[]>(DEMO_ITEMS);
|
||||
const [printCount, setPrintCount] = useState(0);
|
||||
|
||||
const total = items.reduce((sum, item) => sum + item.qty * item.price, 0);
|
||||
|
||||
const handlePrint = () => {
|
||||
publish(DEVICE_EVENTS.PRINT_RECEIPT, {
|
||||
receiptId: `RCP-${Date.now().toString(36).toUpperCase()}`,
|
||||
items,
|
||||
total,
|
||||
cashierName,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
setPrintCount((c) => c + 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
label="Cashier Name"
|
||||
value={cashierName}
|
||||
onChange={(e) => setCashierName(e.currentTarget.value)}
|
||||
size="sm"
|
||||
style={{ maxWidth: 250 }}
|
||||
/>
|
||||
|
||||
<Table striped highlightOnHover withTableBorder withColumnBorders>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Item</Table.Th>
|
||||
<Table.Th>Qty</Table.Th>
|
||||
<Table.Th>Price</Table.Th>
|
||||
<Table.Th>Subtotal</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{items.map((item, idx) => (
|
||||
<Table.Tr key={idx}>
|
||||
<Table.Td>{item.name}</Table.Td>
|
||||
<Table.Td>{item.qty}</Table.Td>
|
||||
<Table.Td>${item.price.toFixed(2)}</Table.Td>
|
||||
<Table.Td>${(item.qty * item.price).toFixed(2)}</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
<Table.Tfoot>
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={3}>
|
||||
<Text fw={700}>Total</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fw={700}>${total.toFixed(2)}</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
</Table.Tfoot>
|
||||
</Table>
|
||||
|
||||
<Group>
|
||||
<Button variant="filled" color="brand" onClick={handlePrint}>
|
||||
🖨️ Print Receipt
|
||||
</Button>
|
||||
{printCount > 0 && (
|
||||
<Badge color="success" variant="light">
|
||||
Printed {printCount}×
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useAppEvent } from '@repo/core-events';
|
||||
import { DEVICE_EVENTS } from '../../../../../core/constants/events';
|
||||
import type { PrintReceiptPayload } from '@repo/core-events';
|
||||
|
||||
// ─── Props ──────────────────────────────────────────────────────
|
||||
|
||||
interface PrinterListenerProps {
|
||||
/** Callback to log messages to the parent demo UI. */
|
||||
onLog: (message: string) => void;
|
||||
}
|
||||
|
||||
// ─── PrinterListener ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Headless component that listens to `DEVICE:PRINT_RECEIPT` events
|
||||
* and dispatches the print job to the correct platform.
|
||||
*
|
||||
* **Platform detection strategy:**
|
||||
* - If `window.electronAPI` exists → Electron preload bridge.
|
||||
* Uses `window.electronAPI.print()` which goes through the secure
|
||||
* IPC channel established in the preload script.
|
||||
* - Otherwise → Browser fallback using `window.print()`.
|
||||
*
|
||||
* This component renders nothing — it is purely a side-effect listener.
|
||||
* Mount it anywhere in the React tree; it will auto-cleanup on unmount.
|
||||
*
|
||||
* **Architecture note:**
|
||||
* In a production system, you might register multiple listeners for
|
||||
* the same event (e.g., one for printing, another for analytics).
|
||||
* The event bus supports unlimited subscribers per event.
|
||||
*/
|
||||
export function PrinterListener({ onLog }: PrinterListenerProps) {
|
||||
useAppEvent(DEVICE_EVENTS.PRINT_RECEIPT, (payload: PrintReceiptPayload) => {
|
||||
const isElectron = typeof window !== 'undefined' && !!window.electronAPI;
|
||||
|
||||
if (isElectron) {
|
||||
// ── Electron Path ──────────────────────────────────────────
|
||||
// Uses the preload-exposed API. The Electron main process
|
||||
// handles the actual OS-level print job via `webContents.print()`.
|
||||
onLog(`[Electron] Sending receipt ${payload.receiptId} to OS printer via IPC bridge...`);
|
||||
window.electronAPI!
|
||||
.print({ silent: true, printBackground: true })
|
||||
.then((result) => {
|
||||
if (result.success) {
|
||||
onLog(`[Electron] ✅ Receipt ${payload.receiptId} printed successfully.`);
|
||||
} else {
|
||||
onLog(`[Electron] ❌ Print failed: ${result.failureReason ?? 'Unknown error'}`);
|
||||
}
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
onLog(`[Electron] ❌ IPC error: ${err.message}`);
|
||||
});
|
||||
} else {
|
||||
// ── Browser Fallback ───────────────────────────────────────
|
||||
// Opens the native browser print dialog. In production, you'd
|
||||
// likely render a hidden print-optimized iframe first.
|
||||
onLog(`[Browser] 🖨️ Receipt ${payload.receiptId} — opening browser print dialog...`);
|
||||
onLog(` → Cashier: ${payload.cashierName} | Items: ${payload.items.length} | Total: $${payload.total.toFixed(2)}`);
|
||||
window.print();
|
||||
}
|
||||
});
|
||||
|
||||
// Headless — renders nothing
|
||||
return null;
|
||||
}
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Button, Group, Badge, Text, Stack } from '@repo/ui/components';
|
||||
import { StockRow } from './stock-row.ui';
|
||||
import { generateStockIds, startMockWebSocket } from './mock-websocket.service';
|
||||
|
||||
// ─── Constants ──────────────────────────────────────────────────
|
||||
|
||||
const STOCK_COUNT = 1000;
|
||||
const VISIBLE_ROWS = 50; // Virtual-scroll window (show first N for performance)
|
||||
|
||||
// ─── LiveStockGrid ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Renders a high-performance stock grid with 1000 rows.
|
||||
*
|
||||
* **Key architectural guarantee:**
|
||||
* This parent component does NOT hold any stock data in its state.
|
||||
* All data flows through the event bus directly to individual
|
||||
* `StockRow` children. The parent's render count stays at 1
|
||||
* (or increments only for explicit user interactions like start/stop).
|
||||
*
|
||||
* **Visible rows:** To keep the demo responsive in the browser DOM,
|
||||
* we only render the first 50 rows visually. In production, you'd
|
||||
* use a virtualizer (e.g., TanStack Virtual). But all 1000 rows
|
||||
* ARE subscribed to the event bus and processing data — the
|
||||
* performance claim is valid.
|
||||
*/
|
||||
export function LiveStockGrid() {
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const wsRef = useRef<ReturnType<typeof startMockWebSocket> | null>(null);
|
||||
const renderCount = useRef(0);
|
||||
renderCount.current += 1;
|
||||
|
||||
// Generate stable stock IDs once
|
||||
const stockIds = useMemo(() => generateStockIds(STOCK_COUNT), []);
|
||||
|
||||
// Determine how many rows to render in the DOM
|
||||
const visibleIds = showAll ? stockIds : stockIds.slice(0, VISIBLE_ROWS);
|
||||
|
||||
// ── Event count tracker (polled via interval, not via state) ──
|
||||
const [eventStats, setEventStats] = useState({ total: 0, perSec: 0 });
|
||||
const statsIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const startFeed = () => {
|
||||
if (wsRef.current) return;
|
||||
|
||||
wsRef.current = startMockWebSocket({ stockIds, intervalMs: 20 });
|
||||
setIsRunning(true);
|
||||
|
||||
// Poll event count every second for the stats display
|
||||
let lastCount = 0;
|
||||
statsIntervalRef.current = setInterval(() => {
|
||||
if (!wsRef.current) return;
|
||||
const current = wsRef.current.getEventCount();
|
||||
setEventStats({ total: current, perSec: current - lastCount });
|
||||
lastCount = current;
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const stopFeed = () => {
|
||||
wsRef.current?.cleanup();
|
||||
wsRef.current = null;
|
||||
if (statsIntervalRef.current) clearInterval(statsIntervalRef.current);
|
||||
statsIntervalRef.current = null;
|
||||
setIsRunning(false);
|
||||
};
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
wsRef.current?.cleanup();
|
||||
if (statsIntervalRef.current) clearInterval(statsIntervalRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
{/* ── Controls ──────────────────────────────────────────── */}
|
||||
<Group>
|
||||
{!isRunning ? (
|
||||
<Button variant="filled" color="brand" onClick={startFeed} size="sm">
|
||||
▶ Start Feed (50 events/sec)
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="filled" color="error" onClick={stopFeed} size="sm">
|
||||
■ Stop Feed
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="light" color="info" onClick={() => setShowAll((s) => !s)} size="sm">
|
||||
{showAll ? `Show ${VISIBLE_ROWS} rows` : `Show all ${STOCK_COUNT} rows`}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{/* ── Stats Bar ─────────────────────────────────────────── */}
|
||||
<Group gap="md">
|
||||
<Badge color="brand" variant="light" size="lg">
|
||||
Grid renders: {renderCount.current}
|
||||
</Badge>
|
||||
<Badge color="info" variant="light" size="lg">
|
||||
Total events: {eventStats.total.toLocaleString()}
|
||||
</Badge>
|
||||
<Badge color="success" variant="light" size="lg">
|
||||
Events/sec: {eventStats.perSec}
|
||||
</Badge>
|
||||
<Badge color="warning" variant="light" size="lg">
|
||||
Subscribed rows: {STOCK_COUNT} | Visible: {visibleIds.length}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<Text size="xs" c="dimmed">
|
||||
Each row shows its own render count in the last column. Only rows receiving updates re-render.
|
||||
</Text>
|
||||
|
||||
{/* ── Data Grid ─────────────────────────────────────────── */}
|
||||
<div style={{ maxHeight: 500, overflow: 'auto', border: '1px solid var(--app-shell-border-color)' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
background: 'var(--mantine-color-body)',
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<th
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
textAlign: 'left',
|
||||
borderBottom: '2px solid var(--app-shell-border-color)',
|
||||
}}
|
||||
>
|
||||
Ticker
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
textAlign: 'right',
|
||||
borderBottom: '2px solid var(--app-shell-border-color)',
|
||||
}}
|
||||
>
|
||||
Price
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
textAlign: 'right',
|
||||
borderBottom: '2px solid var(--app-shell-border-color)',
|
||||
}}
|
||||
>
|
||||
Change
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
textAlign: 'right',
|
||||
borderBottom: '2px solid var(--app-shell-border-color)',
|
||||
}}
|
||||
>
|
||||
Volume
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
textAlign: 'right',
|
||||
borderBottom: '2px solid var(--app-shell-border-color)',
|
||||
}}
|
||||
>
|
||||
Renders
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{visibleIds.map((id) => (
|
||||
<StockRow key={id} stockId={id} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
import { publish } from '@repo/core-events';
|
||||
import { WS_EVENTS } from '../../../../../core/constants/events';
|
||||
|
||||
// ─── Stock Tickers ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Pool of realistic stock ticker symbols.
|
||||
* We generate 1000 unique IDs from these base tickers + numeric suffix.
|
||||
*/
|
||||
const BASE_TICKERS = [
|
||||
'AAPL', 'GOOG', 'MSFT', 'AMZN', 'META', 'NVDA', 'TSLA', 'AMD',
|
||||
'NFLX', 'ORCL', 'CRM', 'INTC', 'PYPL', 'ADBE', 'CSCO', 'QCOM',
|
||||
'AVGO', 'TXN', 'MU', 'SHOP',
|
||||
];
|
||||
|
||||
/**
|
||||
* Generate a deterministic list of 1000 stock IDs.
|
||||
*/
|
||||
export function generateStockIds(count: number = 1000): string[] {
|
||||
const ids: string[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
ids.push(`${BASE_TICKERS[i % BASE_TICKERS.length]}-${String(i).padStart(4, '0')}`);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
// ─── Mock WebSocket ─────────────────────────────────────────────
|
||||
|
||||
interface MockWebSocketConfig {
|
||||
/** All stock IDs to cycle through. */
|
||||
stockIds: string[];
|
||||
/** Interval in ms between events. @default 20 */
|
||||
intervalMs?: number;
|
||||
}
|
||||
|
||||
interface MockWebSocketHandle {
|
||||
/** Call to stop the mock WebSocket and clear the interval. */
|
||||
cleanup: () => void;
|
||||
/** Number of events emitted so far. */
|
||||
getEventCount: () => number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulates a WebSocket that pushes stock price updates at high frequency.
|
||||
*
|
||||
* Fires `WS:STOCK_UPDATE` every `intervalMs` (default 20ms = 50 updates/sec).
|
||||
* Each tick picks a random stock from the pool and generates a
|
||||
* realistic-looking price movement.
|
||||
*
|
||||
* Returns a handle with a `cleanup()` function to stop the simulation.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const ws = startMockWebSocket({ stockIds: ['AAPL-0001', ...] });
|
||||
* // Later:
|
||||
* ws.cleanup();
|
||||
* ```
|
||||
*/
|
||||
export function startMockWebSocket(config: MockWebSocketConfig): MockWebSocketHandle {
|
||||
const { stockIds, intervalMs = 20 } = config;
|
||||
let eventCount = 0;
|
||||
|
||||
// Seed initial prices for each stock
|
||||
const prices = new Map<string, number>();
|
||||
for (const id of stockIds) {
|
||||
prices.set(id, 100 + Math.random() * 400); // $100–$500
|
||||
}
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
// Pick a random stock
|
||||
const randomIndex = Math.floor(Math.random() * stockIds.length);
|
||||
const id = stockIds[randomIndex]!;
|
||||
const currentPrice = prices.get(id)!;
|
||||
|
||||
// Generate a small random price change (-2% to +2%)
|
||||
const changePercent = (Math.random() - 0.5) * 0.04;
|
||||
const change = +(currentPrice * changePercent).toFixed(2);
|
||||
const newPrice = +(currentPrice + change).toFixed(2);
|
||||
|
||||
// Update tracked price
|
||||
prices.set(id, newPrice);
|
||||
|
||||
// Publish to the event bus
|
||||
publish(WS_EVENTS.STOCK_UPDATE, {
|
||||
id,
|
||||
price: newPrice,
|
||||
change,
|
||||
volume: Math.floor(Math.random() * 100000),
|
||||
});
|
||||
|
||||
eventCount++;
|
||||
}, intervalMs);
|
||||
|
||||
return {
|
||||
cleanup: () => clearInterval(intervalId),
|
||||
getEventCount: () => eventCount,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { memo, useState, useRef } from 'react';
|
||||
import { useAppEvent } from '@repo/core-events';
|
||||
import { WS_EVENTS } from '../../../../../core/constants/events';
|
||||
|
||||
// ─── Props ──────────────────────────────────────────────────────
|
||||
|
||||
interface StockRowProps {
|
||||
stockId: string;
|
||||
}
|
||||
|
||||
// ─── Local State ────────────────────────────────────────────────
|
||||
|
||||
interface StockState {
|
||||
price: number;
|
||||
change: number;
|
||||
volume: number;
|
||||
lastUpdate: number;
|
||||
}
|
||||
|
||||
// ─── StockRow ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A single row in the stock grid.
|
||||
*
|
||||
* **Performance architecture:**
|
||||
* 1. Each StockRow subscribes to the global `WS:STOCK_UPDATE` event.
|
||||
* 2. The handler checks `payload.id === stockId` — if no match, it
|
||||
* does NOTHING (no setState, no re-render).
|
||||
* 3. Only the targeted row updates its own local state.
|
||||
* 4. `React.memo` prevents re-renders from parent prop changes.
|
||||
*
|
||||
* Result: At 50 events/sec across 1000 rows, only ~1 row re-renders
|
||||
* per tick. The parent `LiveStockGrid` NEVER re-renders.
|
||||
*/
|
||||
export const StockRow = memo(function StockRow({ stockId }: StockRowProps) {
|
||||
const [data, setData] = useState<StockState | null>(null);
|
||||
const renderCountRef = useRef(0);
|
||||
renderCountRef.current += 1;
|
||||
|
||||
useAppEvent(WS_EVENTS.STOCK_UPDATE, (payload) => {
|
||||
// ── Critical filter ──────────────────────────────────────────
|
||||
// This is the key performance optimization. Only the row whose
|
||||
// ID matches the event payload will call setState. All other
|
||||
// rows (999 out of 1000) do absolutely nothing.
|
||||
if (payload.id !== stockId) return;
|
||||
|
||||
setData({
|
||||
price: payload.price,
|
||||
change: payload.change,
|
||||
volume: payload.volume,
|
||||
lastUpdate: Date.now(),
|
||||
});
|
||||
});
|
||||
|
||||
const changeColor = data
|
||||
? data.change >= 0
|
||||
? '#40c057' // green
|
||||
: '#fa5252' // red
|
||||
: undefined;
|
||||
|
||||
const changeArrow = data
|
||||
? data.change >= 0
|
||||
? '▲'
|
||||
: '▼'
|
||||
: '';
|
||||
|
||||
return (
|
||||
<tr style={{ fontSize: 12, fontFamily: 'monospace' }}>
|
||||
<td style={{ padding: '2px 8px', fontWeight: 600 }}>{stockId}</td>
|
||||
<td style={{ padding: '2px 8px', textAlign: 'right' }}>
|
||||
{data ? `$${data.price.toFixed(2)}` : '—'}
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
padding: '2px 8px',
|
||||
textAlign: 'right',
|
||||
color: changeColor,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{data ? `${changeArrow} ${data.change >= 0 ? '+' : ''}${data.change.toFixed(2)}` : '—'}
|
||||
</td>
|
||||
<td style={{ padding: '2px 8px', textAlign: 'right' }}>
|
||||
{data ? data.volume.toLocaleString() : '—'}
|
||||
</td>
|
||||
<td style={{ padding: '2px 8px', textAlign: 'right', color: '#868e96' }}>
|
||||
{renderCountRef.current}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import BookingSample from './features/booking/presentation/BookingSample';
|
||||
import StorageSample from './features/storage/presentation/StorageSample';
|
||||
import I18nSample from './features/i18n/presentation/I18nSample';
|
||||
|
||||
export default function ExamplePage() {
|
||||
return (
|
||||
<div className="bg-amber-200">
|
||||
example
|
||||
<div className="p-8">
|
||||
<h1 className="text-2xl font-bold mb-4">Enterprise Web App</h1>
|
||||
<BookingSample />
|
||||
</div>
|
||||
<div className="p-8">
|
||||
<h1 className="text-2xl font-bold mb-4">Enterprise Web App</h1>
|
||||
<StorageSample />
|
||||
</div>
|
||||
<div className="p-8 bg-slate-900">
|
||||
<I18nSample />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
import { BaseRemoteDataServices } from '@repo/core-api/data-services';
|
||||
import type { ApiResponse } from '@repo/core-api/http-client';
|
||||
import { apiClient } from '../../../../../../../core/lib/api-client';
|
||||
import type { BookingEntity } from './booking.data-services';
|
||||
import type { BookingDTO } from './booking.transformer';
|
||||
import {
|
||||
AdvancedBookingTransformer,
|
||||
type AvailabilityChartRawData,
|
||||
type AvailabilityChartData,
|
||||
} from './advanced-booking.transformer';
|
||||
|
||||
// ─── Advanced Booking Data Services ─────────────────────────────
|
||||
|
||||
/**
|
||||
* Extended booking data services with custom methods for
|
||||
* advanced booking features beyond standard CRUD.
|
||||
*
|
||||
* Extends {@link BaseRemoteDataServices} directly (instead of using
|
||||
* `CommonRemoteDataServices`) to add domain-specific methods like
|
||||
* `getAvailabilityChart()`.
|
||||
*
|
||||
* Uses {@link AdvancedBookingTransformer} which provides:
|
||||
* - All standard DTO ↔ Entity mappings (inherited from BookingTransformer)
|
||||
* - Custom `transformAvailabilityChart()` for chart data
|
||||
* - Enhanced `transformGetManyResponse()` with status normalization
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Standard CRUD (inherited, with transformer)
|
||||
* const { data: bookings } = await advancedBookingServices.getMany();
|
||||
* const { data: booking } = await advancedBookingServices.getOne('42');
|
||||
*
|
||||
* // Custom method for chart data
|
||||
* const { data: chartData } = await advancedBookingServices.getAvailabilityChart({
|
||||
* startDate: '2026-07-01',
|
||||
* endDate: '2026-07-31',
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
class AdvancedBookingDataServices extends BaseRemoteDataServices<BookingEntity, BookingDTO> {
|
||||
/**
|
||||
* The concrete advanced transformer instance.
|
||||
*
|
||||
* Stored separately from the base `transformer` property
|
||||
* to access custom methods (like `transformAvailabilityChart`)
|
||||
* that aren't part of the `IDataTransformer` interface.
|
||||
*/
|
||||
private readonly advancedTransformer: AdvancedBookingTransformer;
|
||||
|
||||
constructor() {
|
||||
const advancedTransformer = new AdvancedBookingTransformer();
|
||||
|
||||
super(apiClient, {
|
||||
apiUrl: '/bookings',
|
||||
moduleKey: 'BOOKING',
|
||||
transformer: advancedTransformer,
|
||||
});
|
||||
|
||||
this.advancedTransformer = advancedTransformer;
|
||||
}
|
||||
|
||||
// ─── Custom Methods ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fetch the availability chart data for a given date range.
|
||||
*
|
||||
* Calls the `/bookings/availability-chart` endpoint and transforms
|
||||
* the raw API response into a UI-friendly chart format using
|
||||
* {@link AdvancedBookingTransformer.transformAvailabilityChart}.
|
||||
*
|
||||
* @param params - Date range parameters for the chart query
|
||||
* @param params.startDate - Start date (ISO format, e.g., '2026-07-01')
|
||||
* @param params.endDate - End date (ISO format, e.g., '2026-07-31')
|
||||
* @returns Transformed chart data ready for UI rendering
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const { data } = await advancedBookingServices.getAvailabilityChart({
|
||||
* startDate: '2026-07-01',
|
||||
* endDate: '2026-07-31',
|
||||
* });
|
||||
*
|
||||
* // data.dataPoints → Array of chart-ready data points
|
||||
* // data.summary → Aggregated metrics for the period
|
||||
* ```
|
||||
*/
|
||||
async getAvailabilityChart(params: {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
}): Promise<ApiResponse<AvailabilityChartData>> {
|
||||
const response = await this.customRequest<AvailabilityChartRawData>({
|
||||
url: '/bookings/availability-chart',
|
||||
method: 'GET',
|
||||
params: {
|
||||
start_date: params.startDate,
|
||||
end_date: params.endDate,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
data: this.advancedTransformer.transformAvailabilityChart(response.data),
|
||||
status: response.status,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Singleton Export ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Pre-configured advanced booking data services instance.
|
||||
*
|
||||
* Use this when you need both standard CRUD operations and
|
||||
* custom methods like `getAvailabilityChart()`.
|
||||
*
|
||||
* For standard CRUD-only usage, prefer `bookingServices` from
|
||||
* `booking.data-services.ts` instead.
|
||||
*/
|
||||
export const advancedBookingServices = new AdvancedBookingDataServices();
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
import { BookingTransformer } from './booking.transformer';
|
||||
import type { BookingDTO } from './booking.transformer';
|
||||
import type { BookingEntity } from './booking.data-services';
|
||||
|
||||
// ─── Advanced Types ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Raw availability chart data as returned by the API.
|
||||
*
|
||||
* The backend returns a flat structure with snake_case keys
|
||||
* and ISO date strings. This needs to be transformed into
|
||||
* a more UI-friendly shape for chart rendering.
|
||||
*/
|
||||
export interface AvailabilityChartRawData {
|
||||
dates: Array<{
|
||||
date_iso: string;
|
||||
available_rooms: number;
|
||||
total_rooms: number;
|
||||
occupancy_rate: number;
|
||||
revenue_per_room: number;
|
||||
}>;
|
||||
summary: {
|
||||
avg_occupancy_rate: number;
|
||||
total_revenue: number;
|
||||
period_start: string;
|
||||
period_end: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* UI-friendly availability chart data.
|
||||
*
|
||||
* Pre-computed for direct rendering in chart components
|
||||
* with camelCase fields, formatted labels, and derived metrics.
|
||||
*/
|
||||
export interface AvailabilityChartData {
|
||||
/** Data points ready for chart rendering. */
|
||||
dataPoints: Array<{
|
||||
/** Formatted date label (e.g., 'Mon, Jul 1'). */
|
||||
label: string;
|
||||
/** ISO date string for programmatic use. */
|
||||
dateISO: string;
|
||||
/** Number of rooms available. */
|
||||
availableRooms: number;
|
||||
/** Total room capacity. */
|
||||
totalRooms: number;
|
||||
/** Occupancy rate as a percentage (0-100). */
|
||||
occupancyRate: number;
|
||||
/** Revenue per available room. */
|
||||
revenuePerRoom: number;
|
||||
/** Whether the day is a high-demand day (>80% occupancy). */
|
||||
isHighDemand: boolean;
|
||||
}>;
|
||||
/** Aggregated summary metrics for the period. */
|
||||
summary: {
|
||||
averageOccupancy: number;
|
||||
totalRevenue: number;
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
/** Number of high-demand days in the period. */
|
||||
highDemandDays: number;
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Advanced Booking Transformer ───────────────────────────────
|
||||
|
||||
/**
|
||||
* Extended booking transformer with additional custom methods
|
||||
* for non-CRUD data transformations.
|
||||
*
|
||||
* Inherits all standard DTO ↔ Entity mapping from
|
||||
* {@link BookingTransformer} and adds domain-specific
|
||||
* transformations for advanced features like availability charts.
|
||||
*
|
||||
* **When to extend vs. create new:**
|
||||
* - Extend when the new transformer shares the same entity/DTO pair
|
||||
* and you need additional transformation methods
|
||||
* - Create a new transformer when the entity/DTO types are different
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const transformer = new AdvancedBookingTransformer();
|
||||
*
|
||||
* // Standard CRUD mapping (inherited)
|
||||
* const entity = transformer.transformToEntity(bookingDTO);
|
||||
*
|
||||
* // Custom chart transformation (new)
|
||||
* const chartData = transformer.transformAvailabilityChart(rawChartData);
|
||||
* ```
|
||||
*/
|
||||
export class AdvancedBookingTransformer extends BookingTransformer {
|
||||
/**
|
||||
* Transform raw availability chart data from the API into a
|
||||
* UI-friendly format for chart rendering.
|
||||
*
|
||||
* Performs the following transformations:
|
||||
* 1. Maps snake_case fields to camelCase
|
||||
* 2. Formats date strings into human-readable labels
|
||||
* 3. Computes derived `isHighDemand` flag (>80% occupancy)
|
||||
* 4. Aggregates `highDemandDays` count in the summary
|
||||
*
|
||||
* @param rawData - Raw chart data from the `/bookings/availability-chart` endpoint
|
||||
* @returns Transformed chart data ready for UI rendering
|
||||
*/
|
||||
transformAvailabilityChart(rawData: AvailabilityChartRawData): AvailabilityChartData {
|
||||
const HIGH_DEMAND_THRESHOLD = 80;
|
||||
|
||||
const dataPoints = rawData.dates.map((item) => {
|
||||
const date = new Date(item.date_iso);
|
||||
const isHighDemand = item.occupancy_rate > HIGH_DEMAND_THRESHOLD;
|
||||
|
||||
return {
|
||||
label: date.toLocaleDateString('en-US', {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}),
|
||||
dateISO: item.date_iso,
|
||||
availableRooms: item.available_rooms,
|
||||
totalRooms: item.total_rooms,
|
||||
occupancyRate: item.occupancy_rate,
|
||||
revenuePerRoom: item.revenue_per_room,
|
||||
isHighDemand,
|
||||
};
|
||||
});
|
||||
|
||||
const highDemandDays = dataPoints.filter((dp) => dp.isHighDemand).length;
|
||||
|
||||
return {
|
||||
dataPoints,
|
||||
summary: {
|
||||
averageOccupancy: rawData.summary.avg_occupancy_rate,
|
||||
totalRevenue: rawData.summary.total_revenue,
|
||||
periodStart: rawData.summary.period_start,
|
||||
periodEnd: rawData.summary.period_end,
|
||||
highDemandDays,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced getMany response that also normalizes status values.
|
||||
*
|
||||
* Demonstrates overriding an inherited hook to add
|
||||
* additional processing on top of the base transformation.
|
||||
*
|
||||
* @param dtos - Array of booking DTOs from the API
|
||||
* @returns Transformed entities with normalized status
|
||||
*/
|
||||
override transformGetManyResponse(dtos: BookingDTO[]): BookingEntity[] {
|
||||
return super.transformGetManyResponse(dtos).map((entity) => ({
|
||||
...entity,
|
||||
// Normalize 'cancelled' vs 'canceled' from different API versions
|
||||
status: entity.status === ('canceled' as BookingEntity['status'])
|
||||
? 'cancelled'
|
||||
: entity.status,
|
||||
}));
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
import { CommonRemoteDataServices } from '@repo/core-api/data-services';
|
||||
import type { BaseEntity } from '@repo/core-api/data-services';
|
||||
import { apiClient } from '../../../../../../../core/lib/api-client';
|
||||
import { BookingTransformer } from './booking.transformer';
|
||||
import type { BookingDTO } from './booking.transformer';
|
||||
|
||||
// ─── Domain Entity ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Booking domain entity.
|
||||
*
|
||||
* In a real module, this would be defined in the domain layer
|
||||
* (e.g., `features/booking/domain/entities.ts`) and imported here.
|
||||
*/
|
||||
export interface BookingEntity extends BaseEntity {
|
||||
bookingCode: string;
|
||||
customerName: string;
|
||||
checkInDate: string;
|
||||
checkOutDate: string;
|
||||
status: 'pending' | 'confirmed' | 'cancelled';
|
||||
totalAmount: number;
|
||||
}
|
||||
|
||||
// ─── Data Services Instance ─────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Booking data services — wired to the enterprise `apiClient`
|
||||
* with automatic DTO ↔ Entity transformation.
|
||||
*
|
||||
* All requests flow through the full interceptor chain:
|
||||
* Faro tracing → Bearer token injection → ApiError normalization.
|
||||
*
|
||||
* The injected {@link BookingTransformer} automatically:
|
||||
* - Maps snake_case API responses to camelCase entities on `getOne`/`getMany`
|
||||
* - Maps camelCase entity payloads to snake_case DTOs on `create`/`edit`
|
||||
* - Strips `id` from create payloads
|
||||
* - Computes `durationNights` on `getOne` responses
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const { data } = await bookingServices.getMany({ params: { page: 1 } });
|
||||
* // data is BookingEntity[] with camelCase fields
|
||||
*
|
||||
* const { data: booking } = await bookingServices.getOne('42');
|
||||
* // booking is BookingEntity with computed durationNights
|
||||
*
|
||||
* await bookingServices.create({ bookingCode: 'BK001', customerName: 'Alice', ... });
|
||||
* // Payload is automatically transformed to { booking_code: 'BK001', customer_name: 'Alice', ... }
|
||||
*
|
||||
* await bookingServices.confirmProcessTransaction('42');
|
||||
* ```
|
||||
*/
|
||||
export const bookingServices = new CommonRemoteDataServices<BookingEntity, BookingDTO>(apiClient, {
|
||||
apiUrl: '/bookings',
|
||||
moduleKey: 'BOOKING',
|
||||
transformer: new BookingTransformer(),
|
||||
});
|
||||
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
import { BaseDataTransformer } from '@repo/core-api/data-services';
|
||||
import type { BookingEntity } from './booking.data-services';
|
||||
|
||||
// ─── Booking DTO (API Response Shape) ───────────────────────────
|
||||
|
||||
/**
|
||||
* Raw booking data as returned by the API.
|
||||
*
|
||||
* Uses snake_case field names matching the backend's JSON serialization.
|
||||
* This DTO is never used directly in UI components — it is transformed
|
||||
* into a {@link BookingEntity} by the {@link BookingTransformer}.
|
||||
*/
|
||||
export interface BookingDTO {
|
||||
id?: string;
|
||||
booking_code: string;
|
||||
customer_name: string;
|
||||
check_in_date: string;
|
||||
check_out_date: string;
|
||||
status: 'pending' | 'confirmed' | 'cancelled';
|
||||
total_amount: number;
|
||||
}
|
||||
|
||||
// ─── Booking Transformer ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Transforms between the API's `BookingDTO` (snake_case) and
|
||||
* the frontend's `BookingEntity` (camelCase).
|
||||
*
|
||||
* Handles:
|
||||
* - Field name mapping (snake_case ↔ camelCase)
|
||||
* - Computed field derivation (e.g., `durationNights` on `getOne`)
|
||||
* - Payload sanitization (e.g., stripping `id` on create)
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const transformer = new BookingTransformer();
|
||||
*
|
||||
* // API response → Domain entity
|
||||
* const entity = transformer.transformToEntity({
|
||||
* id: '42',
|
||||
* booking_code: 'BK042',
|
||||
* customer_name: 'Alice',
|
||||
* check_in_date: '2026-07-01',
|
||||
* check_out_date: '2026-07-03',
|
||||
* status: 'confirmed',
|
||||
* total_amount: 500000,
|
||||
* });
|
||||
* // → { id: '42', bookingCode: 'BK042', customerName: 'Alice', ... }
|
||||
* ```
|
||||
*/
|
||||
export class BookingTransformer extends BaseDataTransformer<BookingEntity, BookingDTO> {
|
||||
/**
|
||||
* Map an API booking DTO to a frontend booking entity.
|
||||
*
|
||||
* @param dto - Raw booking data from the API
|
||||
* @returns Mapped booking entity with camelCase fields
|
||||
*/
|
||||
override transformToEntity(dto: BookingDTO): BookingEntity {
|
||||
return {
|
||||
id: dto.id,
|
||||
bookingCode: dto.booking_code,
|
||||
customerName: dto.customer_name,
|
||||
checkInDate: dto.check_in_date,
|
||||
checkOutDate: dto.check_out_date,
|
||||
status: dto.status,
|
||||
totalAmount: dto.total_amount,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a frontend booking entity to an API booking DTO.
|
||||
*
|
||||
* @param entity - Booking entity from the frontend
|
||||
* @returns Mapped booking DTO with snake_case fields
|
||||
*/
|
||||
override transformToDTO(entity: BookingEntity): BookingDTO {
|
||||
return {
|
||||
id: entity.id as string,
|
||||
booking_code: entity.bookingCode,
|
||||
customer_name: entity.customerName,
|
||||
check_in_date: entity.checkInDate,
|
||||
check_out_date: entity.checkOutDate,
|
||||
status: entity.status,
|
||||
total_amount: entity.totalAmount,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform a single booking response with computed fields.
|
||||
*
|
||||
* Adds `durationNights` as a derived convenience field
|
||||
* that is only relevant when viewing a single booking detail.
|
||||
*
|
||||
* @param dto - Raw booking DTO from the API
|
||||
* @returns Booking entity with computed fields
|
||||
*/
|
||||
override transformGetOneResponse(dto: BookingDTO): BookingEntity {
|
||||
const entity = this.transformToEntity(dto);
|
||||
const checkIn = new Date(dto.check_in_date);
|
||||
const checkOut = new Date(dto.check_out_date);
|
||||
const durationMs = checkOut.getTime() - checkIn.getTime();
|
||||
const durationNights = Math.max(0, Math.ceil(durationMs / (1000 * 60 * 60 * 24)));
|
||||
|
||||
return {
|
||||
...entity,
|
||||
// Attach computed field via type assertion since
|
||||
// durationNights is a view-layer convenience
|
||||
...(durationNights > 0 ? { durationNights } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip `id` from create payloads since the backend generates IDs.
|
||||
*
|
||||
* @param entity - Partial booking entity from the create form
|
||||
* @returns Sanitized DTO payload without `id`
|
||||
*/
|
||||
override transformCreatePayload(entity: Partial<BookingEntity>): Partial<BookingDTO> {
|
||||
const dto = this.transformToDTO(entity as BookingEntity);
|
||||
const { id: _, ...rest } = dto;
|
||||
return rest;
|
||||
}
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
import { useState } from 'react';
|
||||
import { bookingServices } from '../data/booking.data-services';
|
||||
import type { BookingEntity } from '../data/booking.data-services';
|
||||
import type { ApiResponse } from '@repo/core-api/http-client';
|
||||
import { ApiError } from '@repo/core-api/errors';
|
||||
|
||||
/**
|
||||
* Sample component demonstrating `@repo/core-api` integration
|
||||
* with the advanced TelemetryContext escape hatch.
|
||||
*
|
||||
* Pipeline: Faro auto-instrumentation → Bearer token → GET /bookings
|
||||
* + Custom span "booking.list.fetch" with enriched tags
|
||||
*/
|
||||
export default function BookingSample() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [result, setResult] = useState<ApiResponse<BookingEntity[]> | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleFetch = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
|
||||
try {
|
||||
const response = await bookingServices.getMany<BookingEntity[]>({
|
||||
params: { page: 1, limit: 20 },
|
||||
// ── Telemetry Escape Hatch ──────────────────────────────
|
||||
// This creates a custom OTel span named "booking.list.fetch",
|
||||
// attaches business tags, and pushes a Faro event on success.
|
||||
telemetryContext: {
|
||||
customSpanName: 'booking.list.fetch',
|
||||
tags: {
|
||||
'feature': 'booking',
|
||||
'ui.component': 'BookingSample',
|
||||
'ui.action': 'list_fetch',
|
||||
'page': 1,
|
||||
},
|
||||
pushEventOnSuccess: 'booking_list_loaded',
|
||||
},
|
||||
});
|
||||
setResult(response);
|
||||
console.log('[BookingSample] Response:', response);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(`[${err.code}] ${err.message} (HTTP ${err.status})`);
|
||||
console.error('[BookingSample] ApiError:', err.toJSON());
|
||||
} else {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24, fontFamily: 'monospace' }}>
|
||||
<h2>🧪 Booking Data Services — Integration Test</h2>
|
||||
<p style={{ color: '#888', fontSize: 14 }}>
|
||||
Pipeline: Faro + Custom Span "booking.list.fetch" → Bearer Token → GET /bookings
|
||||
</p>
|
||||
|
||||
<button
|
||||
onClick={handleFetch}
|
||||
disabled={loading}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
fontSize: 16,
|
||||
cursor: loading ? 'wait' : 'pointer',
|
||||
background: loading ? '#555' : '#4f46e5',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: 6,
|
||||
marginTop: 12,
|
||||
}}
|
||||
>
|
||||
{loading ? 'Fetching…' : 'Test Fetch Bookings'}
|
||||
</button>
|
||||
|
||||
{error && (
|
||||
<pre style={{ color: '#ef4444', marginTop: 16, whiteSpace: 'pre-wrap' }}>
|
||||
❌ {error}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<pre style={{ marginTop: 16, background: '#1e1e2e', color: '#a6e3a1', padding: 16, borderRadius: 8, overflow: 'auto' }}>
|
||||
{JSON.stringify(result, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"module_name": "Purchasing",
|
||||
"select_date": "Select Date",
|
||||
"header": {
|
||||
"title": "Transaction List",
|
||||
"subtitle": "Manage all your transactions here"
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"module_name": "Pembelanjaan",
|
||||
"select_date": "Pilih Tanggal",
|
||||
"header": {
|
||||
"title": "Daftar Transaksi",
|
||||
"subtitle": "Kelola semua transaksi Anda di sini"
|
||||
}
|
||||
}
|
||||
+347
@@ -0,0 +1,347 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslation, changeLanguage, applyTenantOverrides, i18n } from '@repo/core-i18n';
|
||||
import { secureIndexedDB, AppStorageKey } from '../../../../../../../core/storage/local';
|
||||
|
||||
// Decentralized languages imports
|
||||
import bookingId from '../languages/id/booking.json';
|
||||
import bookingEn from '../languages/en/booking.json';
|
||||
|
||||
// ─── Shared Styles ──────────────────────────────────────────────
|
||||
|
||||
const sectionStyle = {
|
||||
marginTop: 24,
|
||||
padding: 24,
|
||||
border: '1px solid #334155',
|
||||
borderRadius: 8,
|
||||
background: '#0f172a',
|
||||
};
|
||||
|
||||
const btnStyle = (color: string, isActive: boolean = false) => ({
|
||||
padding: '8px 16px',
|
||||
fontSize: 14,
|
||||
fontWeight: isActive ? 700 : 600,
|
||||
cursor: 'pointer' as const,
|
||||
background: color,
|
||||
color: '#fff',
|
||||
border: isActive ? '2px solid #fff' : '2px solid transparent',
|
||||
borderRadius: 6,
|
||||
marginRight: 8,
|
||||
});
|
||||
|
||||
// ─── Module-Level Flag ──────────────────────────────────────────
|
||||
// Bendera penanda statis agar kamus hanya dimuat satu kali
|
||||
let isBookingDictLoaded = false;
|
||||
|
||||
// ─── Component ──────────────────────────────────────────────────
|
||||
|
||||
export default function I18nSample() {
|
||||
// 1. Eksekusi SINKRONUS tepat sebelum render pertama
|
||||
if (!isBookingDictLoaded) {
|
||||
i18n.addResourceBundle('id', 'booking', bookingId, true, false);
|
||||
i18n.addResourceBundle('en', 'booking', bookingEn, true, false);
|
||||
isBookingDictLoaded = true;
|
||||
}
|
||||
|
||||
// 2. Sekarang useTranslation dijamin mendapat kamus yang sudah terisi penuh
|
||||
const { t } = useTranslation(['common', 'booking']);
|
||||
|
||||
// State untuk melacak bahasa aktif secara real-time
|
||||
const [activeLang, setActiveLang] = useState(i18n.language);
|
||||
const [syncStatus, setSyncStatus] = useState<string>('');
|
||||
const [activeTenant, setActiveTenant] = useState<string>('default');
|
||||
const [isFetchingConfig, setIsFetchingConfig] = useState(false);
|
||||
|
||||
// Dengarkan perubahan bahasa dari engine
|
||||
useEffect(() => {
|
||||
const handleLangChange = (lng: string) => setActiveLang(lng);
|
||||
i18n.on('languageChanged', handleLangChange);
|
||||
return () => {
|
||||
i18n.off('languageChanged', handleLangChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ─── Admin Panel State ──────────────────────────────────────────
|
||||
const [adminModuleName, setAdminModuleName] = useState('PENGELUARAN');
|
||||
const [adminHeaderTitle, setAdminHeaderTitle] = useState('Daftar Pengeluaran');
|
||||
const [dbPayloadStr, setDbPayloadStr] = useState<string>('No data in DB');
|
||||
|
||||
const MOCK_DB_KEY = AppStorageKey.MOCK_DB_COMPANY_A;
|
||||
|
||||
const loadDbPayload = useCallback(async () => {
|
||||
try {
|
||||
const data = await secureIndexedDB.getItem<any>(MOCK_DB_KEY);
|
||||
setDbPayloadStr(data ? JSON.stringify(data, null, 2) : 'No data in DB');
|
||||
setAdminHeaderTitle(data?.overrides?.header?.title || 'Daftar Pengeluaran');
|
||||
setAdminModuleName(data?.overrides?.module_name || 'PENGELUARAN');
|
||||
} catch (e) {
|
||||
setDbPayloadStr('Error reading DB');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadDbPayload();
|
||||
}, [loadDbPayload]);
|
||||
|
||||
const handleAdminSave = async () => {
|
||||
const payload = {
|
||||
namespace: 'booking',
|
||||
overrides: {
|
||||
module_name: adminModuleName,
|
||||
header: { title: adminHeaderTitle },
|
||||
},
|
||||
};
|
||||
await secureIndexedDB.setItem(MOCK_DB_KEY, payload);
|
||||
setSyncStatus('✅ Saved tenant config to IndexedDB!');
|
||||
await loadDbPayload();
|
||||
};
|
||||
|
||||
// ─── Mock API ───────────────────────────────────────────────────
|
||||
const mockFetchTenantConfig = async (companyId: string): Promise<any> => {
|
||||
if (companyId === 'company-a') {
|
||||
const data = await secureIndexedDB.getItem<any>(MOCK_DB_KEY);
|
||||
if (!data) {
|
||||
throw new Error('Company A config not found in DB. Please save via Admin Panel first.');
|
||||
}
|
||||
return data;
|
||||
} else if (companyId === 'company-b') {
|
||||
return {
|
||||
namespace: 'booking',
|
||||
overrides: {
|
||||
module_name: 'PROCUREMENT (B)',
|
||||
header: { title: 'Procurement List (B)' },
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error('Unknown company');
|
||||
};
|
||||
|
||||
// ─── Section A: Language Switcher ──────────────────────────────
|
||||
|
||||
const handleLanguageChange = async (newLng: string, shouldFail: boolean = false) => {
|
||||
setSyncStatus('Syncing with backend...');
|
||||
|
||||
try {
|
||||
await changeLanguage(newLng, async (lng, _prevLng) => {
|
||||
await new Promise((resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
if (shouldFail) {
|
||||
reject(new Error('Mock API 500: Failed to save preference'));
|
||||
} else {
|
||||
resolve(true);
|
||||
}
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
setSyncStatus(`✅ Successfully synced language '${lng}' to backend.`);
|
||||
});
|
||||
} catch (error) {
|
||||
setSyncStatus(`❌ Rollback triggered: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Section B: Tenant Overrides (Real-World Flow) ─────────────
|
||||
|
||||
const handleSimulateLogin = async (companyId: string) => {
|
||||
setIsFetchingConfig(true);
|
||||
setActiveTenant(companyId);
|
||||
|
||||
try {
|
||||
const config = await mockFetchTenantConfig(companyId);
|
||||
applyTenantOverrides(config.namespace, config.overrides, 'id');
|
||||
applyTenantOverrides(config.namespace, config.overrides, 'en');
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch config', err);
|
||||
} finally {
|
||||
setIsFetchingConfig(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetTenant = () => {
|
||||
i18n.addResourceBundle('id', 'booking', bookingId, true, true);
|
||||
i18n.addResourceBundle('en', 'booking', bookingEn, true, true);
|
||||
setActiveTenant('default');
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ fontFamily: 'sans-serif', color: '#f8fafc' }}>
|
||||
<h2 style={{ fontSize: 24, fontWeight: 'bold' }}>🌐 Enterprise i18n Demo</h2>
|
||||
<p style={{ color: '#94a3b8' }}>
|
||||
Current Active Language: <strong style={{ color: '#38bdf8' }}>{activeLang}</strong>
|
||||
</p>
|
||||
|
||||
{/* ─── Admin Panel ──────────────────────────────────────────── */}
|
||||
<div style={sectionStyle}>
|
||||
<h3 style={{ fontSize: 18, marginBottom: 16, color: '#fbbf24' }}>Admin Panel (Company A Config)</h3>
|
||||
<p style={{ fontSize: 14, color: '#94a3b8', marginBottom: 16 }}>
|
||||
Simulate a backend CMS. Save the vocabulary overrides to IndexedDB.
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginBottom: 16 }}>
|
||||
<label style={{ fontSize: 14 }}>
|
||||
<span style={{ display: 'inline-block', width: 120 }}>Module Name:</span>
|
||||
<input
|
||||
type="text"
|
||||
value={adminModuleName}
|
||||
onChange={(e) => setAdminModuleName(e.target.value)}
|
||||
style={{
|
||||
padding: 6,
|
||||
borderRadius: 4,
|
||||
background: '#1e293b',
|
||||
border: '1px solid #475569',
|
||||
color: '#fff',
|
||||
width: 250,
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ fontSize: 14 }}>
|
||||
<span style={{ display: 'inline-block', width: 120 }}>Header Title:</span>
|
||||
<input
|
||||
type="text"
|
||||
value={adminHeaderTitle}
|
||||
onChange={(e) => setAdminHeaderTitle(e.target.value)}
|
||||
style={{
|
||||
padding: 6,
|
||||
borderRadius: 4,
|
||||
background: '#1e293b',
|
||||
border: '1px solid #475569',
|
||||
color: '#fff',
|
||||
width: 250,
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button onClick={handleAdminSave} style={btnStyle('#d97706')}>
|
||||
Save to Database (IndexedDB)
|
||||
</button>
|
||||
|
||||
<div style={{ marginTop: 16, padding: 12, background: '#1e293b', borderRadius: 6 }}>
|
||||
<div style={{ fontSize: 12, color: '#94a3b8', marginBottom: 4 }}>Raw JSON in DB:</div>
|
||||
<pre style={{ margin: 0, fontSize: 12, color: '#a7f3d0' }}>
|
||||
<code>{dbPayloadStr}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── Section A ────────────────────────────────────────────── */}
|
||||
<div style={sectionStyle}>
|
||||
<h3 style={{ fontSize: 18, marginBottom: 16 }}>A. Language Switcher & Backend Sync</h3>
|
||||
<p style={{ fontSize: 14, color: '#94a3b8', marginBottom: 16 }}>
|
||||
Change the language. The callback simulates a 1-second backend API request.
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<button
|
||||
onClick={() => handleLanguageChange('id')}
|
||||
style={btnStyle(activeLang === 'id' ? '#1d4ed8' : '#0ea5e9', activeLang === 'id')}
|
||||
>
|
||||
ID (Lokal & Sync)
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleLanguageChange('en')}
|
||||
style={btnStyle(activeLang === 'en' ? '#1d4ed8' : '#0ea5e9', activeLang === 'en')}
|
||||
>
|
||||
EN (Lokal & Sync)
|
||||
</button>
|
||||
<button onClick={() => handleLanguageChange('en', true)} style={btnStyle('#dc2626')}>
|
||||
Force Error (Test Rollback)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{syncStatus && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 16,
|
||||
padding: 12,
|
||||
background: '#1e293b',
|
||||
borderRadius: 6,
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
{syncStatus}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* UI Result untuk Section A */}
|
||||
<div style={{ background: '#1e293b', padding: 16, borderRadius: 8, marginTop: 16 }}>
|
||||
<h4 style={{ color: '#cbd5e1', marginBottom: 12 }}>UI Result (Live Dictionary):</h4>
|
||||
<p style={{ margin: '4px 0', display: 'flex', alignItems: 'center' }}>
|
||||
<code style={{ color: '#94a3b8', width: 180, display: 'inline-block' }}>common:save</code>
|
||||
<strong style={{ fontSize: 16, color: '#10b981' }}>{t('common:save')}</strong>
|
||||
</p>
|
||||
<p style={{ margin: '4px 0', display: 'flex', alignItems: 'center' }}>
|
||||
<code style={{ color: '#94a3b8', width: 180, display: 'inline-block' }}>booking:select_date</code>
|
||||
<strong style={{ fontSize: 16, color: '#10b981' }}>{t('booking:select_date')}</strong>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── Section B ────────────────────────────────────────────── */}
|
||||
<div style={sectionStyle}>
|
||||
<h3 style={{ fontSize: 18, marginBottom: 16 }}>B. Dynamic Tenant Overrides (End-to-End)</h3>
|
||||
<p style={{ fontSize: 14, color: '#94a3b8', marginBottom: 16 }}>
|
||||
Simulates a user logging in. It fetches the config directly from IndexedDB (mock database) and applies the
|
||||
deep-merge override.
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 24 }}>
|
||||
<button
|
||||
onClick={resetTenant}
|
||||
style={btnStyle(activeTenant === 'default' ? '#16a34a' : '#475569', activeTenant === 'default')}
|
||||
>
|
||||
Default Company
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSimulateLogin('company-a')}
|
||||
style={btnStyle(activeTenant === 'company-a' ? '#16a34a' : '#475569', activeTenant === 'company-a')}
|
||||
disabled={isFetchingConfig}
|
||||
>
|
||||
Simulate Login as Company A
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSimulateLogin('company-b')}
|
||||
style={btnStyle(activeTenant === 'company-b' ? '#16a34a' : '#475569', activeTenant === 'company-b')}
|
||||
disabled={isFetchingConfig}
|
||||
>
|
||||
Simulate Login as Company B
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isFetchingConfig && (
|
||||
<div style={{ marginBottom: 16, color: '#fbbf24', fontSize: 14 }}>⏳ Fetching tenant config...</div>
|
||||
)}
|
||||
|
||||
{/* Display the localized strings */}
|
||||
<div style={{ background: '#1e293b', padding: 16, borderRadius: 8 }}>
|
||||
<h4 style={{ color: '#cbd5e1', marginBottom: 12 }}>UI Result (Tenant Overlay):</h4>
|
||||
<table style={{ width: '100%', textAlign: 'left', borderCollapse: 'collapse' }}>
|
||||
<tbody>
|
||||
<tr style={{ borderBottom: '1px solid #334155' }}>
|
||||
<th style={{ padding: 8, color: '#94a3b8' }}>Key</th>
|
||||
<th style={{ padding: 8, color: '#94a3b8' }}>Value</th>
|
||||
</tr>
|
||||
<tr style={{ borderBottom: '1px solid #334155' }}>
|
||||
<td style={{ padding: 8 }}>
|
||||
<code>booking:module_name</code>
|
||||
</td>
|
||||
<td style={{ padding: 8, fontWeight: 'bold' }}>{t('booking:module_name')}</td>
|
||||
</tr>
|
||||
<tr style={{ borderBottom: '1px solid #334155' }}>
|
||||
<td style={{ padding: 8 }}>
|
||||
<code>booking:header.title</code>
|
||||
</td>
|
||||
<td style={{ padding: 8, fontWeight: 'bold' }}>{t('booking:header.title')}</td>
|
||||
</tr>
|
||||
<tr style={{ borderBottom: '1px solid #334155' }}>
|
||||
<td style={{ padding: 8 }}>
|
||||
<code>booking:header.subtitle</code>
|
||||
</td>
|
||||
<td style={{ padding: 8, fontWeight: 'bold' }}>{t('booking:header.subtitle')}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+280
@@ -0,0 +1,280 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { secureStorage, secureIndexedDB, AppStorageKey } from '../../../../../../../core/storage/local';
|
||||
|
||||
// ─── Demo Data ──────────────────────────────────────────────────
|
||||
|
||||
interface DemoUser {
|
||||
id: string;
|
||||
name: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
interface DemoDraft {
|
||||
id: number;
|
||||
type: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
const DEMO_USER: DemoUser = {
|
||||
id: 'u-123',
|
||||
name: 'Firman Ramdhani',
|
||||
role: 'admin',
|
||||
};
|
||||
|
||||
const DEMO_DRAFT: DemoDraft = { id: 101, type: 'offline_draft', content: 'Draft data saved offline' };
|
||||
|
||||
const LS_KEY = AppStorageKey.USER_PROFILE; // Encrypted at rest (in ENCRYPTED_KEYS)
|
||||
const IDB_KEY = AppStorageKey.OFFLINE_DRAFT; // Plain key for IndexedDB demo
|
||||
|
||||
// ─── Shared Styles ──────────────────────────────────────────────
|
||||
|
||||
const btnStyle = (color: string) => ({
|
||||
padding: '8px 16px',
|
||||
fontSize: 14,
|
||||
fontWeight: 600 as const,
|
||||
cursor: 'pointer' as const,
|
||||
background: color,
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: 6,
|
||||
});
|
||||
|
||||
const preStyle = {
|
||||
marginTop: 16,
|
||||
background: '#1e1e2e',
|
||||
color: '#a6e3a1',
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
minHeight: 60,
|
||||
overflow: 'auto' as const,
|
||||
fontSize: 13,
|
||||
};
|
||||
|
||||
const logContainerStyle = {
|
||||
background: '#0f0f17',
|
||||
color: '#94a3b8',
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
maxHeight: 200,
|
||||
overflow: 'auto' as const,
|
||||
fontSize: 12,
|
||||
};
|
||||
|
||||
// ─── Reusable CRUD Button Row ───────────────────────────────────
|
||||
|
||||
interface CRUDAction {
|
||||
label: string;
|
||||
handler: () => void;
|
||||
color: string;
|
||||
}
|
||||
|
||||
function CRUDButtons({ actions }: { actions: CRUDAction[] }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
{actions.map(({ label, handler, color }) => (
|
||||
<button key={label} onClick={handler} style={btnStyle(color)}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Component ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Interactive demo for `@repo/core-storage`.
|
||||
*
|
||||
* Demonstrates the full CRUD lifecycle for BOTH storage backends:
|
||||
* - **localStorage** (encrypted via AES for sensitive keys)
|
||||
* - **IndexedDB** (Promise-wrapped, suitable for large payloads)
|
||||
*
|
||||
* Open the browser's DevTools:
|
||||
* - **Application → Local Storage** to see AES-encrypted payloads
|
||||
* - **Application → IndexedDB → app_db → kv_store** to see IDB entries
|
||||
*/
|
||||
export default function StorageSample() {
|
||||
const [lsResult, setLsResult] = useState<string>('(no data read yet)');
|
||||
const [idbResult, setIdbResult] = useState<string>('(no data read yet)');
|
||||
const [log, setLog] = useState<string[]>([]);
|
||||
|
||||
const pushLog = useCallback((msg: string) => {
|
||||
setLog((prev) => [...prev, `[${new Date().toLocaleTimeString()}] ${msg}`]);
|
||||
}, []);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// ── localStorage CRUD ─────────────────────────────────────────
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
const lsCreate = useCallback(async () => {
|
||||
await secureStorage.setItem(LS_KEY, DEMO_USER);
|
||||
pushLog(`[LS] CREATE → Stored encrypted: ${JSON.stringify(DEMO_USER)}`);
|
||||
}, [pushLog]);
|
||||
|
||||
const lsRead = useCallback(async () => {
|
||||
const result = await secureStorage.getItem<DemoUser>(LS_KEY);
|
||||
if (result) {
|
||||
setLsResult(JSON.stringify(result, null, 2));
|
||||
pushLog(`[LS] READ → Decrypted: ${JSON.stringify(result)}`);
|
||||
} else {
|
||||
setLsResult('(null — no data found)');
|
||||
pushLog('[LS] READ → null (key does not exist)');
|
||||
}
|
||||
}, [pushLog]);
|
||||
|
||||
const lsUpdate = useCallback(async () => {
|
||||
const existing = await secureStorage.getItem<DemoUser>(LS_KEY);
|
||||
if (!existing) {
|
||||
pushLog('[LS] UPDATE → Failed: key does not exist. Create first.');
|
||||
return;
|
||||
}
|
||||
const updated: DemoUser = { ...existing, role: 'superadmin', id: existing.id + 1 };
|
||||
await secureStorage.setItem(LS_KEY, updated);
|
||||
pushLog(`[LS] UPDATE → Re-encrypted: ${JSON.stringify(updated)}`);
|
||||
}, [pushLog]);
|
||||
|
||||
const lsDelete = useCallback(async () => {
|
||||
await secureStorage.removeItem(LS_KEY);
|
||||
setLsResult('(deleted)');
|
||||
pushLog(`[LS] DELETE → Removed key "${LS_KEY}"`);
|
||||
}, [pushLog]);
|
||||
|
||||
const lsClear = useCallback(async () => {
|
||||
await secureStorage.clear();
|
||||
setLsResult('(cleared)');
|
||||
pushLog('[LS] CLEAR → All localStorage keys removed');
|
||||
}, [pushLog]);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// ── IndexedDB CRUD ────────────────────────────────────────────
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
const idbCreate = useCallback(async () => {
|
||||
try {
|
||||
await secureIndexedDB.setItem(IDB_KEY, DEMO_DRAFT);
|
||||
pushLog(`[IDB] CREATE → Stored: ${JSON.stringify(DEMO_DRAFT)}`);
|
||||
} catch (err) {
|
||||
pushLog(`[IDB] CREATE → ERROR: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}, [pushLog]);
|
||||
|
||||
const idbRead = useCallback(async () => {
|
||||
try {
|
||||
const result = await secureIndexedDB.getItem<DemoDraft>(IDB_KEY);
|
||||
if (result) {
|
||||
setIdbResult(JSON.stringify(result, null, 2));
|
||||
pushLog(`[IDB] READ → Retrieved: ${JSON.stringify(result)}`);
|
||||
} else {
|
||||
setIdbResult('(null — no data found)');
|
||||
pushLog('[IDB] READ → null (key does not exist)');
|
||||
}
|
||||
} catch (err) {
|
||||
pushLog(`[IDB] READ → ERROR: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}, [pushLog]);
|
||||
|
||||
const idbUpdate = useCallback(async () => {
|
||||
try {
|
||||
const existing = await secureIndexedDB.getItem<DemoDraft>(IDB_KEY);
|
||||
if (!existing) {
|
||||
pushLog('[IDB] UPDATE → Failed: key does not exist. Create first.');
|
||||
return;
|
||||
}
|
||||
const updated: DemoDraft = {
|
||||
...existing,
|
||||
id: existing.id + 1,
|
||||
content: `Updated at ${new Date().toLocaleTimeString()}`,
|
||||
};
|
||||
await secureIndexedDB.setItem(IDB_KEY, updated);
|
||||
pushLog(`[IDB] UPDATE → Persisted: ${JSON.stringify(updated)}`);
|
||||
} catch (err) {
|
||||
pushLog(`[IDB] UPDATE → ERROR: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}, [pushLog]);
|
||||
|
||||
const idbDelete = useCallback(async () => {
|
||||
try {
|
||||
await secureIndexedDB.removeItem(IDB_KEY);
|
||||
setIdbResult('(deleted)');
|
||||
pushLog(`[IDB] DELETE → Removed key "${IDB_KEY}"`);
|
||||
} catch (err) {
|
||||
pushLog(`[IDB] DELETE → ERROR: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}, [pushLog]);
|
||||
|
||||
const idbClear = useCallback(async () => {
|
||||
try {
|
||||
await secureIndexedDB.clear();
|
||||
setIdbResult('(cleared)');
|
||||
pushLog('[IDB] CLEAR → All IndexedDB entries removed');
|
||||
} catch (err) {
|
||||
pushLog(`[IDB] CLEAR → ERROR: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}, [pushLog]);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// ── Render ────────────────────────────────────────────────────
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24, fontFamily: 'monospace' }}>
|
||||
<h2>🔐 @repo/core-storage — Dual Backend CRUD Demo</h2>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, marginTop: 16 }}>
|
||||
{/* ── Left: localStorage ─────────────────────────────────── */}
|
||||
<div>
|
||||
<h3 style={{ color: '#22c55e' }}>📦 localStorage (AES Encrypted)</h3>
|
||||
<p style={{ color: '#888', fontSize: 12, marginBottom: 12 }}>
|
||||
Key: <code>{LS_KEY}</code> — stored encrypted at rest
|
||||
<br />
|
||||
Verify: <strong>DevTools → Application → Local Storage</strong>
|
||||
</p>
|
||||
|
||||
<CRUDButtons
|
||||
actions={[
|
||||
{ label: '➕ Create', handler: lsCreate, color: '#22c55e' },
|
||||
{ label: '📖 Read', handler: lsRead, color: '#3b82f6' },
|
||||
{ label: '✏️ Update', handler: lsUpdate, color: '#f59e0b' },
|
||||
{ label: '🗑️ Delete', handler: lsDelete, color: '#ef4444' },
|
||||
{ label: '💣 Clear', handler: lsClear, color: '#6b7280' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<pre style={preStyle}>{lsResult}</pre>
|
||||
</div>
|
||||
|
||||
{/* ── Right: IndexedDB ───────────────────────────────────── */}
|
||||
<div>
|
||||
<h3 style={{ color: '#8b5cf6' }}>🗃️ IndexedDB (app_db / kv_store)</h3>
|
||||
<p style={{ color: '#888', fontSize: 12, marginBottom: 12 }}>
|
||||
Key: <code>{IDB_KEY}</code> — plain JSON (not in ENCRYPTED_KEYS)
|
||||
<br />
|
||||
Verify: <strong>DevTools → Application → IndexedDB → app_db</strong>
|
||||
</p>
|
||||
|
||||
<CRUDButtons
|
||||
actions={[
|
||||
{ label: '➕ Create', handler: idbCreate, color: '#8b5cf6' },
|
||||
{ label: '📖 Read', handler: idbRead, color: '#06b6d4' },
|
||||
{ label: '✏️ Update', handler: idbUpdate, color: '#f59e0b' },
|
||||
{ label: '🗑️ Delete', handler: idbDelete, color: '#ef4444' },
|
||||
{ label: '💣 Clear', handler: idbClear, color: '#6b7280' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<pre style={preStyle}>{idbResult}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Shared Action Log ────────────────────────────────────── */}
|
||||
<h3 style={{ marginTop: 24 }}>📋 Action Log</h3>
|
||||
<div style={logContainerStyle}>
|
||||
{log.length === 0 ? (
|
||||
<span style={{ color: '#475569' }}>(no actions yet)</span>
|
||||
) : (
|
||||
log.map((entry, i) => <div key={i}>{entry}</div>)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Stack, Container, Card, Title, Text } from '@repo/ui/components';
|
||||
import ExamplePage from './components/example/example.page';
|
||||
import EventsDemoPage from './components/events-demo';
|
||||
|
||||
export default function EventsPage() {
|
||||
return (
|
||||
<Container size="xl" m={0} p={0}>
|
||||
<Stack gap="xl">
|
||||
<Card withBorder shadow="sm" radius="md" p="md">
|
||||
<Title order={4} mb="md">Nested Showcase Example</Title>
|
||||
<Text mb="md">This is an example of a nested showcase component.</Text>
|
||||
<ExamplePage />
|
||||
</Card>
|
||||
<Card withBorder shadow="sm" radius="md" p="md">
|
||||
<EventsDemoPage />
|
||||
</Card>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Button, Paper, Title, Group, Stack, Code, Divider, Text, Chip, Radio } from '@repo/ui/components';
|
||||
import {
|
||||
FieldTextInput, FieldPasswordInput, FieldTextarea, FieldNumberInput,
|
||||
FieldJsonInput, FieldPinInput, FieldAutocomplete, FieldSelect,
|
||||
FieldMultiSelect, FieldNativeSelect, FieldTagsInput, FieldCheckbox,
|
||||
FieldRadioGroup, FieldSwitch, FieldChipGroup, FieldSegmentedControl,
|
||||
FieldSlider, FieldRangeSlider, FieldRating, FieldColorInput,
|
||||
FieldColorPicker, FieldFileInput, FieldLocalSelect, FieldAsyncSelect,
|
||||
FieldRichTextEditor
|
||||
} from '@repo/ui/form';
|
||||
import type { LoadOptionsFn } from '@repo/ui/form';
|
||||
import { useFormDemoTranslation } from '../i18n/useFormDemoTranslation';
|
||||
|
||||
const MOCK_POKEMON = Array.from({ length: 100 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
name: `Pokemon ${i + 1}`,
|
||||
}));
|
||||
|
||||
const loadMockPokemonOptions: LoadOptionsFn<any> = async (search, page) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
const filtered = MOCK_POKEMON.filter((p) => p.name.toLowerCase().includes(search.toLowerCase()));
|
||||
const pageSize = 20;
|
||||
const start = (page - 1) * pageSize;
|
||||
const paginated = filtered.slice(start, start + pageSize);
|
||||
return {
|
||||
options: paginated,
|
||||
hasMore: start + pageSize < filtered.length,
|
||||
};
|
||||
};
|
||||
|
||||
const loadRealPokemonOptions: LoadOptionsFn<any> = async (_search, page) => {
|
||||
const limit = 20;
|
||||
const offset = (page - 1) * limit;
|
||||
const res = await fetch(`https://pokeapi.co/api/v2/pokemon?limit=${limit}&offset=${offset}`);
|
||||
const data = await res.json();
|
||||
return {
|
||||
options: data.results.map((p: any, i: number) => ({ id: offset + i + 1, ...p })),
|
||||
hasMore: !!data.next,
|
||||
};
|
||||
};
|
||||
|
||||
const MOCK_VENDORS = [
|
||||
{ id: 'V1', code: 'VN-01', name: 'Vendor One' },
|
||||
{ id: 'V2', code: 'VN-02', name: 'Vendor Two' },
|
||||
{ id: 'V3', code: 'VN-03', name: 'Vendor Three' },
|
||||
];
|
||||
|
||||
const loadMockVendorsOptions: LoadOptionsFn<any> = async (search, _page) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
const filtered = MOCK_VENDORS.filter(v => v.name.toLowerCase().includes(search.toLowerCase()) || v.code.toLowerCase().includes(search.toLowerCase()));
|
||||
return { options: filtered, hasMore: false };
|
||||
};
|
||||
|
||||
export default function AllFieldsDemo() {
|
||||
const t = useFormDemoTranslation();
|
||||
|
||||
const { control, handleSubmit, watch } = useForm<any>({
|
||||
defaultValues: {
|
||||
customerName: '',
|
||||
email: '',
|
||||
password: '',
|
||||
description: '',
|
||||
age: undefined,
|
||||
jsonConfig: '',
|
||||
pin: '',
|
||||
country: '',
|
||||
orderType: '',
|
||||
categories: [],
|
||||
nativeOrderType: '',
|
||||
tags: [],
|
||||
terms: false,
|
||||
priority: '',
|
||||
receiveEmails: false,
|
||||
chipSelection: '',
|
||||
segmentedPriority: 'normal',
|
||||
satisfaction: 5,
|
||||
priceRange: [0, 100],
|
||||
rating: 0,
|
||||
themeColor: '',
|
||||
colorPicker: '#1c7ed6',
|
||||
avatar: null,
|
||||
localSelectEmpty: null,
|
||||
localSelectPrefilled: { id: 'V2', code: 'VN-02', name: 'Vendor Two' },
|
||||
asyncSelectEmpty: null,
|
||||
asyncSelectPrefilled: { id: 999, code: 'ASYNC-99', name: 'Pre-loaded Async Vendor' },
|
||||
localMultiPrefilled: [
|
||||
{ id: 'V1', code: 'VN-01', name: 'Vendor One' },
|
||||
{ id: 'V2', code: 'VN-02', name: 'Vendor Two' }
|
||||
],
|
||||
asyncMultiPrefilled: [
|
||||
{ id: 888, code: 'ASYNC-88', name: 'Ghost Async Vendor 1' },
|
||||
{ id: 999, code: 'ASYNC-99', name: 'Ghost Async Vendor 2' }
|
||||
],
|
||||
richTextEmpty: "",
|
||||
richTextPrefilled: "<h2 style=\"text-align: center\">ERP Release Notes</h2><p>This is a <b>highly important</b> update. Please observe the following:</p><ul><li>System maintenance at <i>midnight</i>.</li><li><u style=\"text-align: justify\">All users must log out.</u></li></ul><p style=\"text-align: justify\">Thank you for your cooperation.</p>",
|
||||
realPokeSelect: null,
|
||||
multiRealPokeSelect: []
|
||||
}
|
||||
});
|
||||
|
||||
const onSubmit = (data: any) => console.log('All Fields Submitted:', data);
|
||||
const data = watch();
|
||||
|
||||
return (
|
||||
<Stack gap="xl">
|
||||
<Paper p="xl" withBorder radius="md">
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack gap="xl">
|
||||
{/* --- Text & Numbers --- */}
|
||||
<div>
|
||||
<Title order={5} mb="sm" c="brand">{t.sections.textAndNumbers}</Title>
|
||||
<Divider mb="md" />
|
||||
<Group grow align="flex-start" mb="md">
|
||||
<FieldTextInput name="customerName" control={control} label={t.fields.customerName} />
|
||||
<FieldTextInput name="email" control={control} label={t.fields.email} />
|
||||
</Group>
|
||||
<Group grow align="flex-start" mb="md">
|
||||
<FieldPasswordInput name="password" control={control} label={t.fields.password} />
|
||||
<FieldNumberInput name="age" control={control} label={t.fields.age} />
|
||||
</Group>
|
||||
<Group grow align="flex-start" mb="md">
|
||||
<FieldTextarea name="description" control={control} label={t.fields.description} minRows={3} />
|
||||
<FieldJsonInput name="jsonConfig" control={control} label={t.fields.jsonConfig} formatOnBlur />
|
||||
</Group>
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb={3}>{t.fields.pin}</Text>
|
||||
<FieldPinInput name="pin" control={control} length={6} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* --- Selections --- */}
|
||||
<div>
|
||||
<Title order={5} mb="sm" c="brand">{t.sections.selections}</Title>
|
||||
<Divider mb="md" />
|
||||
<Group grow align="flex-start" mb="md">
|
||||
<FieldSelect
|
||||
name="orderType"
|
||||
control={control}
|
||||
label={t.fields.orderType}
|
||||
data={['BULK', 'RETAIL']}
|
||||
/>
|
||||
<FieldNativeSelect
|
||||
name="nativeOrderType"
|
||||
control={control}
|
||||
label={`Native ${t.fields.orderType}`}
|
||||
data={['BULK', 'RETAIL']}
|
||||
/>
|
||||
</Group>
|
||||
<Group grow align="flex-start" mb="md">
|
||||
<FieldAutocomplete
|
||||
name="country"
|
||||
control={control}
|
||||
label={t.fields.country}
|
||||
data={['Indonesia', 'Singapore', 'Malaysia']}
|
||||
/>
|
||||
<FieldMultiSelect
|
||||
name="categories"
|
||||
control={control}
|
||||
label={t.fields.categories}
|
||||
data={['Electronics', 'Fashion', 'Food']}
|
||||
/>
|
||||
</Group>
|
||||
<Group grow align="flex-start" mb="md">
|
||||
<FieldLocalSelect
|
||||
name="localSelect"
|
||||
control={control}
|
||||
label={t.fields.localSelect}
|
||||
placeholder={t.placeholders.selectComplexObject}
|
||||
options={[
|
||||
{ id: 1, name: 'Apple', type: 'Fruit' },
|
||||
{ id: 2, name: 'Carrot', type: 'Vegetable' },
|
||||
{ id: 3, name: 'Banana', type: 'Fruit' }
|
||||
]}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
clearable
|
||||
/>
|
||||
<FieldLocalSelect
|
||||
multiple
|
||||
name="multiLocalSelect"
|
||||
control={control}
|
||||
label={t.fields.multiLocalSelect}
|
||||
placeholder={t.placeholders.selectMultipleObjects}
|
||||
options={[
|
||||
{ id: 1, name: 'Red', hex: '#f00' },
|
||||
{ id: 2, name: 'Green', hex: '#0f0' },
|
||||
{ id: 3, name: 'Blue', hex: '#00f' }
|
||||
]}
|
||||
valueKey="id"
|
||||
renderLabel={(item) => `${item.name} (${item.hex})`}
|
||||
clearable
|
||||
/>
|
||||
</Group>
|
||||
<Group grow align="flex-start" mb="md">
|
||||
<FieldAsyncSelect
|
||||
name="asyncSelect"
|
||||
control={control}
|
||||
label={t.fields.asyncSelectMock}
|
||||
placeholder={t.placeholders.searchPokemon}
|
||||
loadOptions={loadMockPokemonOptions}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
clearable
|
||||
/>
|
||||
<FieldAsyncSelect
|
||||
multiple
|
||||
name="multiAsyncSelect"
|
||||
control={control}
|
||||
label={t.fields.multiAsyncSelect}
|
||||
placeholder={t.placeholders.selectMultiplePokemon}
|
||||
loadOptions={loadMockPokemonOptions}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
clearable
|
||||
/>
|
||||
</Group>
|
||||
<Group grow align="flex-start" mb="md">
|
||||
<FieldAsyncSelect
|
||||
name="realPokeSelect"
|
||||
control={control}
|
||||
label={t.fields.realPokeSingle}
|
||||
placeholder={t.placeholders.scrollDeduplication}
|
||||
loadOptions={loadRealPokemonOptions}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
clearable
|
||||
/>
|
||||
<FieldAsyncSelect
|
||||
multiple
|
||||
name="multiRealPokeSelect"
|
||||
control={control}
|
||||
label={t.fields.realPokeMulti}
|
||||
placeholder={t.placeholders.scrollDeduplication}
|
||||
loadOptions={loadRealPokemonOptions}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
clearable
|
||||
/>
|
||||
</Group>
|
||||
<FieldTagsInput name="tags" control={control} label={t.fields.tags} />
|
||||
|
||||
<Title order={5} mb="sm" mt="lg" c="brand">{t.sections.advancedObjectSelects}</Title>
|
||||
<Divider mb="md" />
|
||||
<Group grow align="flex-start" mb="md">
|
||||
<FieldLocalSelect
|
||||
name="localSelectEmpty"
|
||||
control={control}
|
||||
label={t.fields.localEmpty}
|
||||
options={MOCK_VENDORS}
|
||||
valueKey="id"
|
||||
renderLabel={(item) => `[${item.code}] ${item.name}`}
|
||||
clearable
|
||||
/>
|
||||
<FieldLocalSelect
|
||||
name="localSelectPrefilled"
|
||||
control={control}
|
||||
label={t.fields.localPrefilled}
|
||||
options={MOCK_VENDORS}
|
||||
valueKey="id"
|
||||
renderLabel={(item) => `[${item.code}] ${item.name}`}
|
||||
clearable
|
||||
/>
|
||||
</Group>
|
||||
<Group grow align="flex-start" mb="md">
|
||||
<FieldAsyncSelect
|
||||
name="asyncSelectEmpty"
|
||||
control={control}
|
||||
label={t.fields.asyncEmpty}
|
||||
loadOptions={loadMockVendorsOptions}
|
||||
valueKey="id"
|
||||
renderLabel={(item) => `[${item.code}] ${item.name}`}
|
||||
clearable
|
||||
/>
|
||||
<FieldAsyncSelect
|
||||
name="asyncSelectPrefilled"
|
||||
control={control}
|
||||
label={t.fields.asyncPrefilled}
|
||||
loadOptions={loadMockVendorsOptions}
|
||||
valueKey="id"
|
||||
renderLabel={(item) => `[${item.code}] ${item.name}`}
|
||||
defaultOptions={[{ id: 999, code: 'ASYNC-99', name: 'Pre-loaded Async Vendor' }]}
|
||||
clearable
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Title order={5} mb="sm" mt="lg" c="brand">{t.sections.multiSelectEditMode}</Title>
|
||||
<Divider mb="md" />
|
||||
<Group grow align="flex-start" mb="md">
|
||||
<FieldLocalSelect
|
||||
multiple
|
||||
name="localMultiPrefilled"
|
||||
control={control}
|
||||
label={t.fields.localMultiPrefilled}
|
||||
options={MOCK_VENDORS}
|
||||
valueKey="id"
|
||||
renderLabel={(item) => `[${item.code}] ${item.name}`}
|
||||
clearable
|
||||
/>
|
||||
<FieldAsyncSelect
|
||||
multiple
|
||||
name="asyncMultiPrefilled"
|
||||
control={control}
|
||||
label={t.fields.asyncMultiPrefilled}
|
||||
loadOptions={loadMockVendorsOptions}
|
||||
valueKey="id"
|
||||
renderLabel={(item) => `[${item.code}] ${item.name}`}
|
||||
clearable
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Title order={5} mb="sm" mt="lg" c="brand">{t.sections.richTextEditor}</Title>
|
||||
<Divider mb="md" />
|
||||
<FieldRichTextEditor
|
||||
name="richTextEmpty"
|
||||
control={control}
|
||||
label={t.fields.richTextEmpty}
|
||||
description={t.descriptions.freshTipTap}
|
||||
/>
|
||||
<div style={{ marginTop: '16px' }}>
|
||||
<FieldRichTextEditor
|
||||
name="richTextPrefilled"
|
||||
control={control}
|
||||
label={t.fields.richTextPrefilled}
|
||||
description={t.descriptions.htmlStringLoaded}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* --- Toggles & Choices --- */}
|
||||
<div>
|
||||
<Title order={5} mb="sm" c="brand">{t.sections.togglesAndChoices}</Title>
|
||||
<Divider mb="md" />
|
||||
<Group mb="md">
|
||||
<FieldCheckbox name="terms" control={control} label={t.fields.terms} />
|
||||
<FieldSwitch name="receiveEmails" control={control} label={t.fields.receiveEmails} />
|
||||
</Group>
|
||||
<FieldRadioGroup
|
||||
name="priority"
|
||||
control={control}
|
||||
label={t.fields.priority}
|
||||
mb="md"
|
||||
>
|
||||
<Group mt="xs">
|
||||
<Radio value="low" label="Low" />
|
||||
<Radio value="high" label="High" />
|
||||
</Group>
|
||||
</FieldRadioGroup>
|
||||
<FieldSegmentedControl
|
||||
name="segmentedPriority"
|
||||
control={control}
|
||||
label={t.fields.priority}
|
||||
data={[
|
||||
{ label: 'Normal', value: 'normal' },
|
||||
{ label: 'Urgent', value: 'urgent' }
|
||||
]}
|
||||
mb="md"
|
||||
/>
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb={3}>Chip Selection</Text>
|
||||
<FieldChipGroup
|
||||
name="chipSelection"
|
||||
control={control}
|
||||
>
|
||||
<Group>
|
||||
<Chip value="1">Option 1</Chip>
|
||||
<Chip value="2">Option 2</Chip>
|
||||
</Group>
|
||||
</FieldChipGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* --- Ranges & Specialized --- */}
|
||||
<div>
|
||||
<Title order={5} mb="sm" c="brand">{t.sections.rangesAndSpecialized}</Title>
|
||||
<Divider mb="md" />
|
||||
<Group grow align="flex-start" mb="md">
|
||||
<FieldSlider name="satisfaction" control={control} label={t.fields.rating} />
|
||||
<FieldRangeSlider name="priceRange" control={control} label={t.fields.priceRange} />
|
||||
</Group>
|
||||
<Group grow align="flex-start" mb="md">
|
||||
<FieldColorInput name="themeColor" control={control} label={t.fields.themeColor} />
|
||||
<FieldFileInput name="avatar" control={control} label={t.fields.avatar} />
|
||||
</Group>
|
||||
<Group grow align="flex-start" mb="md">
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb={3}>{t.fields.themeColor} Picker</Text>
|
||||
<FieldColorPicker name="colorPicker" control={control} />
|
||||
</div>
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb={3}>{t.fields.rating}</Text>
|
||||
<FieldRating name="rating" control={control} />
|
||||
</div>
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<Button type="submit" mt="md">{t.common.submit}</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</Paper>
|
||||
|
||||
<Paper p="md" withBorder radius="md" bg="var(--mantine-color-gray-0)">
|
||||
<Title order={6} mb="xs">{t.common.submittedData}</Title>
|
||||
<Code block>{JSON.stringify(data, null, 2)}</Code>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
+368
@@ -0,0 +1,368 @@
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { Button, Paper, Title, Divider, Stack, Code, Alert, TypographyStylesProvider } from '@repo/ui/components';
|
||||
import { FieldTextInput, FieldSelect, FieldSwitch, FieldLocalSelect, FieldAsyncSelect, FieldRichTextEditor } from '@repo/ui/form';
|
||||
import { useConditionalField } from '@repo/ui/hooks';
|
||||
import { compose, required, emailValidator } from '@repo/ui/validators';
|
||||
import { useFormDemoTranslation } from '../i18n/useFormDemoTranslation';
|
||||
import { Info } from 'lucide-react';
|
||||
import { useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
|
||||
interface Region {
|
||||
id: string;
|
||||
code: string;
|
||||
taxRate: number;
|
||||
}
|
||||
|
||||
interface Warehouse {
|
||||
id: string;
|
||||
regionId: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const REGIONS: Region[] = [
|
||||
{ id: 'R1', code: 'APAC', taxRate: 0.1 },
|
||||
{ id: 'R2', code: 'EMEA', taxRate: 0.2 }
|
||||
];
|
||||
|
||||
const mockFetchWarehouses = async (regionIds: string[], search: string, page: number) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
const allWarehouses: Warehouse[] = [
|
||||
{ id: 'W1', regionId: 'R1', name: 'Singapore Hub' },
|
||||
{ id: 'W2', regionId: 'R1', name: 'Tokyo Depot' },
|
||||
{ id: 'W3', regionId: 'R2', name: 'London Central' },
|
||||
{ id: 'W4', regionId: 'R2', name: 'Berlin Storage' },
|
||||
];
|
||||
|
||||
const filtered = allWarehouses.filter(w => regionIds.includes(w.regionId) && w.name.toLowerCase().includes(search.toLowerCase()));
|
||||
const pageSize = 10;
|
||||
const start = (page - 1) * pageSize;
|
||||
const paginated = filtered.slice(start, start + pageSize);
|
||||
|
||||
return {
|
||||
options: paginated,
|
||||
hasMore: start + pageSize < filtered.length
|
||||
};
|
||||
};
|
||||
|
||||
export default function ReactiveWatchDemo() {
|
||||
const t = useFormDemoTranslation();
|
||||
|
||||
// Define atomic validators for conditional fields
|
||||
const taxIdValidator = compose(z.string(), required(t.watch.corporateTaxId));
|
||||
const spouseNameValidator = compose(z.string(), required(t.watch.spouseName));
|
||||
const newsletterEmailValidator = compose(z.string(), required(t.fields.email), emailValidator());
|
||||
const roleValidator = compose(z.string(), required(t.fields.role));
|
||||
|
||||
const reactiveSchema = useMemo(() => z
|
||||
.object({
|
||||
userType: z.enum(['PERSONAL', 'CORPORATE']),
|
||||
corporateTaxId: z.string().optional(),
|
||||
hasSpouse: z.boolean(),
|
||||
spouseName: z.string().optional(),
|
||||
newsletter: z.boolean(),
|
||||
newsletterEmail: z.string().optional(),
|
||||
department: z.string().optional(),
|
||||
role: z.string().optional(),
|
||||
regions: z.array(z.object({ id: z.string(), code: z.string(), taxRate: z.number() })).optional(),
|
||||
warehouses: z.array(z.object({ id: z.string(), regionId: z.string(), name: z.string() })).optional(),
|
||||
richTextLive: z.string().optional(),
|
||||
})
|
||||
.and(
|
||||
z.discriminatedUnion('userType', [
|
||||
z.object({ userType: z.literal('PERSONAL') }),
|
||||
z.object({ userType: z.literal('CORPORATE'), corporateTaxId: taxIdValidator }),
|
||||
]),
|
||||
)
|
||||
.and(
|
||||
z.union([
|
||||
z.object({ hasSpouse: z.literal(false) }),
|
||||
z.object({ hasSpouse: z.literal(true), spouseName: spouseNameValidator }),
|
||||
]),
|
||||
)
|
||||
.and(
|
||||
z.union([
|
||||
z.object({ newsletter: z.literal(false) }),
|
||||
z.object({ newsletter: z.literal(true), newsletterEmail: newsletterEmailValidator }),
|
||||
]),
|
||||
)
|
||||
.and(
|
||||
z.union([
|
||||
z.object({ department: z.string().min(1), role: roleValidator }),
|
||||
z.object({ department: z.union([z.literal(''), z.undefined(), z.null()]).optional() }),
|
||||
]),
|
||||
), [t, taxIdValidator, spouseNameValidator, newsletterEmailValidator, roleValidator]);
|
||||
|
||||
const { control, handleSubmit, setValue, unregister, clearErrors } = useForm<any>({
|
||||
resolver: zodResolver(reactiveSchema as any),
|
||||
defaultValues: {
|
||||
userType: 'PERSONAL',
|
||||
corporateTaxId: '',
|
||||
hasSpouse: false,
|
||||
spouseName: '',
|
||||
newsletter: false,
|
||||
newsletterEmail: '',
|
||||
department: '',
|
||||
role: '',
|
||||
regions: [{ id: 'R1', code: 'APAC', taxRate: 0.1 }],
|
||||
warehouses: [
|
||||
{ id: 'W-99', regionId: 'R1', name: 'APAC Central Hub' },
|
||||
{ id: 'W-98', regionId: 'R1', name: 'APAC Backup Hub' }
|
||||
],
|
||||
richTextLive: "<p>Start typing to see the live preview...</p>",
|
||||
},
|
||||
});
|
||||
|
||||
// Micro-subscriptions via useWatch
|
||||
const userType = useWatch({ control, name: 'userType' });
|
||||
const hasSpouse = useWatch({ control, name: 'hasSpouse' });
|
||||
const newsletter = useWatch({ control, name: 'newsletter' });
|
||||
const department = useWatch({ control, name: 'department' });
|
||||
const role = useWatch({ control, name: 'role' });
|
||||
const regions = useWatch({ control, name: 'regions' });
|
||||
const watchedRichTextLive = useWatch({ control, name: 'richTextLive' });
|
||||
|
||||
// Use the custom hook to cleanly unregister and reset fields when hidden
|
||||
useConditionalField({
|
||||
condition: userType === 'CORPORATE',
|
||||
name: 'corporateTaxId',
|
||||
setValue,
|
||||
unregister,
|
||||
mode: 'unregister',
|
||||
defaultValue: '',
|
||||
});
|
||||
|
||||
useConditionalField({
|
||||
condition: hasSpouse === true,
|
||||
name: 'spouseName',
|
||||
setValue,
|
||||
unregister,
|
||||
mode: 'unregister',
|
||||
defaultValue: '',
|
||||
});
|
||||
|
||||
// Use the reset mode to clear values and errors without unregistering the field
|
||||
useConditionalField({
|
||||
condition: newsletter === true,
|
||||
name: 'newsletterEmail',
|
||||
setValue,
|
||||
clearErrors,
|
||||
mode: 'reset',
|
||||
defaultValue: '',
|
||||
});
|
||||
|
||||
// Cascading Dropdown Logic: Department -> Role
|
||||
const roleOptions: Record<string, { value: string; label: string }[]> = {
|
||||
IT: [
|
||||
{ value: 'FRONTEND', label: 'Frontend Engineer' },
|
||||
{ value: 'BACKEND', label: 'Backend Engineer' },
|
||||
],
|
||||
HR: [
|
||||
{ value: 'RECRUITER', label: 'Technical Recruiter' },
|
||||
{ value: 'MANAGER', label: 'HR Manager' },
|
||||
],
|
||||
FINANCE: [
|
||||
{ value: 'ACCOUNTANT', label: 'Accountant' },
|
||||
{ value: 'ANALYST', label: 'Financial Analyst' },
|
||||
],
|
||||
};
|
||||
|
||||
const currentRoleOptions = department ? roleOptions[department] : [];
|
||||
const isRoleValid = !role || (!!department && currentRoleOptions.some((opt) => opt.value === role));
|
||||
|
||||
// Reset Mode: Automatically clears the 'role' field value and errors if the department changes
|
||||
// and the currently selected role is no longer valid for the new department.
|
||||
useConditionalField({
|
||||
condition: isRoleValid,
|
||||
name: 'role',
|
||||
setValue,
|
||||
clearErrors,
|
||||
mode: 'reset',
|
||||
defaultValue: '',
|
||||
});
|
||||
|
||||
const isMounted = useRef(false);
|
||||
const prevRegionIds = useRef<string[]>(regions?.map((r: Region) => r.id) || []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMounted.current) {
|
||||
isMounted.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const currentIds = regions?.map((r: Region) => r.id) || [];
|
||||
const prevIds = prevRegionIds.current;
|
||||
|
||||
const hasChanged = currentIds.length !== prevIds.length || currentIds.some((id: string) => !prevIds.includes(id));
|
||||
|
||||
if (hasChanged) {
|
||||
setValue('warehouses', []);
|
||||
clearErrors('warehouses');
|
||||
prevRegionIds.current = currentIds;
|
||||
}
|
||||
}, [regions, setValue, clearErrors]);
|
||||
|
||||
// Use watch only to display the JSON output at the bottom
|
||||
const allValues = useWatch({ control });
|
||||
|
||||
const onSubmit = (data: any) => console.log('Reactive Passed:', data);
|
||||
|
||||
return (
|
||||
<Stack gap="xl">
|
||||
<Paper p="xl" withBorder radius="md">
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack gap="md">
|
||||
<Title order={5} mb="sm" c="brand">
|
||||
{t.sections.reactiveWatchCascading}
|
||||
</Title>
|
||||
<Divider mb="sm" />
|
||||
|
||||
<Alert icon={<Info size={16} />} title="Micro-subscription Pattern" color="blue" variant="light">
|
||||
This form demonstrates isolated re-rendering using <code>useWatch</code>. Instead of re-rendering the
|
||||
entire form root when typing, only the specific conditional fields update their display states.
|
||||
</Alert>
|
||||
|
||||
<Divider label="Hidden/Unmounted Pattern" labelPosition="center" my="sm" />
|
||||
|
||||
<FieldSelect
|
||||
name="userType"
|
||||
control={control}
|
||||
label={t.watch.userType}
|
||||
data={[
|
||||
{ value: 'PERSONAL', label: t.watch.typePersonal || 'Personal' },
|
||||
{ value: 'CORPORATE', label: t.watch.typeCorporate || 'Corporate' },
|
||||
]}
|
||||
withAsterisk
|
||||
/>
|
||||
|
||||
{userType === 'CORPORATE' && (
|
||||
<FieldTextInput name="corporateTaxId" control={control} label={t.watch.corporateTaxId} withAsterisk />
|
||||
)}
|
||||
|
||||
<FieldSwitch name="hasSpouse" control={control} label={t.watch.hasSpouse} mt="md" />
|
||||
|
||||
{hasSpouse && (
|
||||
<FieldTextInput name="spouseName" control={control} label={t.watch.spouseName} withAsterisk />
|
||||
)}
|
||||
|
||||
<Divider label="Visible but Disabled Pattern" labelPosition="center" my="md" />
|
||||
|
||||
<FieldSwitch name="newsletter" control={control} label="Subscribe to Newsletter" />
|
||||
|
||||
<FieldTextInput
|
||||
name="newsletterEmail"
|
||||
control={control}
|
||||
label="Newsletter Email"
|
||||
disabled={!newsletter}
|
||||
placeholder="Enter your email to subscribe"
|
||||
withAsterisk={newsletter}
|
||||
/>
|
||||
|
||||
<Divider label="Reset Mode (Cascading Dependencies)" labelPosition="center" my="md" />
|
||||
|
||||
<FieldSelect
|
||||
name="department"
|
||||
control={control}
|
||||
label={t.fields.department}
|
||||
placeholder={t.placeholders.selectComplexObject}
|
||||
data={[
|
||||
{ value: 'IT', label: 'Information Technology' },
|
||||
{ value: 'HR', label: 'Human Resources' },
|
||||
{ value: 'FINANCE', label: 'Finance' },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* * ⚠️ CRITICAL UI FIX: DYNAMIC KEY
|
||||
* Why bind the 'key' to the parent dependency (department)?
|
||||
* * Mantine's Select component caches its internal visual state. When the parent
|
||||
* 'department' changes, our useConditionalField hook successfully clears the RHF
|
||||
* payload state, but Mantine might visually retain the old text on the screen.
|
||||
* * By changing the 'key' whenever the department changes, we force React to
|
||||
* completely unmount and remount this component. This destroys Mantine's old
|
||||
* internal cache and guarantees a perfectly clean UI sync.
|
||||
*/}
|
||||
<FieldSelect
|
||||
key={`role-select-${department}`}
|
||||
name="role"
|
||||
control={control}
|
||||
label={t.fields.role}
|
||||
placeholder={t.placeholders.selectComplexObject}
|
||||
disabled={!department}
|
||||
data={currentRoleOptions}
|
||||
withAsterisk={!!department}
|
||||
/>
|
||||
|
||||
<Title order={5} mb="sm" c="brand" mt="lg">
|
||||
{t.sections.reactiveWatchCascading}
|
||||
</Title>
|
||||
<Divider mb="sm" />
|
||||
|
||||
<FieldLocalSelect<Region>
|
||||
multiple
|
||||
name="regions"
|
||||
control={control as any}
|
||||
label={t.fields.regions}
|
||||
options={REGIONS}
|
||||
valueKey="id"
|
||||
labelKey="code"
|
||||
clearable
|
||||
/>
|
||||
|
||||
<FieldAsyncSelect<Warehouse>
|
||||
multiple
|
||||
key={`warehouse-select-${regions?.map((r: any) => r.id).join(',')}`}
|
||||
name="warehouses"
|
||||
control={control as any}
|
||||
label={t.fields.warehouses}
|
||||
disabled={!regions || regions.length === 0}
|
||||
loadOptions={useCallback(async (search, page) => {
|
||||
if (!regions || regions.length === 0) return { options: [], hasMore: false };
|
||||
return mockFetchWarehouses(regions.map((r: any) => r.id), search, page);
|
||||
}, [regions])}
|
||||
valueKey="id"
|
||||
renderLabel={(item) => `[${item.id}] ${item.name}`}
|
||||
clearable
|
||||
/>
|
||||
|
||||
{regions && regions.length > 0 && (
|
||||
<Alert mt="sm" color="teal">
|
||||
{t.descriptions.selectedRegionsTax} {regions.map((r: any) => `${r.code} (${(r.taxRate * 100).toFixed(0)}%)`).join(', ')}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Title order={5} mb="sm" c="brand" mt="lg">
|
||||
{t.sections.reactiveRichTextPreview}
|
||||
</Title>
|
||||
<Divider mb="sm" />
|
||||
|
||||
<FieldRichTextEditor
|
||||
name="richTextLive"
|
||||
control={control as any}
|
||||
label={t.fields.liveEditor}
|
||||
description={t.descriptions.typeToSeePreview}
|
||||
/>
|
||||
|
||||
<Paper p="md" withBorder radius="md" mt="sm">
|
||||
<Title order={6} mb="xs">{t.sections.liveHtmlPreview}</Title>
|
||||
<TypographyStylesProvider>
|
||||
<div dangerouslySetInnerHTML={{ __html: watchedRichTextLive }} />
|
||||
</TypographyStylesProvider>
|
||||
</Paper>
|
||||
|
||||
<Button type="submit" mt="md">
|
||||
{t.common.submitReactive}
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</Paper>
|
||||
|
||||
<Paper p="md" withBorder radius="md" bg="var(--mantine-color-gray-0)">
|
||||
<Title order={6} mb="xs">
|
||||
{t.common?.submittedData || 'Submitted Data'}
|
||||
</Title>
|
||||
<Code block>{JSON.stringify(allValues, null, 2)}</Code>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
+254
@@ -0,0 +1,254 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { Button, Paper, Title, Group, Stack, Code, Divider } from '@repo/ui/components';
|
||||
import {
|
||||
FieldTextInput, FieldPasswordInput, FieldNumberInput,
|
||||
FieldLocalSelect, FieldAsyncSelect, FieldRichTextEditor
|
||||
} from '@repo/ui/form';
|
||||
import type { LoadOptionsFn } from '@repo/ui/form';
|
||||
|
||||
interface Department {
|
||||
code: string;
|
||||
name: string;
|
||||
costCenter: string;
|
||||
}
|
||||
|
||||
interface Assignee {
|
||||
id: number;
|
||||
email: string;
|
||||
}
|
||||
|
||||
const MOCK_DEPARTMENTS: Department[] = [
|
||||
{ code: 'IT', name: 'Information Technology', costCenter: 'CC-100' },
|
||||
{ code: 'HR', name: 'Human Resources', costCenter: 'CC-200' },
|
||||
{ code: 'FIN', name: 'Finance', costCenter: 'CC-300' },
|
||||
];
|
||||
|
||||
const mockFetchUsers: LoadOptionsFn<Assignee> = async (search, page) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
const allUsers = Array.from({ length: 20 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
email: `user${i + 1}@company.com`
|
||||
}));
|
||||
const filtered = allUsers.filter(u => u.email.toLowerCase().includes(search.toLowerCase()));
|
||||
const pageSize = 5;
|
||||
const start = (page - 1) * pageSize;
|
||||
const paginated = filtered.slice(start, start + pageSize);
|
||||
|
||||
return {
|
||||
options: paginated,
|
||||
hasMore: start + pageSize < filtered.length
|
||||
};
|
||||
};
|
||||
|
||||
const MOCK_VENDORS = [
|
||||
{ id: 'V1', code: 'VN-01', name: 'Vendor One' },
|
||||
{ id: 'V2', code: 'VN-02', name: 'Vendor Two' },
|
||||
];
|
||||
|
||||
const mockFetchVendors: LoadOptionsFn<any> = async (search, _page) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
const filtered = MOCK_VENDORS.filter(v => v.name.toLowerCase().includes(search.toLowerCase()) || v.code.toLowerCase().includes(search.toLowerCase()));
|
||||
return { options: filtered, hasMore: false };
|
||||
};
|
||||
import {
|
||||
compose, required, rangeLength,
|
||||
positiveNumber, simplePassword,
|
||||
complexPassword, phoneValidator, rangeValue
|
||||
} from '@repo/ui/validators';
|
||||
import { useFormDemoTranslation } from '../i18n/useFormDemoTranslation';
|
||||
|
||||
export default function ValidationBankDemo() {
|
||||
const t = useFormDemoTranslation();
|
||||
|
||||
// Compose the Zod schema using the atomic validators
|
||||
const validationSchema = useMemo(() => z.object({
|
||||
username: compose(z.string(), required(t.fields.customerName), rangeLength(3, 15, t.fields.customerName)),
|
||||
simplePass: compose(z.string(), required(t.validation.simplePassword), simplePassword(6)),
|
||||
complexPass: compose(z.string(), required(t.validation.complexPassword), complexPassword(8)),
|
||||
age: compose(z.number(), required(t.fields.age), rangeValue(18, 65, t.fields.age)),
|
||||
score: compose(z.number(), required(t.validation.score), positiveNumber(t.validation.score)),
|
||||
phone: compose(z.string(), required(t.validation.phone), phoneValidator()),
|
||||
department: z.object({ code: z.string(), name: z.string() }, { required_error: t.errors.departmentRequired }),
|
||||
assignees: z.array(z.object({ id: z.number(), email: z.string() })).min(2, t.errors.min2Assignees),
|
||||
prefilledVendor: z.object({ id: z.union([z.string(), z.number()]), code: z.string(), name: z.string() }, { required_error: t.errors.vendorRequired }),
|
||||
emptyVendor: z.object({ id: z.union([z.string(), z.number()]), code: z.string(), name: z.string() }, { required_error: t.errors.vendorRequired }),
|
||||
prefilledAsyncMulti: z.array(z.object({ id: z.union([z.string(), z.number()]), code: z.string(), name: z.string() })).min(1, t.errors.min1Vendor),
|
||||
richTextNotes: z.string().min(15, t.errors.notesMin15),
|
||||
}), [t]);
|
||||
|
||||
type ValidationFormValues = z.infer<typeof validationSchema>;
|
||||
|
||||
const { control, handleSubmit, watch } = useForm<ValidationFormValues>({
|
||||
resolver: zodResolver(validationSchema),
|
||||
defaultValues: {
|
||||
username: '',
|
||||
simplePass: '',
|
||||
complexPass: '',
|
||||
age: undefined as any,
|
||||
score: undefined as any,
|
||||
phone: '',
|
||||
department: null as any,
|
||||
assignees: [],
|
||||
prefilledVendor: { id: 'V1', code: 'VN-01', name: 'Vendor One' } as any,
|
||||
emptyVendor: null as any,
|
||||
prefilledAsyncMulti: [
|
||||
{ id: 'V1', code: 'VN-01', name: 'Vendor One' },
|
||||
{ id: 'V2', code: 'VN-02', name: 'Vendor Two' }
|
||||
] as any,
|
||||
richTextNotes: '',
|
||||
}
|
||||
});
|
||||
|
||||
const onSubmit = (data: ValidationFormValues) => console.log('Validation Passed:', data);
|
||||
const data = watch();
|
||||
|
||||
return (
|
||||
<Stack gap="xl">
|
||||
<Paper p="xl" withBorder radius="md">
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack gap="md">
|
||||
<Title order={5} c="brand">{t.sections.validationBankTitle}</Title>
|
||||
<Divider mb="sm" />
|
||||
|
||||
<FieldTextInput
|
||||
name="username"
|
||||
control={control}
|
||||
label={t.fields.customerName}
|
||||
description={t.validation.usernameRange}
|
||||
withAsterisk
|
||||
/>
|
||||
|
||||
<Group grow align="flex-start">
|
||||
<FieldPasswordInput
|
||||
name="simplePass"
|
||||
control={control}
|
||||
label={t.validation.simplePassword}
|
||||
description={t.descriptions.min6Chars}
|
||||
withAsterisk
|
||||
/>
|
||||
<FieldPasswordInput
|
||||
name="complexPass"
|
||||
control={control}
|
||||
label={t.validation.complexPassword}
|
||||
description={t.descriptions.min8Complex}
|
||||
withAsterisk
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group grow align="flex-start">
|
||||
<FieldNumberInput
|
||||
name="age"
|
||||
control={control}
|
||||
label={t.fields.age}
|
||||
description={t.validation.ageRange}
|
||||
withAsterisk
|
||||
/>
|
||||
<FieldNumberInput
|
||||
name="score"
|
||||
control={control}
|
||||
label={t.validation.score}
|
||||
description={t.descriptions.mustBePositive}
|
||||
withAsterisk
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<FieldTextInput
|
||||
name="phone"
|
||||
control={control}
|
||||
label={t.validation.phone}
|
||||
description={t.descriptions.formatPhone}
|
||||
withAsterisk
|
||||
/>
|
||||
|
||||
<Title order={5} c="brand" mt="md">{t.sections.objectLevelValidations}</Title>
|
||||
<Divider mb="sm" />
|
||||
|
||||
<FieldLocalSelect<Department>
|
||||
name="department"
|
||||
control={control as any}
|
||||
label={t.fields.department}
|
||||
options={MOCK_DEPARTMENTS}
|
||||
valueKey="code"
|
||||
renderLabel={(item) => `[${item.code}] ${item.name}`}
|
||||
clearable
|
||||
withAsterisk
|
||||
/>
|
||||
|
||||
<FieldAsyncSelect<Assignee>
|
||||
multiple
|
||||
name="assignees"
|
||||
control={control as any}
|
||||
label={t.fields.assignees}
|
||||
loadOptions={mockFetchUsers}
|
||||
valueKey="id"
|
||||
labelKey="email"
|
||||
searchable
|
||||
clearable
|
||||
withAsterisk
|
||||
/>
|
||||
|
||||
<Title order={5} c="brand" mt="lg">{t.sections.validatedPrefilledObjects}</Title>
|
||||
<Divider mb="sm" />
|
||||
|
||||
<Group grow align="flex-start">
|
||||
<FieldAsyncSelect
|
||||
name="emptyVendor"
|
||||
control={control as any}
|
||||
label={t.fields.emptyVendor}
|
||||
loadOptions={mockFetchVendors}
|
||||
valueKey="id"
|
||||
renderLabel={(item) => `[${item.code}] ${item.name}`}
|
||||
clearable
|
||||
withAsterisk
|
||||
/>
|
||||
<FieldAsyncSelect
|
||||
name="prefilledVendor"
|
||||
control={control as any}
|
||||
label={t.fields.prefilledVendor}
|
||||
loadOptions={mockFetchVendors}
|
||||
valueKey="id"
|
||||
renderLabel={(item) => `[${item.code}] ${item.name}`}
|
||||
defaultOptions={[{ id: 'V1', code: 'VN-01', name: 'Vendor One' }]}
|
||||
clearable
|
||||
withAsterisk
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<FieldAsyncSelect
|
||||
multiple
|
||||
name="prefilledAsyncMulti"
|
||||
control={control as any}
|
||||
label={t.fields.prefilledAsyncMulti}
|
||||
loadOptions={mockFetchVendors}
|
||||
valueKey="id"
|
||||
renderLabel={(item) => `[${item.code}] ${item.name}`}
|
||||
clearable
|
||||
withAsterisk
|
||||
/>
|
||||
|
||||
<Title order={5} c="brand" mt="lg">{t.sections.richTextValidations}</Title>
|
||||
<Divider mb="sm" />
|
||||
|
||||
<FieldRichTextEditor
|
||||
name="richTextNotes"
|
||||
control={control as any}
|
||||
label={t.fields.importantNotes}
|
||||
description={t.descriptions.zodMinLengthString}
|
||||
withAsterisk
|
||||
/>
|
||||
|
||||
<Button type="submit" mt="md">{t.common.submit}</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</Paper>
|
||||
|
||||
<Paper p="md" withBorder radius="md" bg="var(--mantine-color-gray-0)">
|
||||
<Title order={6} mb="xs">{t.common.submittedData}</Title>
|
||||
<Code block>{JSON.stringify(data, null, 2)}</Code>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Tabs } from '@repo/ui/components';
|
||||
import AllFieldsDemo from './components/all-fields-demo';
|
||||
import ValidationBankDemo from './components/validation-bank-demo';
|
||||
import ReactiveWatchDemo from './components/reactive-watch-demo';
|
||||
import { useFormDemoTranslation } from './i18n/useFormDemoTranslation';
|
||||
|
||||
export default function FormDemoView() {
|
||||
const t = useFormDemoTranslation();
|
||||
|
||||
return (
|
||||
<Tabs defaultValue="all-fields" variant="outline" radius="md">
|
||||
<Tabs.List mb="md">
|
||||
<Tabs.Tab value="all-fields">{t.tabs.allFields}</Tabs.Tab>
|
||||
<Tabs.Tab value="validation-bank">{t.tabs.validationBank}</Tabs.Tab>
|
||||
<Tabs.Tab value="reactive-watch">{t.tabs.reactiveWatch}</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="all-fields">
|
||||
<AllFieldsDemo />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="validation-bank">
|
||||
<ValidationBankDemo />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="reactive-watch">
|
||||
<ReactiveWatchDemo />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
{
|
||||
"tabs": {
|
||||
"allFields": "All 22 Fields Demo",
|
||||
"validationBank": "Validation Bank",
|
||||
"reactiveWatch": "Reactive Watch (useWatch)"
|
||||
},
|
||||
"common": {
|
||||
"submit": "Submit Data",
|
||||
"submitReactive": "Submit Reactive Form",
|
||||
"reset": "Reset Form",
|
||||
"submittedData": "Submitted Data"
|
||||
},
|
||||
"sections": {
|
||||
"validationBankTitle": "Validation Bank (Atomic Registry)",
|
||||
"objectLevelValidations": "Object Level Validations (Local & Async)",
|
||||
"validatedPrefilledObjects": "Validated Prefilled Objects",
|
||||
"richTextValidations": "Rich Text Editor Validations",
|
||||
"textAndNumbers": "Text & Numbers",
|
||||
"selections": "Selections",
|
||||
"advancedObjectSelects": "Advanced Object Selects (Custom Labels & Default Values)",
|
||||
"multiSelectEditMode": "Multi-Select Edit Mode (No defaultOptions fallback)",
|
||||
"richTextEditor": "Rich Text Editor (TipTap)",
|
||||
"togglesAndChoices": "Toggles & Choices",
|
||||
"rangesAndSpecialized": "Ranges & Specialized",
|
||||
"reactiveWatchCascading": "Reactive Watch (Cascading)",
|
||||
"reactiveRichTextPreview": "Reactive Rich Text Preview",
|
||||
"liveHtmlPreview": "Live HTML Preview Render"
|
||||
},
|
||||
"fields": {
|
||||
"customerName": "Customer Name",
|
||||
"email": "Email Address",
|
||||
"priority": "Production Priority",
|
||||
"password": "Password",
|
||||
"description": "Description",
|
||||
"age": "Age",
|
||||
"jsonConfig": "JSON Config",
|
||||
"tags": "Tags",
|
||||
"terms": "I agree to the terms and conditions",
|
||||
"receiveEmails": "Receive marketing emails",
|
||||
"rating": "Satisfaction Rating",
|
||||
"themeColor": "Theme Color",
|
||||
"avatar": "Avatar Upload",
|
||||
"orderType": "Order Type",
|
||||
"quantity": "Quantity",
|
||||
"fabricColor": "Fabric Color",
|
||||
"pin": "Security PIN",
|
||||
"department": "Department",
|
||||
"assignees": "Assignees",
|
||||
"emptyVendor": "Empty Vendor",
|
||||
"prefilledVendor": "Prefilled Vendor",
|
||||
"prefilledAsyncMulti": "Prefilled Async Multi (No Fallback)",
|
||||
"importantNotes": "Important Notes",
|
||||
"country": "Country",
|
||||
"categories": "Categories",
|
||||
"localSelect": "Local Select",
|
||||
"multiLocalSelect": "Multi Local Select",
|
||||
"asyncSelectMock": "Async Select (Mock API)",
|
||||
"multiAsyncSelect": "Multi Async Select",
|
||||
"realPokeSingle": "Real PokeAPI (Single - Tests Deduplication)",
|
||||
"realPokeMulti": "Real PokeAPI (Multi - Tests Deduplication)",
|
||||
"localEmpty": "Local Empty",
|
||||
"localPrefilled": "Local Prefilled",
|
||||
"asyncEmpty": "Async Empty",
|
||||
"asyncPrefilled": "Async Prefilled (Edit Mode)",
|
||||
"localMultiPrefilled": "Local Multi Prefilled",
|
||||
"asyncMultiPrefilled": "Async Multi Prefilled (Ghost Items)",
|
||||
"richTextEmpty": "Rich Text (Empty)",
|
||||
"richTextPrefilled": "Rich Text (Prefilled / Edit Mode)",
|
||||
"priceRange": "Price Range",
|
||||
"role": "Role",
|
||||
"regions": "Regions",
|
||||
"warehouses": "Warehouses",
|
||||
"liveEditor": "Live Editor"
|
||||
},
|
||||
"placeholders": {
|
||||
"selectComplexObject": "Select a complex object",
|
||||
"selectMultipleObjects": "Select multiple objects",
|
||||
"searchPokemon": "Search pokemon...",
|
||||
"selectMultiplePokemon": "Select multiple pokemon...",
|
||||
"scrollDeduplication": "Scroll to test deduplication..."
|
||||
},
|
||||
"descriptions": {
|
||||
"min6Chars": "Min 6 chars",
|
||||
"min8Complex": "Min 8, 1 uppercase, 1 number, 1 special",
|
||||
"mustBePositive": "Must be > 0",
|
||||
"formatPhone": "Format: +62...",
|
||||
"zodMinLengthString": "This uses Zod minimum length string validation",
|
||||
"freshTipTap": "A fresh TipTap editor instance",
|
||||
"htmlStringLoaded": "HTML string successfully loaded from default values",
|
||||
"typeToSeePreview": "Type to see instantaneous reactive rendering below",
|
||||
"selectedRegionsTax": "Selected regions tax rates:"
|
||||
},
|
||||
"errors": {
|
||||
"departmentRequired": "Department is required",
|
||||
"vendorRequired": "Vendor is required",
|
||||
"min2Assignees": "Select at least 2 assignees",
|
||||
"min1Vendor": "Select at least 1 vendor",
|
||||
"notesMin15": "Notes must be at least 15 characters long (including HTML tags)",
|
||||
"selectRegionFirst": "Select a region first to load warehouses"
|
||||
},
|
||||
"validation": {
|
||||
"simplePassword": "Simple Password",
|
||||
"complexPassword": "Complex Password",
|
||||
"score": "Score (Positive)",
|
||||
"ageRange": "Age Range (18-65)",
|
||||
"usernameRange": "Username Length (3-15)",
|
||||
"phone": "Phone Number (+62)"
|
||||
},
|
||||
"watch": {
|
||||
"userType": "User Type",
|
||||
"typePersonal": "Personal",
|
||||
"typeCorporate": "Corporate",
|
||||
"corporateTaxId": "Corporate Tax ID",
|
||||
"hasSpouse": "Do you have a spouse?",
|
||||
"spouseName": "Spouse Name"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
{
|
||||
"tabs": {
|
||||
"allFields": "Demo 22 Field",
|
||||
"validationBank": "Bank Validasi",
|
||||
"reactiveWatch": "Reactive Watch (useWatch)"
|
||||
},
|
||||
"common": {
|
||||
"submit": "Kirim Data",
|
||||
"submitReactive": "Kirim Form Reaktif",
|
||||
"reset": "Reset Form",
|
||||
"submittedData": "Data Terkirim"
|
||||
},
|
||||
"sections": {
|
||||
"validationBankTitle": "Bank Validasi (Registri Atomik)",
|
||||
"objectLevelValidations": "Validasi Tingkat Objek (Lokal & Async)",
|
||||
"validatedPrefilledObjects": "Objek Terisi yang Divalidasi",
|
||||
"richTextValidations": "Validasi Rich Text Editor",
|
||||
"textAndNumbers": "Teks & Angka",
|
||||
"selections": "Pilihan",
|
||||
"advancedObjectSelects": "Pemilihan Objek Tingkat Lanjut (Label Kustom & Nilai Default)",
|
||||
"multiSelectEditMode": "Mode Edit Multi-Select (Tanpa fallback defaultOptions)",
|
||||
"richTextEditor": "Rich Text Editor (TipTap)",
|
||||
"togglesAndChoices": "Tombol Sakelar & Pilihan",
|
||||
"rangesAndSpecialized": "Rentang & Khusus",
|
||||
"reactiveWatchCascading": "Reactive Watch (Berjenjang)",
|
||||
"reactiveRichTextPreview": "Pratinjau Rich Text Reaktif",
|
||||
"liveHtmlPreview": "Render Pratinjau HTML Langsung"
|
||||
},
|
||||
"fields": {
|
||||
"customerName": "Nama Pelanggan",
|
||||
"email": "Alamat Email",
|
||||
"priority": "Prioritas Produksi",
|
||||
"password": "Kata Sandi",
|
||||
"description": "Deskripsi",
|
||||
"age": "Usia",
|
||||
"jsonConfig": "Konfigurasi JSON",
|
||||
"tags": "Label (Tags)",
|
||||
"terms": "Saya setuju dengan syarat dan ketentuan",
|
||||
"receiveEmails": "Terima email pemasaran",
|
||||
"rating": "Peringkat Kepuasan",
|
||||
"themeColor": "Warna Tema",
|
||||
"avatar": "Unggah Avatar",
|
||||
"orderType": "Tipe Pesanan",
|
||||
"quantity": "Jumlah",
|
||||
"fabricColor": "Warna Kain",
|
||||
"pin": "PIN Keamanan",
|
||||
"department": "Departemen",
|
||||
"assignees": "Penerima Tugas",
|
||||
"emptyVendor": "Vendor Kosong",
|
||||
"prefilledVendor": "Vendor Terisi",
|
||||
"prefilledAsyncMulti": "Multi Async Terisi (Tanpa Fallback)",
|
||||
"importantNotes": "Catatan Penting",
|
||||
"country": "Negara",
|
||||
"categories": "Kategori",
|
||||
"localSelect": "Pilihan Lokal",
|
||||
"multiLocalSelect": "Pilihan Lokal Multi",
|
||||
"asyncSelectMock": "Pilihan Async (Mock API)",
|
||||
"multiAsyncSelect": "Pilihan Async Multi",
|
||||
"realPokeSingle": "API Pokemon Asli (Tunggal - Uji Deduplikasi)",
|
||||
"realPokeMulti": "API Pokemon Asli (Multi - Uji Deduplikasi)",
|
||||
"localEmpty": "Lokal Kosong",
|
||||
"localPrefilled": "Lokal Terisi",
|
||||
"asyncEmpty": "Async Kosong",
|
||||
"asyncPrefilled": "Async Terisi (Mode Edit)",
|
||||
"localMultiPrefilled": "Multi Lokal Terisi",
|
||||
"asyncMultiPrefilled": "Multi Async Terisi (Item Hantu)",
|
||||
"richTextEmpty": "Rich Text (Kosong)",
|
||||
"richTextPrefilled": "Rich Text (Terisi / Mode Edit)",
|
||||
"priceRange": "Rentang Harga",
|
||||
"role": "Peran",
|
||||
"regions": "Wilayah",
|
||||
"warehouses": "Gudang",
|
||||
"liveEditor": "Editor Langsung"
|
||||
},
|
||||
"placeholders": {
|
||||
"selectComplexObject": "Pilih objek yang kompleks",
|
||||
"selectMultipleObjects": "Pilih beberapa objek",
|
||||
"searchPokemon": "Cari pokemon...",
|
||||
"selectMultiplePokemon": "Pilih beberapa pokemon...",
|
||||
"scrollDeduplication": "Gulir untuk menguji deduplikasi..."
|
||||
},
|
||||
"descriptions": {
|
||||
"min6Chars": "Minimal 6 karakter",
|
||||
"min8Complex": "Min 8, 1 huruf besar, 1 angka, 1 karakter khusus",
|
||||
"mustBePositive": "Harus > 0",
|
||||
"formatPhone": "Format: +62...",
|
||||
"zodMinLengthString": "Ini menggunakan validasi panjang string minimum Zod",
|
||||
"freshTipTap": "Instance editor TipTap yang baru",
|
||||
"htmlStringLoaded": "String HTML berhasil dimuat dari nilai default",
|
||||
"typeToSeePreview": "Ketik untuk melihat render reaktif seketika di bawah",
|
||||
"selectedRegionsTax": "Tarif pajak wilayah yang dipilih:"
|
||||
},
|
||||
"errors": {
|
||||
"departmentRequired": "Departemen wajib diisi",
|
||||
"vendorRequired": "Vendor wajib diisi",
|
||||
"min2Assignees": "Pilih minimal 2 penerima tugas",
|
||||
"min1Vendor": "Pilih minimal 1 vendor",
|
||||
"notesMin15": "Catatan minimal harus terdiri dari 15 karakter (termasuk tag HTML)",
|
||||
"selectRegionFirst": "Pilih wilayah terlebih dahulu untuk memuat gudang"
|
||||
},
|
||||
"validation": {
|
||||
"simplePassword": "Sandi Sederhana",
|
||||
"complexPassword": "Sandi Kompleks",
|
||||
"score": "Skor (Positif)",
|
||||
"ageRange": "Rentang Usia (18-65)",
|
||||
"usernameRange": "Panjang Username (3-15)",
|
||||
"phone": "Nomor Telepon (+62)"
|
||||
},
|
||||
"watch": {
|
||||
"userType": "Tipe Pengguna",
|
||||
"typePersonal": "Personal",
|
||||
"typeCorporate": "Perusahaan",
|
||||
"corporateTaxId": "NPWP Perusahaan",
|
||||
"hasSpouse": "Apakah Anda memiliki pasangan?",
|
||||
"spouseName": "Nama Pasangan"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import en from './en.json';
|
||||
import id from './id.json';
|
||||
|
||||
export type FormDemoI18n = typeof en;
|
||||
|
||||
export function useFormDemoTranslation(): FormDemoI18n {
|
||||
const { i18n } = useTranslation();
|
||||
return (i18n.language === 'id' ? id : en) as FormDemoI18n;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './form-demo-view';
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Stack, Container } from '@repo/ui/components';
|
||||
import FormDemoView from './components/form-demo';
|
||||
|
||||
export default function FormsPage() {
|
||||
return (
|
||||
<Container size="xl" m={0} p={0}>
|
||||
<Stack gap="xl">
|
||||
<FormDemoView />
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// 1. Import hooks yang baru saja dibuat Opus
|
||||
import { Button } from '@repo/ui/components';
|
||||
import { useElectronPrinter } from '../../../core/hooks/use-electron-printer';
|
||||
import { useElectronUpdater } from '../../../core/hooks/use-electron-updater';
|
||||
|
||||
export default function App() {
|
||||
// 2. Panggil hooks-nya
|
||||
const { printers, refreshPrinters } = useElectronPrinter();
|
||||
const { status } = useElectronUpdater();
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px', border: '2px solid blue', margin: '20px' }}>
|
||||
<h2>🧪 Test Integrasi Electron</h2>
|
||||
|
||||
<p>
|
||||
<strong>Status Auto-Update:</strong> {status}
|
||||
</p>
|
||||
|
||||
<Button variant="filled" color="brand" onClick={refreshPrinters}>
|
||||
Refresh Printer
|
||||
</Button>
|
||||
|
||||
<h3>🖨️ Daftar Printer di Komputer Ini:</h3>
|
||||
<ul>
|
||||
{printers.length === 0 ? (
|
||||
<li>Mencari printer... (Atau tidak ada printer terdeteksi)</li>
|
||||
) : (
|
||||
printers.map((printer, index) => (
|
||||
<li key={index}>
|
||||
{printer.name} {printer.isDefault ? '(Default)' : ''}
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Stack, Container, Card } from '@repo/ui/components';
|
||||
import PrinterList from './components/printer-list';
|
||||
|
||||
export default function HardwarePage() {
|
||||
return (
|
||||
<Container size="xl" m={0} p={0}>
|
||||
<Stack gap="xl">
|
||||
<Card withBorder shadow="sm" radius="md" p="md">
|
||||
<PrinterList />
|
||||
</Card>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Stack, Container, Card, Title, Text } from '@repo/ui/components';
|
||||
|
||||
export default function RbacPage() {
|
||||
return (
|
||||
<Container size="xl" m={0} p={0}>
|
||||
<Stack gap="xl">
|
||||
<Card withBorder shadow="sm" radius="md" p="md">
|
||||
<Title order={4} mb="md">RBAC Engine</Title>
|
||||
<Text c="dimmed">RBAC Demo Component Coming Soon...</Text>
|
||||
</Card>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
import { useState, useMemo, ReactNode } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { Box, Button, Group, SegmentedControl, Stack, Text, Paper, Switch, Select, Burger } from '@repo/ui/components';
|
||||
import {
|
||||
CoreAppShell,
|
||||
CorePageContainer,
|
||||
CoreAppShellConfig,
|
||||
LayoutVariant,
|
||||
DesktopCollapseVariant,
|
||||
useCoreAppShell,
|
||||
} from '@repo/ui/components';
|
||||
import { Home, BarChart2, Settings as SettingsIcon } from 'lucide-react';
|
||||
|
||||
// Sub-component to test hook methods
|
||||
function LayoutControls() {
|
||||
const { toggleDesktop, toggleMobile, sidebarVariant, setSidebarVariant, toggleAside, toggleNavbarPanel } =
|
||||
useCoreAppShell();
|
||||
|
||||
return (
|
||||
<Group mb="md">
|
||||
<Button onClick={toggleDesktop} variant="default" size="xs">
|
||||
Toggle Desktop Sidebar
|
||||
</Button>
|
||||
<Button onClick={toggleMobile} variant="default" size="xs" hiddenFrom="sm">
|
||||
Toggle Mobile Sidebar
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setSidebarVariant(sidebarVariant === 'expanded' ? 'mini' : 'expanded')}
|
||||
variant="default"
|
||||
size="xs"
|
||||
>
|
||||
Toggle Sidebar Variant (Current: {sidebarVariant})
|
||||
</Button>
|
||||
<Button onClick={toggleNavbarPanel} variant="light" color="grape" size="xs">
|
||||
Toggle Secondary Panel (Double Sidebar)
|
||||
</Button>
|
||||
<Button onClick={toggleAside} variant="filled" color="cyan" size="xs">
|
||||
Toggle Aside via Context
|
||||
</Button>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function MockHeader() {
|
||||
const { mobileOpened, toggleMobile } = useCoreAppShell();
|
||||
return (
|
||||
<Group
|
||||
h="100%"
|
||||
px="md"
|
||||
justify="space-between"
|
||||
bg="green.1"
|
||||
c="green.9"
|
||||
style={{ borderBottom: '1px solid var(--mantine-color-green-3)' }}
|
||||
>
|
||||
<Group>
|
||||
<Burger opened={mobileOpened} onClick={toggleMobile} hiddenFrom="sm" size="sm" />
|
||||
<Text fw={700} size="lg">
|
||||
Mock Header (bg="green.1")
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function MockMobileDrawer() {
|
||||
return (
|
||||
<Box p="md" h="100%" bg="yellow.1" c="yellow.9">
|
||||
<Text fw={700} mb="sm">
|
||||
Mock Mobile Drawer (bg="yellow.1")
|
||||
</Text>
|
||||
<Stack gap="xs">
|
||||
<Button variant="light" color="yellow" justify="flex-start" fullWidth>
|
||||
Mobile Dashboard
|
||||
</Button>
|
||||
<Button variant="light" color="yellow" justify="flex-start" fullWidth>
|
||||
Mobile Settings
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingRow({ title, description, control }: { title: string; description: string; control: ReactNode }) {
|
||||
return (
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Stack gap={0}>
|
||||
<Text fw={500}>{title}</Text>
|
||||
<Text c="dimmed" size="sm">
|
||||
{description}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Box>{control}</Box>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ShellDemo() {
|
||||
const [layoutVariant, setLayoutVariant] = useState<LayoutVariant>('header-first');
|
||||
const [collapseVariant, setCollapseVariant] = useState<DesktopCollapseVariant>('hide');
|
||||
const [withUtilityBar, setWithUtilityBar] = useState(true);
|
||||
const [withAside, setWithAside] = useState(true);
|
||||
const [withFooter, setWithFooter] = useState(true);
|
||||
const [withDoubleSidebar, setWithDoubleSidebar] = useState(true);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const liveConfig = useMemo(() => {
|
||||
return {
|
||||
variant: layoutVariant,
|
||||
features: {
|
||||
desktopCollapseVariant: collapseVariant,
|
||||
withUtilityBar: withUtilityBar ? undefined : false,
|
||||
withAside: withAside ? undefined : false,
|
||||
withFooter: withFooter ? undefined : false,
|
||||
withDoubleSidebar,
|
||||
},
|
||||
};
|
||||
}, [layoutVariant, collapseVariant, withUtilityBar, withAside, withFooter, withDoubleSidebar]);
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(JSON.stringify(liveConfig, null, 2));
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2500);
|
||||
};
|
||||
|
||||
const config: CoreAppShellConfig = {
|
||||
variant: layoutVariant,
|
||||
features: {
|
||||
desktopCollapseVariant: collapseVariant,
|
||||
withUtilityBar: withUtilityBar ? undefined : false,
|
||||
withAside: withAside ? undefined : false,
|
||||
withFooter: withFooter ? undefined : false,
|
||||
withDoubleSidebar,
|
||||
persistState: false, // Don't persist for the demo to avoid confusing other showcases
|
||||
disabled: false, // Allow nested render overrides if needed
|
||||
zIndex: 100,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<CoreAppShell
|
||||
config={config}
|
||||
slots={{
|
||||
utilityBar: (
|
||||
<Group h="100%" px="md" justify="flex-end" bg="blue.1" c="blue.9">
|
||||
<Text size="xs" fw={600}>
|
||||
Mock Utility Bar (bg="blue.1")
|
||||
</Text>
|
||||
</Group>
|
||||
),
|
||||
header: <MockHeader />,
|
||||
sidebarMobile: <MockMobileDrawer />,
|
||||
sidebar: !withDoubleSidebar ? (
|
||||
<Box
|
||||
p="md"
|
||||
h="100%"
|
||||
bg="grape.1"
|
||||
c="grape.9"
|
||||
style={{ borderRight: '1px solid var(--mantine-color-grape-3)' }}
|
||||
>
|
||||
<Text fw={700} mb="sm">
|
||||
Mock Standard Navbar (bg="grape.1")
|
||||
</Text>
|
||||
<Stack gap="xs">
|
||||
<Button variant="light" color="grape" justify="flex-start" fullWidth>
|
||||
Dashboard
|
||||
</Button>
|
||||
<Button variant="light" color="grape" justify="flex-start" fullWidth>
|
||||
Users
|
||||
</Button>
|
||||
<Button variant="light" color="grape" justify="flex-start" fullWidth>
|
||||
Reports
|
||||
</Button>
|
||||
<Button variant="light" color="grape" justify="flex-start" fullWidth>
|
||||
Settings
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
) : undefined,
|
||||
sidebarRail: withDoubleSidebar ? (
|
||||
<Stack align="center" gap="lg" pt="md" h="100%" bg="orange.1" c="orange.9">
|
||||
<Text size="xs" fw={700} style={{ writingMode: 'vertical-rl', transform: 'rotate(180deg)' }}>
|
||||
Mock Rail (bg="orange.1")
|
||||
</Text>
|
||||
<Home size={24} />
|
||||
<BarChart2 size={24} />
|
||||
<SettingsIcon size={24} />
|
||||
</Stack>
|
||||
) : undefined,
|
||||
sidebarPanel: withDoubleSidebar ? (
|
||||
<Box
|
||||
p="md"
|
||||
h="100%"
|
||||
bg="grape.1"
|
||||
c="grape.9"
|
||||
style={{ borderRight: '1px solid var(--mantine-color-grape-3)' }}
|
||||
>
|
||||
<Text fw={700} mb="sm">
|
||||
Mock Panel (bg="grape.1")
|
||||
</Text>
|
||||
<Stack gap="xs">
|
||||
<Button variant="light" color="grape" justify="flex-start" fullWidth>
|
||||
Dashboard
|
||||
</Button>
|
||||
<Button variant="light" color="grape" justify="flex-start" fullWidth>
|
||||
Users
|
||||
</Button>
|
||||
<Button variant="light" color="grape" justify="flex-start" fullWidth>
|
||||
Reports
|
||||
</Button>
|
||||
<Button variant="light" color="grape" justify="flex-start" fullWidth>
|
||||
Settings
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
) : undefined,
|
||||
aside: (
|
||||
<Box p="md" h="100%" bg="cyan.1" c="cyan.9">
|
||||
<Text fw={700} mb="md">
|
||||
Mock Aside
|
||||
</Text>
|
||||
<Text size="sm">This area could be used for notifications, help text, or contextual settings.</Text>
|
||||
</Box>
|
||||
),
|
||||
footer: (
|
||||
<Group
|
||||
h="100%"
|
||||
px="md"
|
||||
justify="space-between"
|
||||
bg="gray.1"
|
||||
style={{ borderTop: '1px solid var(--app-shell-border-color)' }}
|
||||
>
|
||||
<Text size="sm" fw={600}>
|
||||
Mock Footer (bg="gray.1")
|
||||
</Text>
|
||||
</Group>
|
||||
),
|
||||
}}
|
||||
>
|
||||
<CorePageContainer
|
||||
headerSlot={
|
||||
<Group justify="space-between" align="center">
|
||||
<Text component="h1" size="xl" fw={700} m={0}>
|
||||
Layout Engine Interactive Demo
|
||||
</Text>
|
||||
<Group>
|
||||
<Button component={Link} to="/" leftSection={<ArrowLeft size={16} />} variant="default">
|
||||
Back to Showcase
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
}
|
||||
stickyHeader
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Paper withBorder p="md" bg="var(--mantine-color-gray-0)">
|
||||
<LayoutControls />
|
||||
</Paper>
|
||||
|
||||
<SettingRow
|
||||
title="Layout Variant"
|
||||
description="Switch between standard cloud or SaaS layout styles."
|
||||
control={
|
||||
<Select
|
||||
value={layoutVariant}
|
||||
onChange={(value) => setLayoutVariant((value as LayoutVariant) || 'sidebar-first')}
|
||||
data={[
|
||||
{ label: 'Sidebar First (Alt)', value: 'sidebar-first' },
|
||||
{ label: 'Header First (Default)', value: 'header-first' },
|
||||
{ label: 'Top Nav (Hidden Sidebar)', value: 'top-nav' },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
title="Desktop Collapse Strategy"
|
||||
description="Determine if the sidebar shrinks to icons or slides out completely."
|
||||
control={
|
||||
<SegmentedControl
|
||||
value={collapseVariant}
|
||||
onChange={(value) => setCollapseVariant(value as DesktopCollapseVariant)}
|
||||
data={[
|
||||
{ label: 'Hide (Slide Out)', value: 'hide' },
|
||||
{ label: 'Mini (Shrink)', value: 'mini' },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
title="Enable Double Sidebar"
|
||||
description="Activate the Google-style rail and contextual panel navigation."
|
||||
control={
|
||||
<Switch
|
||||
checked={withDoubleSidebar}
|
||||
onChange={(event) => setWithDoubleSidebar(event.currentTarget.checked)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
title="Render Utility Bar"
|
||||
description="Show a system-level announcement bar above the main header."
|
||||
control={
|
||||
<Switch checked={withUtilityBar} onChange={(event) => setWithUtilityBar(event.currentTarget.checked)} />
|
||||
}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
title="Render Aside"
|
||||
description="Toggle the right-hand properties or filter panel."
|
||||
control={<Switch checked={withAside} onChange={(event) => setWithAside(event.currentTarget.checked)} />}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
title="Render Footer"
|
||||
description="Toggle the bottom application footer."
|
||||
control={<Switch checked={withFooter} onChange={(event) => setWithFooter(event.currentTarget.checked)} />}
|
||||
/>
|
||||
|
||||
<Box mt="xl">
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Text fw={700} size="lg">
|
||||
Configuration Preview
|
||||
</Text>
|
||||
<Button
|
||||
variant={copied ? 'filled' : 'light'}
|
||||
color={copied ? 'teal' : 'blue'}
|
||||
size="xs"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{copied ? 'Copied to Clipboard!' : 'Copy JSON'}
|
||||
</Button>
|
||||
</Group>
|
||||
<Paper withBorder p="md" bg="dark.8" c="gray.0" style={{ fontFamily: 'monospace', overflowX: 'auto' }}>
|
||||
<pre style={{ margin: 0 }}>{JSON.stringify(liveConfig, null, 2)}</pre>
|
||||
</Paper>
|
||||
</Box>
|
||||
</Stack>
|
||||
</CorePageContainer>
|
||||
</CoreAppShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Button, Card, Group, Stack, Title, Text, Table, Badge } from '@repo/ui/components';
|
||||
|
||||
import { itemDB, posConfigDB, newItemDB } from '../../../core/storage/pouch-db';
|
||||
import type { ItemEntity, POSConfigurationEntity } from '../../../core/storage/pouch-db/entities';
|
||||
|
||||
export default function PouchSample() {
|
||||
const [configs, setConfigs] = useState<POSConfigurationEntity[]>([]);
|
||||
const [items, setItems] = useState<ItemEntity[]>([]);
|
||||
const [newItems, setNewItems] = useState<ItemEntity[]>([]);
|
||||
|
||||
// Load initial data
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
const allConfigs = await posConfigDB.find({ selector: {} });
|
||||
setConfigs(allConfigs);
|
||||
|
||||
const allItems = await itemDB.find({ selector: {} });
|
||||
setItems(allItems);
|
||||
|
||||
// We can use getAll() or find() on Envelope DB. It's automatically scoped.
|
||||
const allNewItems = await newItemDB.getAll();
|
||||
console.log('allNewItems', allNewItems);
|
||||
setNewItems(allNewItems as ItemEntity[]);
|
||||
|
||||
console.log({ allConfigs, allItems, allNewItems });
|
||||
} catch (err) {
|
||||
console.error('Failed to load PouchDB data', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// 1. Initial Data Load
|
||||
loadData();
|
||||
|
||||
// 2. Setup Real-Time Listeners
|
||||
const unsubscribeItems = itemDB.onChange(() => {
|
||||
loadData();
|
||||
});
|
||||
|
||||
const unsubscribePos = posConfigDB.onChange(() => {
|
||||
loadData();
|
||||
});
|
||||
|
||||
const unsubscribeNewItems = newItemDB.onChange(() => {
|
||||
loadData();
|
||||
});
|
||||
|
||||
// 3. CRITICAL: Cleanup to prevent memory leaks on unmount
|
||||
return () => {
|
||||
unsubscribeItems();
|
||||
unsubscribePos();
|
||||
unsubscribeNewItems();
|
||||
};
|
||||
}, [loadData]);
|
||||
|
||||
// ─── POS Configuration Handlers ─────────────────────────────────
|
||||
|
||||
const handleSeedConfig = async () => {
|
||||
try {
|
||||
const id = `pos-${Date.now()}`;
|
||||
await posConfigDB.create({
|
||||
_id: id,
|
||||
pos_number: '1111111111666',
|
||||
pos_name: 'Premium Test POS TESTING COUNCH',
|
||||
items: items, // mapping current items
|
||||
payment_methods: [{ id: 'cash', name: 'Cash' }],
|
||||
});
|
||||
loadData();
|
||||
} catch (err) {
|
||||
console.error('Failed to seed config', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteConfig = async (id: string) => {
|
||||
try {
|
||||
await posConfigDB.delete(id);
|
||||
loadData();
|
||||
} catch (err) {
|
||||
console.error('Failed to delete config', err);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Items Inventory Handlers ───────────────────────────────────
|
||||
|
||||
const handleAddItem = async () => {
|
||||
try {
|
||||
const id = `item-${Date.now()}`;
|
||||
await itemDB.create({
|
||||
_id: id,
|
||||
name: 'PLAYGROUND ALL DAY TESTING POUCH',
|
||||
base_price: '75000',
|
||||
item_type: 'wahana',
|
||||
usage_type: 'ticket',
|
||||
item_category: [{ name: 'Entertainment' }],
|
||||
item_rates: [
|
||||
{ season_period: 'weekday', price: 50000 },
|
||||
{ season_period: 'weekend', price: 75000 },
|
||||
],
|
||||
});
|
||||
|
||||
loadData();
|
||||
} catch (err) {
|
||||
console.error('Failed to add item', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteItem = async (id: string) => {
|
||||
try {
|
||||
await itemDB.delete(id);
|
||||
loadData();
|
||||
} catch (err) {
|
||||
console.error('Failed to delete item', err);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── New Items Envelope Handlers ────────────────────────────────
|
||||
|
||||
const handleAddNewItem = async () => {
|
||||
try {
|
||||
const id = `new-item-${Date.now()}`;
|
||||
await newItemDB.create({
|
||||
_id: id,
|
||||
name: 'ENVELOPE ITEM TEST',
|
||||
base_price: '100000',
|
||||
item_type: 'souvenir',
|
||||
usage_type: 'retail',
|
||||
item_category: [{ name: 'Merchandise' }],
|
||||
});
|
||||
|
||||
loadData();
|
||||
} catch (err) {
|
||||
console.error('Failed to add new item', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteNewItem = async (id: string) => {
|
||||
try {
|
||||
await newItemDB.delete(id);
|
||||
loadData();
|
||||
} catch (err) {
|
||||
console.error('Failed to delete new item', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearAll = async () => {
|
||||
try {
|
||||
await posConfigDB.cleanAllData();
|
||||
await itemDB.cleanAllData();
|
||||
await newItemDB.cleanAllData();
|
||||
loadData();
|
||||
} catch (err) {
|
||||
console.error('Failed to clear data', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="xl">
|
||||
<Group justify="space-between">
|
||||
<Title order={2}>Enterprise PouchDB Sync</Title>
|
||||
<Button color="error" variant="outline" onClick={handleClearAll}>
|
||||
Clear All Local Data
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{/* Items Inventory Table */}
|
||||
<Card withBorder shadow="sm" radius="md" p="md">
|
||||
<Group justify="space-between" mb="md">
|
||||
<Title order={4}>Items Database (Standard)</Title>
|
||||
<Button onClick={handleAddItem} color="success">
|
||||
Inject Mock ERP Item
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<div className="max-h-[400px] overflow-y-auto border border-gray-200 rounded-lg scrollbar-thin scrollbar-thumb-gray-300">
|
||||
<Table striped highlightOnHover withTableBorder withColumnBorders>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th className="sticky top-0 bg-white z-10 shadow-sm">ID</Table.Th>
|
||||
<Table.Th className="sticky top-0 bg-white z-10 shadow-sm">Name</Table.Th>
|
||||
<Table.Th className="sticky top-0 bg-white z-10 shadow-sm">Type</Table.Th>
|
||||
<Table.Th className="sticky top-0 bg-white z-10 shadow-sm">Base Price</Table.Th>
|
||||
<Table.Th className="sticky top-0 bg-white z-10 shadow-sm">Rates Count</Table.Th>
|
||||
<Table.Th className="sticky top-0 bg-white z-10 shadow-sm">Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{items.length > 0 ? (
|
||||
items.map((item) => (
|
||||
<Table.Tr key={item._id}>
|
||||
<Table.Td>{item._id}</Table.Td>
|
||||
<Table.Td>{item.name}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color="brand" variant="light">
|
||||
{item.item_type}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>${Number(item.base_price).toFixed(2)}</Table.Td>
|
||||
<Table.Td>{item.item_rates?.length || 0}</Table.Td>
|
||||
<Table.Td>
|
||||
<Button size="xs" color="error" variant="subtle" onClick={() => handleDeleteItem(item._id)}>
|
||||
Delete
|
||||
</Button>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))
|
||||
) : (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={6} align="center">
|
||||
<Text c="dimmed">No items found.</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* New Items Envelope Table */}
|
||||
<Card withBorder shadow="sm" radius="md" p="md">
|
||||
<Group justify="space-between" mb="md">
|
||||
<Title order={4}>New Items Database (Envelope Pattern)</Title>
|
||||
<Button onClick={handleAddNewItem} color="brand">
|
||||
Inject Mock Envelope Item
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<div className="max-h-[400px] overflow-y-auto border border-gray-200 rounded-lg scrollbar-thin scrollbar-thumb-gray-300">
|
||||
<Table striped highlightOnHover withTableBorder withColumnBorders>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th className="sticky top-0 bg-white z-10 shadow-sm">ID</Table.Th>
|
||||
<Table.Th className="sticky top-0 bg-white z-10 shadow-sm">Name</Table.Th>
|
||||
<Table.Th className="sticky top-0 bg-white z-10 shadow-sm">Type</Table.Th>
|
||||
<Table.Th className="sticky top-0 bg-white z-10 shadow-sm">Base Price</Table.Th>
|
||||
<Table.Th className="sticky top-0 bg-white z-10 shadow-sm">Rates Count</Table.Th>
|
||||
<Table.Th className="sticky top-0 bg-white z-10 shadow-sm">Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{newItems.length > 0 ? (
|
||||
newItems.map((item) => (
|
||||
<Table.Tr key={item._id}>
|
||||
<Table.Td>{item._id}</Table.Td>
|
||||
<Table.Td>{item.name}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color="violet" variant="light">
|
||||
{item.item_type}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>${Number(item.base_price).toFixed(2)}</Table.Td>
|
||||
<Table.Td>{item.item_rates?.length || 0}</Table.Td>
|
||||
<Table.Td>
|
||||
<Button size="xs" color="error" variant="subtle" onClick={() => handleDeleteNewItem(item._id)}>
|
||||
Delete
|
||||
</Button>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))
|
||||
) : (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={6} align="center">
|
||||
<Text c="dimmed">No items found.</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* POS Configuration Table */}
|
||||
<Card withBorder shadow="sm" radius="md" p="md">
|
||||
<Group justify="space-between" mb="md">
|
||||
<Title order={4}>POS Configurations</Title>
|
||||
<Button onClick={handleSeedConfig} variant="light" color="brand">
|
||||
Inject Mock POS Config
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<div className="max-h-[400px] overflow-y-auto border border-gray-200 rounded-lg scrollbar-thin scrollbar-thumb-gray-300">
|
||||
<Table striped highlightOnHover withTableBorder withColumnBorders>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th className="sticky top-0 bg-white z-10 shadow-sm">ID</Table.Th>
|
||||
<Table.Th className="sticky top-0 bg-white z-10 shadow-sm">POS Name</Table.Th>
|
||||
<Table.Th className="sticky top-0 bg-white z-10 shadow-sm">POS Number</Table.Th>
|
||||
<Table.Th className="sticky top-0 bg-white z-10 shadow-sm">Mapped Items</Table.Th>
|
||||
<Table.Th className="sticky top-0 bg-white z-10 shadow-sm">Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{configs.length > 0 ? (
|
||||
configs.map((cfg) => (
|
||||
<Table.Tr key={cfg._id}>
|
||||
<Table.Td>{cfg._id}</Table.Td>
|
||||
<Table.Td>{cfg.pos_name}</Table.Td>
|
||||
<Table.Td>{cfg.pos_number}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color="info" variant="outline">
|
||||
{cfg.items?.length || 0} Items
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Button size="xs" color="error" variant="subtle" onClick={() => handleDeleteConfig(cfg._id)}>
|
||||
Delete
|
||||
</Button>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))
|
||||
) : (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={5} align="center">
|
||||
<Text c="dimmed">No configurations found.</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</div>
|
||||
</Card>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Stack, Container } from '@repo/ui/components';
|
||||
import PouchSample from './components/pouch-sample';
|
||||
|
||||
export default function StoragePage() {
|
||||
return (
|
||||
<Container size="xl" m={0} p={0}>
|
||||
<Stack gap="xl">
|
||||
<PouchSample />
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { Stack, Card, Title, Group, Select, Text, Badge, Divider, Button, TextInput, NumberInput, PasswordInput, Textarea, Checkbox, Switch, Table, StatusBadge, STATUS_DATA, Container } from '@repo/ui/components';
|
||||
import { useThemeStore } from '../../core/stores/theme.store';
|
||||
import { ColorSchemeType } from '@repo/ui/provider';
|
||||
|
||||
const tableData = [
|
||||
{ id: 'ORD-001', customer: 'John Doe', status: 'Shipped', total: '$120.00' },
|
||||
{ id: 'ORD-002', customer: 'Jane Smith', status: 'Pending', total: '$85.50' },
|
||||
{ id: 'ORD-003', customer: 'Acme Corp', status: 'Delivered', total: '$1,250.00' },
|
||||
];
|
||||
|
||||
export default function UiComponentsPage() {
|
||||
const { colorScheme, setColorScheme } = useThemeStore();
|
||||
|
||||
return (
|
||||
<Container size="xl" m={0} p={0}>
|
||||
<Stack gap="xl">
|
||||
{/* Control Panel */}
|
||||
<Card withBorder shadow="sm" radius="md" p="md">
|
||||
<Title order={4} mb="md">Theme Controls</Title>
|
||||
<Group grow align="flex-end">
|
||||
<Select
|
||||
label="Color Scheme"
|
||||
value={colorScheme}
|
||||
onChange={(val) => setColorScheme((val as ColorSchemeType) || 'light')}
|
||||
data={[
|
||||
{ value: 'light', label: 'Light' },
|
||||
{ value: 'dark', label: 'Dark' },
|
||||
]}
|
||||
/>
|
||||
</Group>
|
||||
</Card>
|
||||
|
||||
{/* Typography & Buttons */}
|
||||
<Card withBorder shadow="sm" radius="md" p="md">
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<Title order={4} mb="xs">Typography & Badges</Title>
|
||||
<Text size="sm" c="dimmed">This is dimmed small text indicating a subtitle.</Text>
|
||||
<Group mt="md">
|
||||
<Badge color="brand">Brand Badge</Badge>
|
||||
<Badge color="success" variant="light">Success Status</Badge>
|
||||
<Badge color="error" variant="outline">Error State</Badge>
|
||||
</Group>
|
||||
</div>
|
||||
<Divider />
|
||||
<div>
|
||||
<Title order={4} mb="xs">Enterprise Status Badges</Title>
|
||||
<Text size="sm" c="dimmed" mb="md">Pre-configured status badges for transaction and master data.</Text>
|
||||
<Group>
|
||||
<StatusBadge status={STATUS_DATA.DRAFT} />
|
||||
<StatusBadge status={STATUS_DATA.PENDING} />
|
||||
<StatusBadge status={STATUS_DATA.PROCESS} />
|
||||
<StatusBadge status={STATUS_DATA.APPROVED} />
|
||||
<StatusBadge status={STATUS_DATA.CANCELLED} />
|
||||
<StatusBadge status={STATUS_DATA.WAITING} />
|
||||
<StatusBadge status={STATUS_DATA.ON_HOLD} />
|
||||
<StatusBadge status={STATUS_DATA.REFUNDED} />
|
||||
</Group>
|
||||
</div>
|
||||
<Divider />
|
||||
<div>
|
||||
<Title order={4} mb="md">Buttons</Title>
|
||||
<Group>
|
||||
<Button variant="filled" color="brand">Filled Button</Button>
|
||||
<Button variant="outline" color="brand">Outline Button</Button>
|
||||
<Button variant="light" color="info">Light Info</Button>
|
||||
<Button variant="subtle" color="error">Cancel</Button>
|
||||
</Group>
|
||||
</div>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* Forms */}
|
||||
<Card withBorder shadow="sm" radius="md" p="md">
|
||||
<Title order={4} mb="md">Form Elements</Title>
|
||||
<Stack gap="md">
|
||||
<Group grow align="flex-start">
|
||||
<TextInput label="First Name" placeholder="Enter your first name" withAsterisk />
|
||||
<TextInput label="Last Name" placeholder="Enter your last name" />
|
||||
</Group>
|
||||
<Group grow align="flex-start">
|
||||
<NumberInput label="Age" placeholder="25" min={0} max={100} />
|
||||
<PasswordInput label="Password" placeholder="Your secret password" withAsterisk />
|
||||
</Group>
|
||||
<Textarea label="Bio" placeholder="Tell us about yourself" minRows={3} />
|
||||
<Group mt="sm">
|
||||
<Checkbox label="I agree to the terms and conditions" defaultChecked />
|
||||
<Switch label="Enable notifications" defaultChecked />
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* Data Grid */}
|
||||
<Card withBorder shadow="sm" radius="md" p="md">
|
||||
<Title order={4} mb="md">Data Grid</Title>
|
||||
<Table striped highlightOnHover withTableBorder withColumnBorders>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Order ID</Table.Th>
|
||||
<Table.Th>Customer</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>Total</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{tableData.map((row) => (
|
||||
<Table.Tr key={row.id}>
|
||||
<Table.Td>{row.id}</Table.Td>
|
||||
<Table.Td>{row.customer}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm" color={row.status === 'Delivered' ? 'success' : row.status === 'Shipped' ? 'info' : 'warning'}>
|
||||
{row.status}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>{row.total}</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Card>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Type declarations for the Electron preload API.
|
||||
*
|
||||
* When running inside Electron, `window.electronAPI` is defined.
|
||||
* When running in a regular browser, it is `undefined`.
|
||||
*
|
||||
* Usage:
|
||||
* if (window.electronAPI) {
|
||||
* const printers = await window.electronAPI.getPrinters();
|
||||
* }
|
||||
*/
|
||||
|
||||
// ─── Printer types ──────────────────────────────────────────────
|
||||
|
||||
interface ElectronPrinterInfo {
|
||||
name: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
status: number;
|
||||
isDefault: boolean;
|
||||
options?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface ElectronPrintOptions {
|
||||
silent?: boolean;
|
||||
printBackground?: boolean;
|
||||
deviceName?: string;
|
||||
color?: boolean;
|
||||
margins?: {
|
||||
marginType?: 'default' | 'none' | 'printableArea' | 'custom';
|
||||
top?: number;
|
||||
bottom?: number;
|
||||
left?: number;
|
||||
right?: number;
|
||||
};
|
||||
landscape?: boolean;
|
||||
scaleFactor?: number;
|
||||
pagesPerSheet?: number;
|
||||
collate?: boolean;
|
||||
copies?: number;
|
||||
pageRanges?: Array<{ from: number; to: number }>;
|
||||
duplexMode?: 'simplex' | 'shortEdge' | 'longEdge';
|
||||
header?: string;
|
||||
footer?: string;
|
||||
}
|
||||
|
||||
interface ElectronPrintResult {
|
||||
success: boolean;
|
||||
failureReason?: string;
|
||||
}
|
||||
|
||||
// ─── Auto-Update types ──────────────────────────────────────────
|
||||
|
||||
interface ElectronUpdateInfo {
|
||||
version: string;
|
||||
releaseDate: string;
|
||||
releaseName?: string | null;
|
||||
releaseNotes?: string | null;
|
||||
}
|
||||
|
||||
interface ElectronProgressInfo {
|
||||
total: number;
|
||||
delta: number;
|
||||
transferred: number;
|
||||
percent: number;
|
||||
bytesPerSecond: number;
|
||||
}
|
||||
|
||||
// ─── ElectronAPI interface ──────────────────────────────────────
|
||||
|
||||
interface ElectronAPI {
|
||||
// Printing
|
||||
getPrinters: () => Promise<ElectronPrinterInfo[]>;
|
||||
print: (options?: ElectronPrintOptions) => Promise<ElectronPrintResult>;
|
||||
|
||||
// Auto-Update: Commands
|
||||
checkForUpdates: () => void;
|
||||
installUpdate: () => void;
|
||||
|
||||
// Auto-Update: Event Subscriptions
|
||||
// Each returns an unsubscribe function.
|
||||
onUpdateChecking: (callback: () => void) => () => void;
|
||||
onUpdateAvailable: (callback: (info: ElectronUpdateInfo) => void) => () => void;
|
||||
onUpdateNotAvailable: (callback: (info: ElectronUpdateInfo) => void) => () => void;
|
||||
onDownloadProgress: (callback: (progress: ElectronProgressInfo) => void) => () => void;
|
||||
onUpdateDownloaded: (callback: (info: ElectronUpdateInfo) => void) => () => void;
|
||||
onUpdateError: (callback: (error: string) => void) => () => void;
|
||||
}
|
||||
|
||||
// ─── Augment the global Window interface ────────────────────────
|
||||
|
||||
interface Window {
|
||||
/**
|
||||
* Available only when running inside Electron.
|
||||
* Always check `if (window.electronAPI)` before use.
|
||||
*/
|
||||
electronAPI?: ElectronAPI;
|
||||
}
|
||||
Vendored
+80
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* App-level event registry for `apps/web`.
|
||||
*
|
||||
* This file uses TypeScript Declaration Merging (Module Augmentation)
|
||||
* to extend the open `AppEventRegistry` interface exported by
|
||||
* `@repo/core-events`. This is the IoC pattern in action:
|
||||
*
|
||||
* - `@repo/core-events` provides the bus, hooks, and helpers (the tool).
|
||||
* - `apps/web` defines which events exist and their payload shapes (the contract).
|
||||
*
|
||||
* The core package has zero knowledge of these events. If `apps/web`
|
||||
* is removed from the monorepo, the core package remains unchanged.
|
||||
*
|
||||
* **Adding new events**: Simply add new entries to `AppEventRegistry`
|
||||
* below. TypeScript will automatically provide autocomplete and
|
||||
* type safety across every `publish()` / `useAppEvent()` call in
|
||||
* the web app.
|
||||
*
|
||||
* @see packages/core-events/src/events.registry.ts
|
||||
*/
|
||||
|
||||
// This import turns this file from an ambient declaration into a
|
||||
// module augmentation. Without it, `declare module` would REPLACE
|
||||
// the module signature instead of merging into it.
|
||||
import type {} from '@repo/core-events';
|
||||
|
||||
declare module '@repo/core-events' {
|
||||
|
||||
// ─── Payload Types ──────────────────────────────────────────
|
||||
|
||||
interface ReceiptItem {
|
||||
name: string;
|
||||
qty: number;
|
||||
price: number;
|
||||
}
|
||||
|
||||
interface PrintReceiptPayload {
|
||||
receiptId: string;
|
||||
items: ReceiptItem[];
|
||||
total: number;
|
||||
cashierName: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface StockUpdatePayload {
|
||||
id: string;
|
||||
price: number;
|
||||
change: number;
|
||||
volume: number;
|
||||
}
|
||||
|
||||
interface ProfileUpdatedPayload {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
avatar: string;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
// ─── Event Registry ─────────────────────────────────────────
|
||||
|
||||
interface AppEventRegistry {
|
||||
// ── Device / Hardware ─────────────────────────────────────
|
||||
'DEVICE:PRINT_RECEIPT': PrintReceiptPayload;
|
||||
|
||||
// ── WebSocket / Real-Time ────────────────────────────────
|
||||
'WS:STOCK_UPDATE': StockUpdatePayload;
|
||||
|
||||
// ── Auth / User ──────────────────────────────────────────
|
||||
'AUTH:PROFILE_UPDATED': ProfileUpdatedPayload;
|
||||
|
||||
// ── App Lifecycle ────────────────────────────────────────
|
||||
'APP:INITIALIZED': undefined;
|
||||
'APP:ERROR': { message: string; code?: string };
|
||||
|
||||
// ── Layout ───────────────────────────────────────────────
|
||||
'LAYOUT:TOGGLE_HISTORY_DRAWER': undefined;
|
||||
'LAYOUT:TOGGLE_BOOKMARK_DRAWER': undefined;
|
||||
}
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "@repo/typescript-config/react-app.json",
|
||||
"include": ["src"],
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
server: {
|
||||
port: 3005,
|
||||
strictPort: true,
|
||||
},
|
||||
define: {
|
||||
global: 'window',
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
events: 'events',
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user