From 980952252dc4cdf2ec9a2f642cf7a7e953c99a09 Mon Sep 17 00:00:00 2001 From: Firman Ramdhani <33869609+firmanramdhani@users.noreply.github.com> Date: Thu, 23 Jul 2026 17:26:41 +0700 Subject: [PATCH] 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. --- .devcontainer/devcontainer.json | 7 +- apps/showcase/.eslintrc.cjs | 5 + apps/showcase/index.html | 13 + apps/showcase/package.json | 48 +++ apps/showcase/src/App.tsx | 49 +++ apps/showcase/src/core/constants/events.ts | 21 + .../src/core/hooks/use-electron-printer.ts | 110 +++++ .../src/core/hooks/use-electron-updater.ts | 140 ++++++ .../src/core/hooks/use-is-electron.ts | 7 + apps/showcase/src/core/lib/api-client.ts | 43 ++ apps/showcase/src/core/storage/local/index.ts | 44 ++ .../core/storage/pouch-db/entities/index.ts | 3 + .../pouch-db/entities/item.pouchdb.entity.ts | 27 ++ .../entities/new-item.pouchdb.entity.ts | 51 +++ .../pos-configuration.pouchdb.entity.ts | 16 + .../src/core/storage/pouch-db/index.ts | 45 ++ apps/showcase/src/core/stores/theme.store.ts | 30 ++ apps/showcase/src/layouts/ShowcaseLayout.tsx | 131 ++++++ apps/showcase/src/main.css | 3 + apps/showcase/src/main.tsx | 17 + .../showcase/src/pages/action-tools/index.tsx | 161 +++++++ apps/showcase/src/pages/ag-grid/index.tsx | 218 ++++++++++ apps/showcase/src/pages/auth/index.tsx | 14 + .../auth-sync/profile-settings.ui.tsx | 64 +++ .../auth-sync/storage-sync.listener.tsx | 36 ++ .../events/components/events-demo/index.tsx | 167 +++++++ .../events-demo/printer/cashier.ui.tsx | 109 +++++ .../events-demo/printer/printer.listener.tsx | 65 +++ .../stock-grid/live-stock-grid.ui.tsx | 187 ++++++++ .../stock-grid/mock-websocket.service.ts | 98 +++++ .../events-demo/stock-grid/stock-row.ui.tsx | 91 ++++ .../components/example/example.page.tsx | 22 + .../data/advanced-booking.data-services.ts | 118 +++++ .../data/advanced-booking.transformer.ts | 159 +++++++ .../booking/data/booking.data-services.ts | 58 +++ .../booking/data/booking.transformer.ts | 123 ++++++ .../booking/presentation/BookingSample.tsx | 92 ++++ .../features/i18n/languages/en/booking.json | 8 + .../features/i18n/languages/id/booking.json | 8 + .../features/i18n/presentation/I18nSample.tsx | 347 +++++++++++++++ .../storage/presentation/StorageSample.tsx | 280 ++++++++++++ apps/showcase/src/pages/events/index.tsx | 20 + .../form-demo/components/all-fields-demo.tsx | 408 ++++++++++++++++++ .../components/reactive-watch-demo.tsx | 368 ++++++++++++++++ .../components/validation-bank-demo.tsx | 254 +++++++++++ .../components/form-demo/form-demo-view.tsx | 31 ++ .../forms/components/form-demo/i18n/en.json | 117 +++++ .../forms/components/form-demo/i18n/id.json | 117 +++++ .../form-demo/i18n/useFormDemoTranslation.ts | 10 + .../pages/forms/components/form-demo/index.ts | 1 + apps/showcase/src/pages/forms/index.tsx | 12 + .../hardware/components/printer-list.tsx | 37 ++ apps/showcase/src/pages/hardware/index.tsx | 14 + apps/showcase/src/pages/rbac/index.tsx | 14 + apps/showcase/src/pages/shell-demo/index.tsx | 346 +++++++++++++++ .../pages/storage/components/pouch-sample.tsx | 324 ++++++++++++++ apps/showcase/src/pages/storage/index.tsx | 12 + .../src/pages/ui-components/index.tsx | 124 ++++++ apps/showcase/src/types/electron.d.ts | 98 +++++ apps/showcase/src/types/events.d.ts | 80 ++++ apps/showcase/src/vite-env.d.ts | 1 + apps/showcase/tsconfig.json | 7 + apps/showcase/vite.config.ts | 19 + package.json | 3 + pnpm-lock.yaml | 106 ++++- 65 files changed, 5749 insertions(+), 9 deletions(-) create mode 100644 apps/showcase/.eslintrc.cjs create mode 100644 apps/showcase/index.html create mode 100644 apps/showcase/package.json create mode 100644 apps/showcase/src/App.tsx create mode 100644 apps/showcase/src/core/constants/events.ts create mode 100644 apps/showcase/src/core/hooks/use-electron-printer.ts create mode 100644 apps/showcase/src/core/hooks/use-electron-updater.ts create mode 100644 apps/showcase/src/core/hooks/use-is-electron.ts create mode 100644 apps/showcase/src/core/lib/api-client.ts create mode 100644 apps/showcase/src/core/storage/local/index.ts create mode 100644 apps/showcase/src/core/storage/pouch-db/entities/index.ts create mode 100644 apps/showcase/src/core/storage/pouch-db/entities/item.pouchdb.entity.ts create mode 100644 apps/showcase/src/core/storage/pouch-db/entities/new-item.pouchdb.entity.ts create mode 100644 apps/showcase/src/core/storage/pouch-db/entities/pos-configuration.pouchdb.entity.ts create mode 100644 apps/showcase/src/core/storage/pouch-db/index.ts create mode 100644 apps/showcase/src/core/stores/theme.store.ts create mode 100644 apps/showcase/src/layouts/ShowcaseLayout.tsx create mode 100644 apps/showcase/src/main.css create mode 100644 apps/showcase/src/main.tsx create mode 100644 apps/showcase/src/pages/action-tools/index.tsx create mode 100644 apps/showcase/src/pages/ag-grid/index.tsx create mode 100644 apps/showcase/src/pages/auth/index.tsx create mode 100644 apps/showcase/src/pages/events/components/events-demo/auth-sync/profile-settings.ui.tsx create mode 100644 apps/showcase/src/pages/events/components/events-demo/auth-sync/storage-sync.listener.tsx create mode 100644 apps/showcase/src/pages/events/components/events-demo/index.tsx create mode 100644 apps/showcase/src/pages/events/components/events-demo/printer/cashier.ui.tsx create mode 100644 apps/showcase/src/pages/events/components/events-demo/printer/printer.listener.tsx create mode 100644 apps/showcase/src/pages/events/components/events-demo/stock-grid/live-stock-grid.ui.tsx create mode 100644 apps/showcase/src/pages/events/components/events-demo/stock-grid/mock-websocket.service.ts create mode 100644 apps/showcase/src/pages/events/components/events-demo/stock-grid/stock-row.ui.tsx create mode 100644 apps/showcase/src/pages/events/components/example/example.page.tsx create mode 100644 apps/showcase/src/pages/events/components/example/features/booking/data/advanced-booking.data-services.ts create mode 100644 apps/showcase/src/pages/events/components/example/features/booking/data/advanced-booking.transformer.ts create mode 100644 apps/showcase/src/pages/events/components/example/features/booking/data/booking.data-services.ts create mode 100644 apps/showcase/src/pages/events/components/example/features/booking/data/booking.transformer.ts create mode 100644 apps/showcase/src/pages/events/components/example/features/booking/presentation/BookingSample.tsx create mode 100644 apps/showcase/src/pages/events/components/example/features/i18n/languages/en/booking.json create mode 100644 apps/showcase/src/pages/events/components/example/features/i18n/languages/id/booking.json create mode 100644 apps/showcase/src/pages/events/components/example/features/i18n/presentation/I18nSample.tsx create mode 100644 apps/showcase/src/pages/events/components/example/features/storage/presentation/StorageSample.tsx create mode 100644 apps/showcase/src/pages/events/index.tsx create mode 100644 apps/showcase/src/pages/forms/components/form-demo/components/all-fields-demo.tsx create mode 100644 apps/showcase/src/pages/forms/components/form-demo/components/reactive-watch-demo.tsx create mode 100644 apps/showcase/src/pages/forms/components/form-demo/components/validation-bank-demo.tsx create mode 100644 apps/showcase/src/pages/forms/components/form-demo/form-demo-view.tsx create mode 100644 apps/showcase/src/pages/forms/components/form-demo/i18n/en.json create mode 100644 apps/showcase/src/pages/forms/components/form-demo/i18n/id.json create mode 100644 apps/showcase/src/pages/forms/components/form-demo/i18n/useFormDemoTranslation.ts create mode 100644 apps/showcase/src/pages/forms/components/form-demo/index.ts create mode 100644 apps/showcase/src/pages/forms/index.tsx create mode 100644 apps/showcase/src/pages/hardware/components/printer-list.tsx create mode 100644 apps/showcase/src/pages/hardware/index.tsx create mode 100644 apps/showcase/src/pages/rbac/index.tsx create mode 100644 apps/showcase/src/pages/shell-demo/index.tsx create mode 100644 apps/showcase/src/pages/storage/components/pouch-sample.tsx create mode 100644 apps/showcase/src/pages/storage/index.tsx create mode 100644 apps/showcase/src/pages/ui-components/index.tsx create mode 100644 apps/showcase/src/types/electron.d.ts create mode 100644 apps/showcase/src/types/events.d.ts create mode 100644 apps/showcase/src/vite-env.d.ts create mode 100644 apps/showcase/tsconfig.json create mode 100644 apps/showcase/vite.config.ts diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index f759ae2..5d1677a 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -18,7 +18,8 @@ // 4173: VitePress Docs (Preview) // 5173: Web App (Must be strictly 5173 for Electron IPC compatibility) // 3000: Landing App (Isolated from Vite's default 517x blast radius) - "forwardPorts": [6060, 4173, 5173, 3000], + // 3005: Showcase App + "forwardPorts": [6060, 4173, 5173, 3000, 3005], "portsAttributes": { "6060": { @@ -33,6 +34,10 @@ "label": "Web App (Electron Target)", "onAutoForward": "notify" }, + "3005": { + "label": "Showcase App", + "onAutoForward": "notify" + }, "3000": { "label": "Landing App (Public)", "onAutoForward": "notify" diff --git a/apps/showcase/.eslintrc.cjs b/apps/showcase/.eslintrc.cjs new file mode 100644 index 0000000..a779c57 --- /dev/null +++ b/apps/showcase/.eslintrc.cjs @@ -0,0 +1,5 @@ +/** @type {import("eslint").Linter.Config} */ +module.exports = { + root: true, + extends: ['@repo/eslint-config/react.js'], +}; diff --git a/apps/showcase/index.html b/apps/showcase/index.html new file mode 100644 index 0000000..e09f575 --- /dev/null +++ b/apps/showcase/index.html @@ -0,0 +1,13 @@ + + + + + + + Showcase + + +
+ + + diff --git a/apps/showcase/package.json b/apps/showcase/package.json new file mode 100644 index 0000000..def6253 --- /dev/null +++ b/apps/showcase/package.json @@ -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" + } +} diff --git a/apps/showcase/src/App.tsx b/apps/showcase/src/App.tsx new file mode 100644 index 0000000..e145187 --- /dev/null +++ b/apps/showcase/src/App.tsx @@ -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('compact'); + + return ( + + + + Loading...}> + + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + + + + + + ); +} diff --git a/apps/showcase/src/core/constants/events.ts b/apps/showcase/src/core/constants/events.ts new file mode 100644 index 0000000..0cdd2f4 --- /dev/null +++ b/apps/showcase/src/core/constants/events.ts @@ -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; diff --git a/apps/showcase/src/core/hooks/use-electron-printer.ts b/apps/showcase/src/core/hooks/use-electron-printer.ts new file mode 100644 index 0000000..bf732b4 --- /dev/null +++ b/apps/showcase/src/core/hooks/use-electron-printer.ts @@ -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; + /** Print with the given options. Returns success/failure. */ + print: (options?: ElectronPrintOptions) => Promise; +} + +/** + * 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 ( + * + * ); + * } + * ``` + */ +export function useElectronPrinter(): UseElectronPrinterReturn { + const [printers, setPrinters] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const isElectron = useIsElectron(); + + const refreshPrinters = useCallback(async (): Promise => { + 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 => { + 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, + }; +} diff --git a/apps/showcase/src/core/hooks/use-electron-updater.ts b/apps/showcase/src/core/hooks/use-electron-updater.ts new file mode 100644 index 0000000..1fa8cf5 --- /dev/null +++ b/apps/showcase/src/core/hooks/use-electron-updater.ts @@ -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
Update {updateInfo?.version} available! Downloading...
; + * } + * if (status === 'downloading') { + * return
Downloading... {progress.toFixed(0)}%
; + * } + * if (status === 'ready') { + * return ; + * } + * return ; + * } + * ``` + */ +export function useElectronUpdater(): UseElectronUpdaterReturn { + const [status, setStatus] = useState('idle'); + const [progress, setProgress] = useState(0); + const [bytesPerSecond, setBytesPerSecond] = useState(0); + const [updateInfo, setUpdateInfo] = useState(null); + const [errorMessage, setErrorMessage] = useState(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, + }; +} diff --git a/apps/showcase/src/core/hooks/use-is-electron.ts b/apps/showcase/src/core/hooks/use-is-electron.ts new file mode 100644 index 0000000..7f8bbb9 --- /dev/null +++ b/apps/showcase/src/core/hooks/use-is-electron.ts @@ -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; +} diff --git a/apps/showcase/src/core/lib/api-client.ts b/apps/showcase/src/core/lib/api-client.ts new file mode 100644 index 0000000..0fea5e4 --- /dev/null +++ b/apps/showcase/src/core/lib/api-client.ts @@ -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(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; + }, + }, +); diff --git a/apps/showcase/src/core/storage/local/index.ts b/apps/showcase/src/core/storage/local/index.ts new file mode 100644 index 0000000..6f38f55 --- /dev/null +++ b/apps/showcase/src/core/storage/local/index.ts @@ -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([ + AppStorageKey.USER_PROFILE, + AppStorageKey.ACCESS_TOKEN, + AppStorageKey.REFRESH_TOKEN, +]); + +export const PLAIN_KEYS = new Set([ + 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({ + encryptedKeys: ENCRYPTED_KEYS, + plainTextKeys: PLAIN_KEYS, +}); + +export const secureIndexedDB = createIndexedDB({ + dbName: 'e_apps_db', + storeName: 'web_store', + encryptedKeys: ENCRYPTED_KEYS, + plainTextKeys: PLAIN_KEYS, +}); diff --git a/apps/showcase/src/core/storage/pouch-db/entities/index.ts b/apps/showcase/src/core/storage/pouch-db/entities/index.ts new file mode 100644 index 0000000..686679f --- /dev/null +++ b/apps/showcase/src/core/storage/pouch-db/entities/index.ts @@ -0,0 +1,3 @@ +export * from './item.pouchdb.entity'; +export * from './pos-configuration.pouchdb.entity'; +export * from './new-item.pouchdb.entity'; diff --git a/apps/showcase/src/core/storage/pouch-db/entities/item.pouchdb.entity.ts b/apps/showcase/src/core/storage/pouch-db/entities/item.pouchdb.entity.ts new file mode 100644 index 0000000..feefa14 --- /dev/null +++ b/apps/showcase/src/core/storage/pouch-db/entities/item.pouchdb.entity.ts @@ -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; +} diff --git a/apps/showcase/src/core/storage/pouch-db/entities/new-item.pouchdb.entity.ts b/apps/showcase/src/core/storage/pouch-db/entities/new-item.pouchdb.entity.ts new file mode 100644 index 0000000..c03b4a4 --- /dev/null +++ b/apps/showcase/src/core/storage/pouch-db/entities/new-item.pouchdb.entity.ts @@ -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; +} diff --git a/apps/showcase/src/core/storage/pouch-db/entities/pos-configuration.pouchdb.entity.ts b/apps/showcase/src/core/storage/pouch-db/entities/pos-configuration.pouchdb.entity.ts new file mode 100644 index 0000000..3bed0c3 --- /dev/null +++ b/apps/showcase/src/core/storage/pouch-db/entities/pos-configuration.pouchdb.entity.ts @@ -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; +} diff --git a/apps/showcase/src/core/storage/pouch-db/index.ts b/apps/showcase/src/core/storage/pouch-db/index.ts new file mode 100644 index 0000000..8e3ff4b --- /dev/null +++ b/apps/showcase/src/core/storage/pouch-db/index.ts @@ -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({ + localName: 'pos_configuration', + remoteUrl: buildRemoteUrl('pos_configuration'), +}); + +/** Items database — products available for sale in POS. */ +export const itemDB = dbManager.register({ + 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', +); diff --git a/apps/showcase/src/core/stores/theme.store.ts b/apps/showcase/src/core/stores/theme.store.ts new file mode 100644 index 0000000..d780321 --- /dev/null +++ b/apps/showcase/src/core/stores/theme.store.ts @@ -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()( + persist( + (set) => ({ + colorScheme: 'light', + setColorScheme: (scheme) => set({ colorScheme: scheme }), + }), + { + name: AppStorageKey.THEME, // matches AppStorageKey.THEME + storage: createJSONStorage(() => localStorage), + }, + ), +); diff --git a/apps/showcase/src/layouts/ShowcaseLayout.tsx b/apps/showcase/src/layouts/ShowcaseLayout.tsx new file mode 100644 index 0000000..6a96b27 --- /dev/null +++ b/apps/showcase/src/layouts/ShowcaseLayout.tsx @@ -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 ( + + + + Eigen ERP + Architecture Showcase + + + {navItems.map((item) => ( + } + onClick={() => navigate(item.path)} + variant="filled" + style={{ borderRadius: 'var(--mantine-radius-md)', marginBottom: 4 }} + /> + ))} + + + + setDensity((val as DensityType) || 'standard')} + data={[ + { value: 'compact', label: 'Compact' }, + { value: 'standard', label: 'Standard' }, + ]} + /> + + + + + + + + Architecture Showcase + {getSubtitle()} + + setRowModelType(val || 'clientSide')} + data={[ + { value: 'clientSide', label: 'Client Side' }, + { value: 'serverSide', label: 'Server Side (no datasource)' }, + ]} + w={260} + /> + + + + {/* ── AG Grid ──────────────────────────────────── */} + + + Orders Data Grid + +
+ + rowData={SAMPLE_DATA} + columnDefs={columnDefs} + defaultColDef={defaultColDef} + rowSelection={{ mode: 'multiRow', checkboxes: true }} + pagination + paginationPageSize={10} + animateRows + enableCellTextSelection + suppressCopyRowsToClipboard + /> +
+
+ + + ); +} diff --git a/apps/showcase/src/pages/auth/index.tsx b/apps/showcase/src/pages/auth/index.tsx new file mode 100644 index 0000000..0cb1bf1 --- /dev/null +++ b/apps/showcase/src/pages/auth/index.tsx @@ -0,0 +1,14 @@ +import { Stack, Container, Card, Title, Text } from '@repo/ui/components'; + +export default function AuthPage() { + return ( + + + + Auth & Security + Auth Demo Component Coming Soon... + + + + ); +} diff --git a/apps/showcase/src/pages/events/components/events-demo/auth-sync/profile-settings.ui.tsx b/apps/showcase/src/pages/events/components/events-demo/auth-sync/profile-settings.ui.tsx new file mode 100644 index 0000000..a72a1d0 --- /dev/null +++ b/apps/showcase/src/pages/events/components/events-demo/auth-sync/profile-settings.ui.tsx @@ -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 ( + + + setName(e.currentTarget.value)} size="sm" /> + setEmail(e.currentTarget.value)} size="sm" /> + + + setAvatar(e.currentTarget.value)} size="sm" /> + + + + {saveCount > 0 && ( + + Synced {saveCount}× + + )} + + + ); +} diff --git a/apps/showcase/src/pages/events/components/events-demo/auth-sync/storage-sync.listener.tsx b/apps/showcase/src/pages/events/components/events-demo/auth-sync/storage-sync.listener.tsx new file mode 100644 index 0000000..e138a5a --- /dev/null +++ b/apps/showcase/src/pages/events/components/events-demo/auth-sync/storage-sync.listener.tsx @@ -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; +} diff --git a/apps/showcase/src/pages/events/components/events-demo/index.tsx b/apps/showcase/src/pages/events/components/events-demo/index.tsx new file mode 100644 index 0000000..0f4947b --- /dev/null +++ b/apps/showcase/src/pages/events/components/events-demo/index.tsx @@ -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([]); + + // ── Showcase 3: Storage sync status feedback ───────────────── + const [syncLog, setSyncLog] = useState([]); + + // ── 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 ( + +
+ 🔌 Event Bus Showcase + + Three real-world demos of @repo/core-events — zero coupling, strict typing, high performance. + + + Parent render count: {renderCount.current} + +
+ + {/* ═══════════════════════════════════════════════════════════ + SHOWCASE 1: Cross-Platform Printer Abstraction + ═══════════════════════════════════════════════════════════ */} + + + 🖨️ Showcase 1: Cross-Platform Printer Abstraction + + + The CashierUI publishes a {DEVICE_EVENTS.PRINT_RECEIPT} event. + The PrinterListener listens for it and simulates interacting with a physical printer. + + + {/* Headless listener — renders nothing visible */} + setPrinterLog((prev) => [...prev, `[${new Date().toLocaleTimeString()}] ${msg}`])} + /> + + + + {printerLog.length > 0 && ( + <> + + + 📋 Printer Log: + +
+ {printerLog.map((line, i) => ( +
{line}
+ ))} +
+ + )} +
+ + {/* ═══════════════════════════════════════════════════════════ + SHOWCASE 2: Extreme Performance — Live Stock Grid + ═══════════════════════════════════════════════════════════ */} + + + 📈 Showcase 2: High-Frequency Real-Time Data (50 updates/sec) + + + A mock WebSocket fires WS:STOCK_UPDATE every 20ms. + Each StockRow subscribes to the global event but only updates when{' '} + payload.id === row.id. The parent grid never re-renders. + + + + + + {/* ═══════════════════════════════════════════════════════════ + SHOWCASE 3: Auth/Profile → IndexedDB Sync + ═══════════════════════════════════════════════════════════ */} + + + 💾 Showcase 3: Auth/Profile Sync with <code>@repo/core-storage</code> + + + ProfileSettingsUI publishes {AUTH_EVENTS.PROFILE_UPDATED}. + StorageSyncListener silently catches it in the background and saves to IndexedDB via secureIndexedDB. + + + {/* Headless listener — renders nothing visible */} + setSyncLog((prev) => [...prev, `[${new Date().toLocaleTimeString()}] ${msg}`])} + /> + + + + {syncLog.length > 0 && ( + <> + + + 📋 Storage Sync Log: + +
+ {syncLog.map((line, i) => ( +
{line}
+ ))} +
+ + )} +
+
+ ); +} diff --git a/apps/showcase/src/pages/events/components/events-demo/printer/cashier.ui.tsx b/apps/showcase/src/pages/events/components/events-demo/printer/cashier.ui.tsx new file mode 100644 index 0000000..9b66188 --- /dev/null +++ b/apps/showcase/src/pages/events/components/events-demo/printer/cashier.ui.tsx @@ -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(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 ( + + setCashierName(e.currentTarget.value)} + size="sm" + style={{ maxWidth: 250 }} + /> + + + + + Item + Qty + Price + Subtotal + + + + {items.map((item, idx) => ( + + {item.name} + {item.qty} + ${item.price.toFixed(2)} + ${(item.qty * item.price).toFixed(2)} + + ))} + + + + + Total + + + ${total.toFixed(2)} + + + +
+ + + + {printCount > 0 && ( + + Printed {printCount}× + + )} + +
+ ); +} diff --git a/apps/showcase/src/pages/events/components/events-demo/printer/printer.listener.tsx b/apps/showcase/src/pages/events/components/events-demo/printer/printer.listener.tsx new file mode 100644 index 0000000..eb52f3e --- /dev/null +++ b/apps/showcase/src/pages/events/components/events-demo/printer/printer.listener.tsx @@ -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; +} diff --git a/apps/showcase/src/pages/events/components/events-demo/stock-grid/live-stock-grid.ui.tsx b/apps/showcase/src/pages/events/components/events-demo/stock-grid/live-stock-grid.ui.tsx new file mode 100644 index 0000000..236ad5f --- /dev/null +++ b/apps/showcase/src/pages/events/components/events-demo/stock-grid/live-stock-grid.ui.tsx @@ -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 | 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 | 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 ( + + {/* ── Controls ──────────────────────────────────────────── */} + + {!isRunning ? ( + + ) : ( + + )} + + + + {/* ── Stats Bar ─────────────────────────────────────────── */} + + + Grid renders: {renderCount.current} + + + Total events: {eventStats.total.toLocaleString()} + + + Events/sec: {eventStats.perSec} + + + Subscribed rows: {STOCK_COUNT} | Visible: {visibleIds.length} + + + + + Each row shows its own render count in the last column. Only rows receiving updates re-render. + + + {/* ── Data Grid ─────────────────────────────────────────── */} +
+ + + + + + + + + + + + {visibleIds.map((id) => ( + + ))} + +
+ Ticker + + Price + + Change + + Volume + + Renders +
+
+
+ ); +} diff --git a/apps/showcase/src/pages/events/components/events-demo/stock-grid/mock-websocket.service.ts b/apps/showcase/src/pages/events/components/events-demo/stock-grid/mock-websocket.service.ts new file mode 100644 index 0000000..d841e80 --- /dev/null +++ b/apps/showcase/src/pages/events/components/events-demo/stock-grid/mock-websocket.service.ts @@ -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(); + 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, + }; +} diff --git a/apps/showcase/src/pages/events/components/events-demo/stock-grid/stock-row.ui.tsx b/apps/showcase/src/pages/events/components/events-demo/stock-grid/stock-row.ui.tsx new file mode 100644 index 0000000..8d38047 --- /dev/null +++ b/apps/showcase/src/pages/events/components/events-demo/stock-grid/stock-row.ui.tsx @@ -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(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 ( + + {stockId} + + {data ? `$${data.price.toFixed(2)}` : '—'} + + + {data ? `${changeArrow} ${data.change >= 0 ? '+' : ''}${data.change.toFixed(2)}` : '—'} + + + {data ? data.volume.toLocaleString() : '—'} + + + {renderCountRef.current} + + + ); +}); diff --git a/apps/showcase/src/pages/events/components/example/example.page.tsx b/apps/showcase/src/pages/events/components/example/example.page.tsx new file mode 100644 index 0000000..236318f --- /dev/null +++ b/apps/showcase/src/pages/events/components/example/example.page.tsx @@ -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 ( +
+ example +
+

Enterprise Web App

+ +
+
+

Enterprise Web App

+ +
+
+ +
+
+ ); +} diff --git a/apps/showcase/src/pages/events/components/example/features/booking/data/advanced-booking.data-services.ts b/apps/showcase/src/pages/events/components/example/features/booking/data/advanced-booking.data-services.ts new file mode 100644 index 0000000..b5c24d3 --- /dev/null +++ b/apps/showcase/src/pages/events/components/example/features/booking/data/advanced-booking.data-services.ts @@ -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 { + /** + * 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> { + const response = await this.customRequest({ + 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(); diff --git a/apps/showcase/src/pages/events/components/example/features/booking/data/advanced-booking.transformer.ts b/apps/showcase/src/pages/events/components/example/features/booking/data/advanced-booking.transformer.ts new file mode 100644 index 0000000..657d803 --- /dev/null +++ b/apps/showcase/src/pages/events/components/example/features/booking/data/advanced-booking.transformer.ts @@ -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, + })); + } +} diff --git a/apps/showcase/src/pages/events/components/example/features/booking/data/booking.data-services.ts b/apps/showcase/src/pages/events/components/example/features/booking/data/booking.data-services.ts new file mode 100644 index 0000000..2cd74ce --- /dev/null +++ b/apps/showcase/src/pages/events/components/example/features/booking/data/booking.data-services.ts @@ -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(apiClient, { + apiUrl: '/bookings', + moduleKey: 'BOOKING', + transformer: new BookingTransformer(), +}); + diff --git a/apps/showcase/src/pages/events/components/example/features/booking/data/booking.transformer.ts b/apps/showcase/src/pages/events/components/example/features/booking/data/booking.transformer.ts new file mode 100644 index 0000000..25fcb6e --- /dev/null +++ b/apps/showcase/src/pages/events/components/example/features/booking/data/booking.transformer.ts @@ -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 { + /** + * 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): Partial { + const dto = this.transformToDTO(entity as BookingEntity); + const { id: _, ...rest } = dto; + return rest; + } +} diff --git a/apps/showcase/src/pages/events/components/example/features/booking/presentation/BookingSample.tsx b/apps/showcase/src/pages/events/components/example/features/booking/presentation/BookingSample.tsx new file mode 100644 index 0000000..5cdbccb --- /dev/null +++ b/apps/showcase/src/pages/events/components/example/features/booking/presentation/BookingSample.tsx @@ -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 | null>(null); + const [error, setError] = useState(null); + + const handleFetch = async () => { + setLoading(true); + setError(null); + setResult(null); + + try { + const response = await bookingServices.getMany({ + 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 ( +
+

🧪 Booking Data Services — Integration Test

+

+ Pipeline: Faro + Custom Span "booking.list.fetch" → Bearer Token → GET /bookings +

+ + + + {error && ( +
+          ❌ {error}
+        
+ )} + + {result && ( +
+          {JSON.stringify(result, null, 2)}
+        
+ )} +
+ ); +} diff --git a/apps/showcase/src/pages/events/components/example/features/i18n/languages/en/booking.json b/apps/showcase/src/pages/events/components/example/features/i18n/languages/en/booking.json new file mode 100644 index 0000000..8e8835d --- /dev/null +++ b/apps/showcase/src/pages/events/components/example/features/i18n/languages/en/booking.json @@ -0,0 +1,8 @@ +{ + "module_name": "Purchasing", + "select_date": "Select Date", + "header": { + "title": "Transaction List", + "subtitle": "Manage all your transactions here" + } +} diff --git a/apps/showcase/src/pages/events/components/example/features/i18n/languages/id/booking.json b/apps/showcase/src/pages/events/components/example/features/i18n/languages/id/booking.json new file mode 100644 index 0000000..bf22e68 --- /dev/null +++ b/apps/showcase/src/pages/events/components/example/features/i18n/languages/id/booking.json @@ -0,0 +1,8 @@ +{ + "module_name": "Pembelanjaan", + "select_date": "Pilih Tanggal", + "header": { + "title": "Daftar Transaksi", + "subtitle": "Kelola semua transaksi Anda di sini" + } +} diff --git a/apps/showcase/src/pages/events/components/example/features/i18n/presentation/I18nSample.tsx b/apps/showcase/src/pages/events/components/example/features/i18n/presentation/I18nSample.tsx new file mode 100644 index 0000000..8a04f9b --- /dev/null +++ b/apps/showcase/src/pages/events/components/example/features/i18n/presentation/I18nSample.tsx @@ -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(''); + const [activeTenant, setActiveTenant] = useState('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('No data in DB'); + + const MOCK_DB_KEY = AppStorageKey.MOCK_DB_COMPANY_A; + + const loadDbPayload = useCallback(async () => { + try { + const data = await secureIndexedDB.getItem(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 => { + if (companyId === 'company-a') { + const data = await secureIndexedDB.getItem(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 ( +
+

🌐 Enterprise i18n Demo

+

+ Current Active Language: {activeLang} +

+ + {/* ─── Admin Panel ──────────────────────────────────────────── */} +
+

Admin Panel (Company A Config)

+

+ Simulate a backend CMS. Save the vocabulary overrides to IndexedDB. +

+ +
+ + +
+ + + +
+
Raw JSON in DB:
+
+            {dbPayloadStr}
+          
+
+
+ + {/* ─── Section A ────────────────────────────────────────────── */} +
+

A. Language Switcher & Backend Sync

+

+ Change the language. The callback simulates a 1-second backend API request. +

+ +
+ + + +
+ + {syncStatus && ( +
+ {syncStatus} +
+ )} + + {/* UI Result untuk Section A */} +
+

UI Result (Live Dictionary):

+

+ common:save + {t('common:save')} +

+

+ booking:select_date + {t('booking:select_date')} +

+
+
+ + {/* ─── Section B ────────────────────────────────────────────── */} +
+

B. Dynamic Tenant Overrides (End-to-End)

+

+ Simulates a user logging in. It fetches the config directly from IndexedDB (mock database) and applies the + deep-merge override. +

+ +
+ + + +
+ + {isFetchingConfig && ( +
⏳ Fetching tenant config...
+ )} + + {/* Display the localized strings */} +
+

UI Result (Tenant Overlay):

+ + + + + + + + + + + + + + + + + + + +
KeyValue
+ booking:module_name + {t('booking:module_name')}
+ booking:header.title + {t('booking:header.title')}
+ booking:header.subtitle + {t('booking:header.subtitle')}
+
+
+
+ ); +} diff --git a/apps/showcase/src/pages/events/components/example/features/storage/presentation/StorageSample.tsx b/apps/showcase/src/pages/events/components/example/features/storage/presentation/StorageSample.tsx new file mode 100644 index 0000000..2b87b2b --- /dev/null +++ b/apps/showcase/src/pages/events/components/example/features/storage/presentation/StorageSample.tsx @@ -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 ( +
+ {actions.map(({ label, handler, color }) => ( + + ))} +
+ ); +} + +// ─── 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('(no data read yet)'); + const [idbResult, setIdbResult] = useState('(no data read yet)'); + const [log, setLog] = useState([]); + + 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(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(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(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(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 ( +
+

🔐 @repo/core-storage — Dual Backend CRUD Demo

+ +
+ {/* ── Left: localStorage ─────────────────────────────────── */} +
+

📦 localStorage (AES Encrypted)

+

+ Key: {LS_KEY} — stored encrypted at rest +
+ Verify: DevTools → Application → Local Storage +

+ + + +
{lsResult}
+
+ + {/* ── Right: IndexedDB ───────────────────────────────────── */} +
+

🗃️ IndexedDB (app_db / kv_store)

+

+ Key: {IDB_KEY} — plain JSON (not in ENCRYPTED_KEYS) +
+ Verify: DevTools → Application → IndexedDB → app_db +

+ + + +
{idbResult}
+
+
+ + {/* ── Shared Action Log ────────────────────────────────────── */} +

📋 Action Log

+
+ {log.length === 0 ? ( + (no actions yet) + ) : ( + log.map((entry, i) =>
{entry}
) + )} +
+
+ ); +} diff --git a/apps/showcase/src/pages/events/index.tsx b/apps/showcase/src/pages/events/index.tsx new file mode 100644 index 0000000..e5a4893 --- /dev/null +++ b/apps/showcase/src/pages/events/index.tsx @@ -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 ( + + + + Nested Showcase Example + This is an example of a nested showcase component. + + + + + + + + ); +} diff --git a/apps/showcase/src/pages/forms/components/form-demo/components/all-fields-demo.tsx b/apps/showcase/src/pages/forms/components/form-demo/components/all-fields-demo.tsx new file mode 100644 index 0000000..73f84c4 --- /dev/null +++ b/apps/showcase/src/pages/forms/components/form-demo/components/all-fields-demo.tsx @@ -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 = 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 = 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 = 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({ + 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: "

ERP Release Notes

This is a highly important update. Please observe the following:

  • System maintenance at midnight.
  • All users must log out.

Thank you for your cooperation.

", + realPokeSelect: null, + multiRealPokeSelect: [] + } + }); + + const onSubmit = (data: any) => console.log('All Fields Submitted:', data); + const data = watch(); + + return ( + + +
+ + {/* --- Text & Numbers --- */} +
+ {t.sections.textAndNumbers} + + + + + + + + + + + + + +
+ {t.fields.pin} + +
+
+ + {/* --- Selections --- */} +
+ {t.sections.selections} + + + + + + + + + + + + `${item.name} (${item.hex})`} + clearable + /> + + + + + + + + + + + + {t.sections.advancedObjectSelects} + + + `[${item.code}] ${item.name}`} + clearable + /> + `[${item.code}] ${item.name}`} + clearable + /> + + + `[${item.code}] ${item.name}`} + clearable + /> + `[${item.code}] ${item.name}`} + defaultOptions={[{ id: 999, code: 'ASYNC-99', name: 'Pre-loaded Async Vendor' }]} + clearable + /> + + + {t.sections.multiSelectEditMode} + + + `[${item.code}] ${item.name}`} + clearable + /> + `[${item.code}] ${item.name}`} + clearable + /> + + + {t.sections.richTextEditor} + + +
+ +
+
+ + {/* --- Toggles & Choices --- */} +
+ {t.sections.togglesAndChoices} + + + + + + + + + + + + +
+ Chip Selection + + + Option 1 + Option 2 + + +
+
+ + {/* --- Ranges & Specialized --- */} +
+ {t.sections.rangesAndSpecialized} + + + + + + + + + + +
+ {t.fields.themeColor} Picker + +
+
+ {t.fields.rating} + +
+
+
+ + +
+
+
+ + + {t.common.submittedData} + {JSON.stringify(data, null, 2)} + +
+ ); +} diff --git a/apps/showcase/src/pages/forms/components/form-demo/components/reactive-watch-demo.tsx b/apps/showcase/src/pages/forms/components/form-demo/components/reactive-watch-demo.tsx new file mode 100644 index 0000000..e1c3119 --- /dev/null +++ b/apps/showcase/src/pages/forms/components/form-demo/components/reactive-watch-demo.tsx @@ -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({ + 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: "

Start typing to see the live preview...

", + }, + }); + + // 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 = { + 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(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 ( + + +
+ + + {t.sections.reactiveWatchCascading} + + + + } title="Micro-subscription Pattern" color="blue" variant="light"> + This form demonstrates isolated re-rendering using useWatch. Instead of re-rendering the + entire form root when typing, only the specific conditional fields update their display states. + + + + + + + {userType === 'CORPORATE' && ( + + )} + + + + {hasSpouse && ( + + )} + + + + + + + + + + + + {/* * ⚠️ 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. + */} + + + + {t.sections.reactiveWatchCascading} + + + + + multiple + name="regions" + control={control as any} + label={t.fields.regions} + options={REGIONS} + valueKey="id" + labelKey="code" + clearable + /> + + + 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 && ( + + {t.descriptions.selectedRegionsTax} {regions.map((r: any) => `${r.code} (${(r.taxRate * 100).toFixed(0)}%)`).join(', ')} + + )} + + + {t.sections.reactiveRichTextPreview} + + + + + + + {t.sections.liveHtmlPreview} + +
+ + + + + + + + + + + {t.common?.submittedData || 'Submitted Data'} + + {JSON.stringify(allValues, null, 2)} + + + ); +} diff --git a/apps/showcase/src/pages/forms/components/form-demo/components/validation-bank-demo.tsx b/apps/showcase/src/pages/forms/components/form-demo/components/validation-bank-demo.tsx new file mode 100644 index 0000000..029c089 --- /dev/null +++ b/apps/showcase/src/pages/forms/components/form-demo/components/validation-bank-demo.tsx @@ -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 = 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 = 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; + + const { control, handleSubmit, watch } = useForm({ + 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 ( + + +
+ + {t.sections.validationBankTitle} + + + + + + + + + + + + + + + + + {t.sections.objectLevelValidations} + + + + name="department" + control={control as any} + label={t.fields.department} + options={MOCK_DEPARTMENTS} + valueKey="code" + renderLabel={(item) => `[${item.code}] ${item.name}`} + clearable + withAsterisk + /> + + + multiple + name="assignees" + control={control as any} + label={t.fields.assignees} + loadOptions={mockFetchUsers} + valueKey="id" + labelKey="email" + searchable + clearable + withAsterisk + /> + + {t.sections.validatedPrefilledObjects} + + + + `[${item.code}] ${item.name}`} + clearable + withAsterisk + /> + `[${item.code}] ${item.name}`} + defaultOptions={[{ id: 'V1', code: 'VN-01', name: 'Vendor One' }]} + clearable + withAsterisk + /> + + + `[${item.code}] ${item.name}`} + clearable + withAsterisk + /> + + {t.sections.richTextValidations} + + + + + + +
+
+ + + {t.common.submittedData} + {JSON.stringify(data, null, 2)} + +
+ ); +} diff --git a/apps/showcase/src/pages/forms/components/form-demo/form-demo-view.tsx b/apps/showcase/src/pages/forms/components/form-demo/form-demo-view.tsx new file mode 100644 index 0000000..9581fa1 --- /dev/null +++ b/apps/showcase/src/pages/forms/components/form-demo/form-demo-view.tsx @@ -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 ( + + + {t.tabs.allFields} + {t.tabs.validationBank} + {t.tabs.reactiveWatch} + + + + + + + + + + + + + + + ); +} diff --git a/apps/showcase/src/pages/forms/components/form-demo/i18n/en.json b/apps/showcase/src/pages/forms/components/form-demo/i18n/en.json new file mode 100644 index 0000000..a32a1b4 --- /dev/null +++ b/apps/showcase/src/pages/forms/components/form-demo/i18n/en.json @@ -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" + } +} diff --git a/apps/showcase/src/pages/forms/components/form-demo/i18n/id.json b/apps/showcase/src/pages/forms/components/form-demo/i18n/id.json new file mode 100644 index 0000000..f0261f0 --- /dev/null +++ b/apps/showcase/src/pages/forms/components/form-demo/i18n/id.json @@ -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" + } +} diff --git a/apps/showcase/src/pages/forms/components/form-demo/i18n/useFormDemoTranslation.ts b/apps/showcase/src/pages/forms/components/form-demo/i18n/useFormDemoTranslation.ts new file mode 100644 index 0000000..0c84d67 --- /dev/null +++ b/apps/showcase/src/pages/forms/components/form-demo/i18n/useFormDemoTranslation.ts @@ -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; +} diff --git a/apps/showcase/src/pages/forms/components/form-demo/index.ts b/apps/showcase/src/pages/forms/components/form-demo/index.ts new file mode 100644 index 0000000..9582b1e --- /dev/null +++ b/apps/showcase/src/pages/forms/components/form-demo/index.ts @@ -0,0 +1 @@ +export { default } from './form-demo-view'; diff --git a/apps/showcase/src/pages/forms/index.tsx b/apps/showcase/src/pages/forms/index.tsx new file mode 100644 index 0000000..461dcef --- /dev/null +++ b/apps/showcase/src/pages/forms/index.tsx @@ -0,0 +1,12 @@ +import { Stack, Container } from '@repo/ui/components'; +import FormDemoView from './components/form-demo'; + +export default function FormsPage() { + return ( + + + + + + ); +} diff --git a/apps/showcase/src/pages/hardware/components/printer-list.tsx b/apps/showcase/src/pages/hardware/components/printer-list.tsx new file mode 100644 index 0000000..2995161 --- /dev/null +++ b/apps/showcase/src/pages/hardware/components/printer-list.tsx @@ -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 ( +
+

🧪 Test Integrasi Electron

+ +

+ Status Auto-Update: {status} +

+ + + +

🖨️ Daftar Printer di Komputer Ini:

+
    + {printers.length === 0 ? ( +
  • Mencari printer... (Atau tidak ada printer terdeteksi)
  • + ) : ( + printers.map((printer, index) => ( +
  • + {printer.name} {printer.isDefault ? '(Default)' : ''} +
  • + )) + )} +
+
+ ); +} diff --git a/apps/showcase/src/pages/hardware/index.tsx b/apps/showcase/src/pages/hardware/index.tsx new file mode 100644 index 0000000..0747a63 --- /dev/null +++ b/apps/showcase/src/pages/hardware/index.tsx @@ -0,0 +1,14 @@ +import { Stack, Container, Card } from '@repo/ui/components'; +import PrinterList from './components/printer-list'; + +export default function HardwarePage() { + return ( + + + + + + + + ); +} diff --git a/apps/showcase/src/pages/rbac/index.tsx b/apps/showcase/src/pages/rbac/index.tsx new file mode 100644 index 0000000..bcd45dd --- /dev/null +++ b/apps/showcase/src/pages/rbac/index.tsx @@ -0,0 +1,14 @@ +import { Stack, Container, Card, Title, Text } from '@repo/ui/components'; + +export default function RbacPage() { + return ( + + + + RBAC Engine + RBAC Demo Component Coming Soon... + + + + ); +} diff --git a/apps/showcase/src/pages/shell-demo/index.tsx b/apps/showcase/src/pages/shell-demo/index.tsx new file mode 100644 index 0000000..f0fbacc --- /dev/null +++ b/apps/showcase/src/pages/shell-demo/index.tsx @@ -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 ( + + + + + + + + ); +} + +function MockHeader() { + const { mobileOpened, toggleMobile } = useCoreAppShell(); + return ( + + + + + Mock Header (bg="green.1") + + + + ); +} + +function MockMobileDrawer() { + return ( + + + Mock Mobile Drawer (bg="yellow.1") + + + + + + + ); +} + +function SettingRow({ title, description, control }: { title: string; description: string; control: ReactNode }) { + return ( + + + + {title} + + {description} + + + {control} + + + ); +} + +export default function ShellDemo() { + const [layoutVariant, setLayoutVariant] = useState('header-first'); + const [collapseVariant, setCollapseVariant] = useState('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 ( + + + Mock Utility Bar (bg="blue.1") + + + ), + header: , + sidebarMobile: , + sidebar: !withDoubleSidebar ? ( + + + Mock Standard Navbar (bg="grape.1") + + + + + + + + + ) : undefined, + sidebarRail: withDoubleSidebar ? ( + + + Mock Rail (bg="orange.1") + + + + + + ) : undefined, + sidebarPanel: withDoubleSidebar ? ( + + + Mock Panel (bg="grape.1") + + + + + + + + + ) : undefined, + aside: ( + + + Mock Aside + + This area could be used for notifications, help text, or contextual settings. + + ), + footer: ( + + + Mock Footer (bg="gray.1") + + + ), + }} + > + + + Layout Engine Interactive Demo + + + + + + } + stickyHeader + > + + + + + + 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' }, + ]} + /> + } + /> + + setCollapseVariant(value as DesktopCollapseVariant)} + data={[ + { label: 'Hide (Slide Out)', value: 'hide' }, + { label: 'Mini (Shrink)', value: 'mini' }, + ]} + /> + } + /> + + setWithDoubleSidebar(event.currentTarget.checked)} + /> + } + /> + + setWithUtilityBar(event.currentTarget.checked)} /> + } + /> + + setWithAside(event.currentTarget.checked)} />} + /> + + setWithFooter(event.currentTarget.checked)} />} + /> + + + + + Configuration Preview + + + + +
{JSON.stringify(liveConfig, null, 2)}
+
+
+
+
+
+ ); +} diff --git a/apps/showcase/src/pages/storage/components/pouch-sample.tsx b/apps/showcase/src/pages/storage/components/pouch-sample.tsx new file mode 100644 index 0000000..e68ef4b --- /dev/null +++ b/apps/showcase/src/pages/storage/components/pouch-sample.tsx @@ -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([]); + const [items, setItems] = useState([]); + const [newItems, setNewItems] = useState([]); + + // 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 ( + + + Enterprise PouchDB Sync + + + + {/* Items Inventory Table */} + + + Items Database (Standard) + + + +
+ + + + ID + Name + Type + Base Price + Rates Count + Actions + + + + {items.length > 0 ? ( + items.map((item) => ( + + {item._id} + {item.name} + + + {item.item_type} + + + ${Number(item.base_price).toFixed(2)} + {item.item_rates?.length || 0} + + + + + )) + ) : ( + + + No items found. + + + )} + +
+
+
+ + {/* New Items Envelope Table */} + + + New Items Database (Envelope Pattern) + + + +
+ + + + ID + Name + Type + Base Price + Rates Count + Actions + + + + {newItems.length > 0 ? ( + newItems.map((item) => ( + + {item._id} + {item.name} + + + {item.item_type} + + + ${Number(item.base_price).toFixed(2)} + {item.item_rates?.length || 0} + + + + + )) + ) : ( + + + No items found. + + + )} + +
+
+
+ + {/* POS Configuration Table */} + + + POS Configurations + + + +
+ + + + ID + POS Name + POS Number + Mapped Items + Actions + + + + {configs.length > 0 ? ( + configs.map((cfg) => ( + + {cfg._id} + {cfg.pos_name} + {cfg.pos_number} + + + {cfg.items?.length || 0} Items + + + + + + + )) + ) : ( + + + No configurations found. + + + )} + +
+
+
+
+ ); +} diff --git a/apps/showcase/src/pages/storage/index.tsx b/apps/showcase/src/pages/storage/index.tsx new file mode 100644 index 0000000..5c14eb9 --- /dev/null +++ b/apps/showcase/src/pages/storage/index.tsx @@ -0,0 +1,12 @@ +import { Stack, Container } from '@repo/ui/components'; +import PouchSample from './components/pouch-sample'; + +export default function StoragePage() { + return ( + + + + + + ); +} diff --git a/apps/showcase/src/pages/ui-components/index.tsx b/apps/showcase/src/pages/ui-components/index.tsx new file mode 100644 index 0000000..80aa45c --- /dev/null +++ b/apps/showcase/src/pages/ui-components/index.tsx @@ -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 ( + + + {/* Control Panel */} + + Theme Controls + +