refactor: remove unused showcase components and related files
- Deleted PouchSample component and its associated logic for PouchDB data handling. - Removed PrinterList component and its integration for printer management. - Eliminated registry.ts for component registration, simplifying the showcase structure. - Cleaned up ShellDemo component, removing unnecessary layout controls and demo configurations. - Removed ShowcaseView component, consolidating the showcase functionality.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { lazy, Suspense, useEffect, useState } from 'react';
|
||||
import { lazy, Suspense, useEffect } from 'react';
|
||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
|
||||
import { ThemeProvider, DensityType } from '@repo/ui/provider';
|
||||
import { ThemeProvider } from '@repo/ui/provider';
|
||||
import { NotFound, Forbidden, Maintenance, ComingSoon, AgGridProvider } from '@repo/ui/components';
|
||||
import { LoadingScreen } from '../core/components/loading-screen';
|
||||
import { useThemeStore } from '../core/stores/theme.store';
|
||||
@@ -8,12 +8,9 @@ import { initializeAndPurgeHistoryBackground } from './modules/layouts/hooks/use
|
||||
|
||||
const AuthModule = lazy(() => import('./auth'));
|
||||
const AppModule = lazy(() => import('./modules'));
|
||||
const ShowcaseView = lazy(() => import('./showcase/showcase-view'));
|
||||
const ShellDemo = lazy(() => import('./showcase/shell-demo'));
|
||||
|
||||
export default function App() {
|
||||
const colorScheme = useThemeStore((s) => s.colorScheme);
|
||||
const [density, setDensity] = useState<DensityType>('compact');
|
||||
|
||||
useEffect(() => {
|
||||
// Execution runs purely in the background (fire and forget)
|
||||
@@ -22,20 +19,18 @@ export default function App() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ThemeProvider colorScheme={colorScheme} density={density}>
|
||||
<ThemeProvider colorScheme={colorScheme} density={'compact'}>
|
||||
<AgGridProvider bypassLicense>
|
||||
<BrowserRouter>
|
||||
<Suspense fallback={<LoadingScreen />}>
|
||||
<Routes>
|
||||
<Route path="/auth/*" element={<AuthModule />} />
|
||||
<Route path="/app/*" element={<AppModule />} />
|
||||
<Route path="/showcase" element={<ShowcaseView density={density} setDensity={setDensity} />} />
|
||||
<Route path="/shell-demo" element={<ShellDemo />} />
|
||||
<Route path="/404" element={<NotFound homeUrl="/app" />} />
|
||||
<Route path="/403" element={<Forbidden homeUrl="/app" />} />
|
||||
<Route path="/maintenance" element={<Maintenance />} />
|
||||
<Route path="/coming-soon" element={<ComingSoon />} />
|
||||
<Route path="/" element={<Navigate to="/showcase" />} />
|
||||
<Route path="/" element={<Navigate to="/app" />} />
|
||||
<Route path="*" element={<Navigate to="/404" />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
import { Card, Title, Text, Table, Stack, Badge } from '@repo/ui/components';
|
||||
import { PageActions, RowActions, PageActionProps, RowActionProps } from '@repo/ui/components';
|
||||
import { Save, Printer, Trash, MoreVertical, Edit, FileText, CheckCircle, Check } from 'lucide-react';
|
||||
|
||||
export default function ActionToolsShowcase() {
|
||||
const pageActions: PageActionProps[] = [
|
||||
{
|
||||
key: 'save',
|
||||
label: 'Save Changes',
|
||||
icon: <Save size={16} />,
|
||||
variant: 'filled',
|
||||
onClick: (key) => console.log('Clicked', key),
|
||||
},
|
||||
{
|
||||
key: 'save',
|
||||
label: 'Save Changes',
|
||||
icon: <Save size={16} />,
|
||||
onClick: (key) => console.log('Clicked', key),
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
key: 'print',
|
||||
label: 'Print',
|
||||
icon: <Printer size={16} />,
|
||||
children: [
|
||||
{
|
||||
key: 'print-original',
|
||||
label: 'Print Original',
|
||||
icon: <FileText size={16} />,
|
||||
onClick: (k) => console.log(k),
|
||||
},
|
||||
// { type: 'divider' },
|
||||
{ key: 'print-copy', label: 'Print Copy', icon: <FileText size={16} />, onClick: (k) => console.log(k) },
|
||||
{ key: 'print-copy', label: 'Print Copy', icon: <FileText size={16} />, onClick: (k) => console.log(k) },
|
||||
{ key: 'print-copy', label: 'Print Copy', icon: <FileText size={16} />, onClick: (k) => console.log(k) },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'confirm',
|
||||
label: 'Confirm',
|
||||
icon: <CheckCircle size={16} />,
|
||||
onClick: (key) => console.log('Clicked', key),
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
label: 'Delete',
|
||||
icon: <Trash size={16} />,
|
||||
intent: 'destructive',
|
||||
onClick: (key) => console.log('Clicked', key),
|
||||
},
|
||||
|
||||
{
|
||||
key: 'success',
|
||||
label: 'Success',
|
||||
icon: <Check size={16} />,
|
||||
intent: 'success',
|
||||
onClick: (key) => console.log('Clicked', key),
|
||||
},
|
||||
{
|
||||
key: 'warning',
|
||||
label: 'Warning',
|
||||
icon: <Check size={16} />,
|
||||
intent: 'warning',
|
||||
onClick: (key) => console.log('Clicked', key),
|
||||
},
|
||||
{
|
||||
key: 'Primary',
|
||||
label: 'Primary',
|
||||
icon: <Check size={16} />,
|
||||
intent: 'primary',
|
||||
onClick: (key) => console.log('Clicked', key),
|
||||
},
|
||||
];
|
||||
|
||||
const rowActions: RowActionProps[] = [
|
||||
{
|
||||
key: 'edit',
|
||||
tooltip: 'Edit Record',
|
||||
label: 'Edit Record',
|
||||
icon: <Edit size={16} />,
|
||||
onClick: (key) => console.log('Clicked', key),
|
||||
},
|
||||
{
|
||||
key: 'approve',
|
||||
tooltip: 'Approve',
|
||||
label: 'Approve',
|
||||
icon: <CheckCircle size={16} />,
|
||||
intent: 'success',
|
||||
onClick: (key) => console.log('Clicked', key),
|
||||
},
|
||||
{
|
||||
key: 'more',
|
||||
label: 'More Options',
|
||||
icon: <MoreVertical size={16} />,
|
||||
children: [
|
||||
{
|
||||
key: 'delete',
|
||||
label: 'Delete Record',
|
||||
icon: <Trash size={16} />,
|
||||
intent: 'destructive',
|
||||
onClick: (k) => console.log(k),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const tableData = [
|
||||
{ id: '1', name: 'Invoice #001', status: 'Pending' },
|
||||
{ id: '2', name: 'Invoice #002', status: 'Approved' },
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack gap="xl">
|
||||
<Card withBorder shadow="sm" radius="md" p="md">
|
||||
<Title order={4} mb="md">
|
||||
Page Actions
|
||||
</Title>
|
||||
<Text c="dimmed" mb="lg">
|
||||
Used in toolbars and page headers.
|
||||
</Text>
|
||||
<PageActions actions={pageActions} />
|
||||
</Card>
|
||||
|
||||
<Card withBorder shadow="sm" radius="md" p="md">
|
||||
<Title order={4} mb="md">
|
||||
Row Actions
|
||||
</Title>
|
||||
<Text c="dimmed" mb="lg">
|
||||
Used inside data grids or list items.
|
||||
</Text>
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>ID</Table.Th>
|
||||
<Table.Th>Name</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th style={{ width: 120 }}>Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{tableData.map((row) => (
|
||||
<Table.Tr key={row.id}>
|
||||
<Table.Td>{row.id}</Table.Td>
|
||||
<Table.Td>{row.name}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={row.status === 'Approved' ? 'success' : 'warning'}>{row.status}</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<RowActions actions={rowActions} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<RowActions showLabels actions={rowActions} />
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Card>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,218 +0,0 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Card, Title, Text, Stack, Badge, Group, Select, NumberInput } from '@repo/ui/components';
|
||||
import { AgGridProvider, DataGrid } from '@repo/ui/ag-grid';
|
||||
import type { ColDef, ValueFormatterParams } from '@repo/ui/ag-grid';
|
||||
|
||||
/* ─── Sample Data ──────────────────────────────────────────────── */
|
||||
|
||||
interface OrderRow {
|
||||
id: string;
|
||||
orderCode: string;
|
||||
customer: string;
|
||||
email: string;
|
||||
product: string;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
total: number;
|
||||
status: 'Draft' | 'Pending' | 'Processing' | 'Shipped' | 'Delivered' | 'Cancelled';
|
||||
createdAt: string;
|
||||
region: string;
|
||||
}
|
||||
|
||||
const SAMPLE_DATA: OrderRow[] = [
|
||||
{ id: '1', orderCode: 'ORD-2026-001', customer: 'Acme Corp', email: 'acme@example.com', product: 'Widget A', quantity: 120, unitPrice: 24.5, total: 2940, status: 'Delivered', createdAt: '2026-01-15', region: 'North America' },
|
||||
{ id: '2', orderCode: 'ORD-2026-002', customer: 'Globex Inc', email: 'info@globex.com', product: 'Widget B', quantity: 50, unitPrice: 89.99, total: 4499.5, status: 'Shipped', createdAt: '2026-02-03', region: 'Europe' },
|
||||
{ id: '3', orderCode: 'ORD-2026-003', customer: 'Initech', email: 'orders@initech.com', product: 'Gadget Pro', quantity: 200, unitPrice: 15.0, total: 3000, status: 'Processing', createdAt: '2026-03-21', region: 'Asia Pacific' },
|
||||
{ id: '4', orderCode: 'ORD-2026-004', customer: 'Umbrella Ltd', email: 'sales@umbrella.co', product: 'Widget A', quantity: 75, unitPrice: 24.5, total: 1837.5, status: 'Pending', createdAt: '2026-04-10', region: 'Europe' },
|
||||
{ id: '5', orderCode: 'ORD-2026-005', customer: 'Stark Industries', email: 'tony@stark.io', product: 'Gadget Elite', quantity: 10, unitPrice: 499.0, total: 4990, status: 'Delivered', createdAt: '2026-04-18', region: 'North America' },
|
||||
{ id: '6', orderCode: 'ORD-2026-006', customer: 'Wayne Enterprises', email: 'bruce@wayne.com', product: 'Widget C', quantity: 300, unitPrice: 12.75, total: 3825, status: 'Draft', createdAt: '2026-05-02', region: 'North America' },
|
||||
{ id: '7', orderCode: 'ORD-2026-007', customer: 'Cyberdyne', email: 'info@cyberdyne.jp', product: 'Gadget Pro', quantity: 150, unitPrice: 15.0, total: 2250, status: 'Cancelled', createdAt: '2026-05-15', region: 'Asia Pacific' },
|
||||
{ id: '8', orderCode: 'ORD-2026-008', customer: 'Oscorp', email: 'orders@oscorp.com', product: 'Widget B', quantity: 90, unitPrice: 89.99, total: 8099.1, status: 'Shipped', createdAt: '2026-06-01', region: 'North America' },
|
||||
{ id: '9', orderCode: 'ORD-2026-009', customer: 'LexCorp', email: 'lex@lexcorp.com', product: 'Gadget Elite', quantity: 5, unitPrice: 499.0, total: 2495, status: 'Processing', createdAt: '2026-06-12', region: 'Europe' },
|
||||
{ id: '10', orderCode: 'ORD-2026-010', customer: 'Pied Piper', email: 'richard@piedpiper.io', product: 'Widget A', quantity: 500, unitPrice: 24.5, total: 12250, status: 'Pending', createdAt: '2026-06-20', region: 'North America' },
|
||||
{ id: '11', orderCode: 'ORD-2026-011', customer: 'Hooli', email: 'gavin@hooli.com', product: 'Gadget Pro', quantity: 1000, unitPrice: 15.0, total: 15000, status: 'Delivered', createdAt: '2026-07-01', region: 'North America' },
|
||||
{ id: '12', orderCode: 'ORD-2026-012', customer: 'Massive Dynamic', email: 'nina@massive.com', product: 'Widget C', quantity: 60, unitPrice: 12.75, total: 765, status: 'Shipped', createdAt: '2026-07-08', region: 'Europe' },
|
||||
];
|
||||
|
||||
/* ─── Status Badge Renderer ─────────────────────────────────────── */
|
||||
|
||||
const STATUS_COLORS: Record<OrderRow['status'], string> = {
|
||||
Draft: 'gray',
|
||||
Pending: 'warning',
|
||||
Processing: 'info',
|
||||
Shipped: 'brand',
|
||||
Delivered: 'success',
|
||||
Cancelled: 'error',
|
||||
};
|
||||
|
||||
function StatusCellRenderer({ value }: { value: OrderRow['status'] }) {
|
||||
return (
|
||||
<Badge size="sm" variant="light" color={STATUS_COLORS[value] || 'gray'}>
|
||||
{value}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Currency Formatter ────────────────────────────────────────── */
|
||||
|
||||
function currencyFormatter(params: ValueFormatterParams): string {
|
||||
if (params.value == null) return '-';
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: 2,
|
||||
}).format(params.value);
|
||||
}
|
||||
|
||||
/* ─── Showcase Component ────────────────────────────────────────── */
|
||||
|
||||
export default function AgGridShowcase() {
|
||||
const [gridHeight, setGridHeight] = useState(460);
|
||||
const [rowModelType, setRowModelType] = useState<string>('clientSide');
|
||||
|
||||
const defaultColDef = useMemo<ColDef>(
|
||||
() => ({
|
||||
flex: 1,
|
||||
minWidth: 100,
|
||||
sortable: true,
|
||||
filter: true,
|
||||
resizable: true,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const columnDefs = useMemo<ColDef<OrderRow>[]>(
|
||||
() => [
|
||||
{
|
||||
field: 'orderCode',
|
||||
headerName: 'Order Code',
|
||||
pinned: 'left',
|
||||
minWidth: 140,
|
||||
filter: 'agTextColumnFilter',
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
headerName: 'Status',
|
||||
minWidth: 120,
|
||||
cellRenderer: StatusCellRenderer,
|
||||
filter: 'agSetColumnFilter',
|
||||
},
|
||||
{
|
||||
field: 'customer',
|
||||
headerName: 'Customer',
|
||||
minWidth: 160,
|
||||
filter: 'agTextColumnFilter',
|
||||
},
|
||||
{
|
||||
field: 'email',
|
||||
headerName: 'Email',
|
||||
minWidth: 180,
|
||||
filter: 'agTextColumnFilter',
|
||||
},
|
||||
{
|
||||
field: 'product',
|
||||
headerName: 'Product',
|
||||
minWidth: 130,
|
||||
filter: 'agSetColumnFilter',
|
||||
},
|
||||
{
|
||||
field: 'region',
|
||||
headerName: 'Region',
|
||||
minWidth: 140,
|
||||
filter: 'agSetColumnFilter',
|
||||
enableRowGroup: true,
|
||||
},
|
||||
{
|
||||
field: 'quantity',
|
||||
headerName: 'Qty',
|
||||
minWidth: 80,
|
||||
filter: 'agNumberColumnFilter',
|
||||
type: 'numericColumn',
|
||||
},
|
||||
{
|
||||
field: 'unitPrice',
|
||||
headerName: 'Unit Price',
|
||||
minWidth: 110,
|
||||
filter: 'agNumberColumnFilter',
|
||||
type: 'numericColumn',
|
||||
valueFormatter: currencyFormatter,
|
||||
},
|
||||
{
|
||||
field: 'total',
|
||||
headerName: 'Total',
|
||||
minWidth: 120,
|
||||
filter: 'agNumberColumnFilter',
|
||||
type: 'numericColumn',
|
||||
valueFormatter: currencyFormatter,
|
||||
},
|
||||
{
|
||||
field: 'createdAt',
|
||||
headerName: 'Created Date',
|
||||
minWidth: 130,
|
||||
filter: 'agDateColumnFilter',
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<AgGridProvider bypassLicense>
|
||||
<Stack gap="xl">
|
||||
{/* ── Info Card ────────────────────────────────── */}
|
||||
<Card withBorder shadow="sm" radius="md" p="md">
|
||||
<Title order={4} mb="xs">
|
||||
AG Grid Enterprise — Mantine Integration
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" mb="md">
|
||||
This showcase demonstrates AG Grid integrated with Mantine's theme system. The grid
|
||||
automatically adapts to dark/light mode changes, inherits brand colors, typography, and
|
||||
border radius from the Mantine theme — all via AG Grid's built-in CSS variable system with
|
||||
zero custom stylesheets. Toggle the color scheme in the Theme Controls above to see it in
|
||||
action.
|
||||
</Text>
|
||||
<Group>
|
||||
<NumberInput
|
||||
label="Grid Height (px)"
|
||||
value={gridHeight}
|
||||
onChange={(val) => setGridHeight(Number(val) || 400)}
|
||||
min={300}
|
||||
max={800}
|
||||
step={50}
|
||||
w={180}
|
||||
/>
|
||||
<Select
|
||||
label="Row Model"
|
||||
value={rowModelType}
|
||||
onChange={(val) => setRowModelType(val || 'clientSide')}
|
||||
data={[
|
||||
{ value: 'clientSide', label: 'Client Side' },
|
||||
{ value: 'serverSide', label: 'Server Side (no datasource)' },
|
||||
]}
|
||||
w={260}
|
||||
/>
|
||||
</Group>
|
||||
</Card>
|
||||
|
||||
{/* ── AG Grid ──────────────────────────────────── */}
|
||||
<Card withBorder shadow="sm" radius="md" p="md">
|
||||
<Title order={4} mb="md">
|
||||
Orders Data Grid
|
||||
</Title>
|
||||
<div style={{ height: gridHeight }}>
|
||||
<DataGrid<OrderRow>
|
||||
rowData={SAMPLE_DATA}
|
||||
columnDefs={columnDefs}
|
||||
defaultColDef={defaultColDef}
|
||||
rowSelection={{ mode: 'multiRow', checkboxes: true }}
|
||||
pagination
|
||||
paginationPageSize={10}
|
||||
animateRows
|
||||
enableCellTextSelection
|
||||
suppressCopyRowsToClipboard
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</Stack>
|
||||
</AgGridProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { usePublishEvent } from '@repo/core-events';
|
||||
import { Button, Group, Stack, TextInput, Badge } from '@repo/ui/components';
|
||||
import { AUTH_EVENTS } from '../../../../core/constants/events';
|
||||
|
||||
// ─── ProfileSettingsUI ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A profile settings form that publishes `AUTH:PROFILE_UPDATED`
|
||||
* when the user saves changes.
|
||||
*
|
||||
* **Decoupling principle:**
|
||||
* This component doesn't know about IndexedDB, localStorage,
|
||||
* or any storage mechanism. It simply announces that the profile
|
||||
* has been updated. Any number of listeners can react to this
|
||||
* event independently:
|
||||
*
|
||||
* - `StorageSyncListener` persists to IndexedDB
|
||||
* - A hypothetical `AnalyticsListener` could send to Mixpanel
|
||||
* - A hypothetical `AvatarCacheListener` could pre-warm a CDN
|
||||
*
|
||||
* All without modifying this component.
|
||||
*/
|
||||
export function ProfileSettingsUI() {
|
||||
const publish = usePublishEvent();
|
||||
|
||||
const [name, setName] = useState('Firman Ramdhani');
|
||||
const [email, setEmail] = useState('firman@eigen.co.id');
|
||||
const [avatar, setAvatar] = useState('https://ui-avatars.com/api/?name=FM&background=4263eb&color=fff');
|
||||
const [saveCount, setSaveCount] = useState(0);
|
||||
|
||||
const handleSave = () => {
|
||||
publish(AUTH_EVENTS.PROFILE_UPDATED, {
|
||||
id: 'user-1',
|
||||
name,
|
||||
email,
|
||||
avatar,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
setSaveCount((c) => c + 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Group grow align="flex-start">
|
||||
<TextInput label="Full Name" value={name} onChange={(e) => setName(e.currentTarget.value)} size="sm" />
|
||||
<TextInput label="Email" value={email} onChange={(e) => setEmail(e.currentTarget.value)} size="sm" />
|
||||
</Group>
|
||||
|
||||
<TextInput label="Avatar URL" value={avatar} onChange={(e) => setAvatar(e.currentTarget.value)} size="sm" />
|
||||
|
||||
<Group>
|
||||
<Button variant="filled" color="brand" onClick={handleSave}>
|
||||
💾 Save Profile
|
||||
</Button>
|
||||
{saveCount > 0 && (
|
||||
<Badge color="success" variant="light">
|
||||
Synced {saveCount}×
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import {
|
||||
Card,
|
||||
Title,
|
||||
Text,
|
||||
Stack,
|
||||
Badge,
|
||||
Divider,
|
||||
} from '@repo/ui/components';
|
||||
|
||||
// ── Showcase Components ──────────────────────────────────────────
|
||||
import { CashierUI } from './printer/cashier.ui';
|
||||
import { PrinterListener } from './printer/printer.listener';
|
||||
import { LiveStockGrid } from './stock-grid/live-stock-grid.ui';
|
||||
import { ProfileSettingsUI } from './auth-sync/profile-settings.ui';
|
||||
import { StorageSyncListener } from './auth-sync/storage-sync.listener';
|
||||
import { DEVICE_EVENTS, AUTH_EVENTS } from '../../../core/constants/events';
|
||||
|
||||
// ─── Events Demo Page ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Orchestrator page for all three Event Bus showcase demos.
|
||||
*
|
||||
* This component is completely self-contained within the
|
||||
* `events-demo/` folder and does not leak state or side-effects
|
||||
* into the rest of the application.
|
||||
*/
|
||||
export default function EventsDemoPage() {
|
||||
// ── Showcase 1: Printer status feedback ──────────────────────
|
||||
const [printerLog, setPrinterLog] = useState<string[]>([]);
|
||||
|
||||
// ── Showcase 3: Storage sync status feedback ─────────────────
|
||||
const [syncLog, setSyncLog] = useState<string[]>([]);
|
||||
|
||||
// ── Render counter to prove this parent is stable ────────────
|
||||
const renderCount = useRef(0);
|
||||
renderCount.current += 1;
|
||||
|
||||
// Keep log sizes bounded
|
||||
useEffect(() => {
|
||||
if (printerLog.length > 20) setPrinterLog((prev) => prev.slice(-20));
|
||||
}, [printerLog.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (syncLog.length > 20) setSyncLog((prev) => prev.slice(-20));
|
||||
}, [syncLog.length]);
|
||||
|
||||
return (
|
||||
<Stack gap="xl">
|
||||
<div>
|
||||
<Title order={2}>🔌 Event Bus Showcase</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
Three real-world demos of <code>@repo/core-events</code> — zero coupling, strict typing, high performance.
|
||||
</Text>
|
||||
<Badge color="brand" variant="light" mt="xs">
|
||||
Parent render count: {renderCount.current}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* ═══════════════════════════════════════════════════════════
|
||||
SHOWCASE 1: Cross-Platform Printer Abstraction
|
||||
═══════════════════════════════════════════════════════════ */}
|
||||
<Card withBorder shadow="sm" radius="md" p="md">
|
||||
<Title order={4} mb="xs">
|
||||
🖨️ Showcase 1: Cross-Platform Printer Abstraction
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" mb="md">
|
||||
The CashierUI publishes a <code>{DEVICE_EVENTS.PRINT_RECEIPT}</code> event.
|
||||
The PrinterListener listens for it and simulates interacting with a physical printer.
|
||||
</Text>
|
||||
|
||||
{/* Headless listener — renders nothing visible */}
|
||||
<PrinterListener
|
||||
onLog={(msg) => setPrinterLog((prev) => [...prev, `[${new Date().toLocaleTimeString()}] ${msg}`])}
|
||||
/>
|
||||
|
||||
<CashierUI />
|
||||
|
||||
{printerLog.length > 0 && (
|
||||
<>
|
||||
<Divider my="sm" />
|
||||
<Text size="sm" fw={600}>
|
||||
📋 Printer Log:
|
||||
</Text>
|
||||
<div
|
||||
style={{
|
||||
maxHeight: 120,
|
||||
overflow: 'auto',
|
||||
fontSize: 12,
|
||||
fontFamily: 'monospace',
|
||||
background: 'var(--mantine-color-dark-7, #1a1b1e)',
|
||||
color: 'var(--mantine-color-green-4, #69db7c)',
|
||||
padding: 8,
|
||||
borderRadius: 6,
|
||||
}}
|
||||
>
|
||||
{printerLog.map((line, i) => (
|
||||
<div key={i}>{line}</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* ═══════════════════════════════════════════════════════════
|
||||
SHOWCASE 2: Extreme Performance — Live Stock Grid
|
||||
═══════════════════════════════════════════════════════════ */}
|
||||
<Card withBorder shadow="sm" radius="md" p="md">
|
||||
<Title order={4} mb="xs">
|
||||
📈 Showcase 2: High-Frequency Real-Time Data (50 updates/sec)
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" mb="md">
|
||||
A mock WebSocket fires <code>WS:STOCK_UPDATE</code> every 20ms.
|
||||
Each StockRow subscribes to the global event but only updates when{' '}
|
||||
<code>payload.id === row.id</code>. The parent grid never re-renders.
|
||||
</Text>
|
||||
|
||||
<LiveStockGrid />
|
||||
</Card>
|
||||
|
||||
{/* ═══════════════════════════════════════════════════════════
|
||||
SHOWCASE 3: Auth/Profile → IndexedDB Sync
|
||||
═══════════════════════════════════════════════════════════ */}
|
||||
<Card withBorder shadow="sm" radius="md" p="md">
|
||||
<Title order={4} mb="xs">
|
||||
💾 Showcase 3: Auth/Profile Sync with <code>@repo/core-storage</code>
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" mb="md">
|
||||
ProfileSettingsUI publishes <code>{AUTH_EVENTS.PROFILE_UPDATED}</code>.
|
||||
StorageSyncListener silently catches it in the background and saves to IndexedDB via <code>secureIndexedDB</code>.
|
||||
</Text>
|
||||
|
||||
{/* Headless listener — renders nothing visible */}
|
||||
<StorageSyncListener
|
||||
onLog={(msg) => setSyncLog((prev) => [...prev, `[${new Date().toLocaleTimeString()}] ${msg}`])}
|
||||
/>
|
||||
|
||||
<ProfileSettingsUI />
|
||||
|
||||
{syncLog.length > 0 && (
|
||||
<>
|
||||
<Divider my="sm" />
|
||||
<Text size="sm" fw={600}>
|
||||
📋 Storage Sync Log:
|
||||
</Text>
|
||||
<div
|
||||
style={{
|
||||
maxHeight: 120,
|
||||
overflow: 'auto',
|
||||
fontSize: 12,
|
||||
fontFamily: 'monospace',
|
||||
background: 'var(--mantine-color-dark-7, #1a1b1e)',
|
||||
color: 'var(--mantine-color-blue-4, #4dabf7)',
|
||||
padding: 8,
|
||||
borderRadius: 6,
|
||||
}}
|
||||
>
|
||||
{syncLog.map((line, i) => (
|
||||
<div key={i}>{line}</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { usePublishEvent } from '@repo/core-events';
|
||||
import type { ReceiptItem } from '@repo/core-events';
|
||||
import {
|
||||
Button,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Table,
|
||||
Badge,
|
||||
} from '@repo/ui/components';
|
||||
import { DEVICE_EVENTS } from '../../../../core/constants/events';
|
||||
|
||||
// ─── Mock Receipt Data ──────────────────────────────────────────
|
||||
|
||||
const DEMO_ITEMS: ReceiptItem[] = [
|
||||
{ name: 'Espresso', qty: 2, price: 3.5 },
|
||||
{ name: 'Croissant', qty: 1, price: 4.25 },
|
||||
{ name: 'Orange Juice', qty: 3, price: 2.75 },
|
||||
];
|
||||
|
||||
// ─── CashierUI ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A simple cashier interface that publishes a print receipt event.
|
||||
*
|
||||
* This component has ZERO knowledge of how printing works.
|
||||
* It simply fires `DEVICE:PRINT_RECEIPT` and trusts that a
|
||||
* listener somewhere in the tree will handle the rest.
|
||||
*
|
||||
* This is the essence of decoupled architecture:
|
||||
* - CashierUI doesn't import any printer logic.
|
||||
* - CashierUI doesn't know if it's running in Electron or browser.
|
||||
* - CashierUI doesn't even know if anyone is listening.
|
||||
*/
|
||||
export function CashierUI() {
|
||||
const publish = usePublishEvent();
|
||||
const [cashierName, setCashierName] = useState('Firman');
|
||||
const [items] = useState<ReceiptItem[]>(DEMO_ITEMS);
|
||||
const [printCount, setPrintCount] = useState(0);
|
||||
|
||||
const total = items.reduce((sum, item) => sum + item.qty * item.price, 0);
|
||||
|
||||
const handlePrint = () => {
|
||||
publish(DEVICE_EVENTS.PRINT_RECEIPT, {
|
||||
receiptId: `RCP-${Date.now().toString(36).toUpperCase()}`,
|
||||
items,
|
||||
total,
|
||||
cashierName,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
setPrintCount((c) => c + 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
label="Cashier Name"
|
||||
value={cashierName}
|
||||
onChange={(e) => setCashierName(e.currentTarget.value)}
|
||||
size="sm"
|
||||
style={{ maxWidth: 250 }}
|
||||
/>
|
||||
|
||||
<Table striped highlightOnHover withTableBorder withColumnBorders>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Item</Table.Th>
|
||||
<Table.Th>Qty</Table.Th>
|
||||
<Table.Th>Price</Table.Th>
|
||||
<Table.Th>Subtotal</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{items.map((item, idx) => (
|
||||
<Table.Tr key={idx}>
|
||||
<Table.Td>{item.name}</Table.Td>
|
||||
<Table.Td>{item.qty}</Table.Td>
|
||||
<Table.Td>${item.price.toFixed(2)}</Table.Td>
|
||||
<Table.Td>${(item.qty * item.price).toFixed(2)}</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
<Table.Tfoot>
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={3}>
|
||||
<Text fw={700}>Total</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fw={700}>${total.toFixed(2)}</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
</Table.Tfoot>
|
||||
</Table>
|
||||
|
||||
<Group>
|
||||
<Button variant="filled" color="brand" onClick={handlePrint}>
|
||||
🖨️ Print Receipt
|
||||
</Button>
|
||||
{printCount > 0 && (
|
||||
<Badge color="success" variant="light">
|
||||
Printed {printCount}×
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Button, Group, Badge, Text, Stack } from '@repo/ui/components';
|
||||
import { StockRow } from './stock-row.ui';
|
||||
import { generateStockIds, startMockWebSocket } from './mock-websocket.service';
|
||||
|
||||
// ─── Constants ──────────────────────────────────────────────────
|
||||
|
||||
const STOCK_COUNT = 1000;
|
||||
const VISIBLE_ROWS = 50; // Virtual-scroll window (show first N for performance)
|
||||
|
||||
// ─── LiveStockGrid ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Renders a high-performance stock grid with 1000 rows.
|
||||
*
|
||||
* **Key architectural guarantee:**
|
||||
* This parent component does NOT hold any stock data in its state.
|
||||
* All data flows through the event bus directly to individual
|
||||
* `StockRow` children. The parent's render count stays at 1
|
||||
* (or increments only for explicit user interactions like start/stop).
|
||||
*
|
||||
* **Visible rows:** To keep the demo responsive in the browser DOM,
|
||||
* we only render the first 50 rows visually. In production, you'd
|
||||
* use a virtualizer (e.g., TanStack Virtual). But all 1000 rows
|
||||
* ARE subscribed to the event bus and processing data — the
|
||||
* performance claim is valid.
|
||||
*/
|
||||
export function LiveStockGrid() {
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const wsRef = useRef<ReturnType<typeof startMockWebSocket> | null>(null);
|
||||
const renderCount = useRef(0);
|
||||
renderCount.current += 1;
|
||||
|
||||
// Generate stable stock IDs once
|
||||
const stockIds = useMemo(() => generateStockIds(STOCK_COUNT), []);
|
||||
|
||||
// Determine how many rows to render in the DOM
|
||||
const visibleIds = showAll ? stockIds : stockIds.slice(0, VISIBLE_ROWS);
|
||||
|
||||
// ── Event count tracker (polled via interval, not via state) ──
|
||||
const [eventStats, setEventStats] = useState({ total: 0, perSec: 0 });
|
||||
const statsIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const startFeed = () => {
|
||||
if (wsRef.current) return;
|
||||
|
||||
wsRef.current = startMockWebSocket({ stockIds, intervalMs: 20 });
|
||||
setIsRunning(true);
|
||||
|
||||
// Poll event count every second for the stats display
|
||||
let lastCount = 0;
|
||||
statsIntervalRef.current = setInterval(() => {
|
||||
if (!wsRef.current) return;
|
||||
const current = wsRef.current.getEventCount();
|
||||
setEventStats({ total: current, perSec: current - lastCount });
|
||||
lastCount = current;
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const stopFeed = () => {
|
||||
wsRef.current?.cleanup();
|
||||
wsRef.current = null;
|
||||
if (statsIntervalRef.current) clearInterval(statsIntervalRef.current);
|
||||
statsIntervalRef.current = null;
|
||||
setIsRunning(false);
|
||||
};
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
wsRef.current?.cleanup();
|
||||
if (statsIntervalRef.current) clearInterval(statsIntervalRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
{/* ── Controls ──────────────────────────────────────────── */}
|
||||
<Group>
|
||||
{!isRunning ? (
|
||||
<Button variant="filled" color="brand" onClick={startFeed} size="sm">
|
||||
▶ Start Feed (50 events/sec)
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="filled" color="error" onClick={stopFeed} size="sm">
|
||||
■ Stop Feed
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="light" color="info" onClick={() => setShowAll((s) => !s)} size="sm">
|
||||
{showAll ? `Show ${VISIBLE_ROWS} rows` : `Show all ${STOCK_COUNT} rows`}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{/* ── Stats Bar ─────────────────────────────────────────── */}
|
||||
<Group gap="md">
|
||||
<Badge color="brand" variant="light" size="lg">
|
||||
Grid renders: {renderCount.current}
|
||||
</Badge>
|
||||
<Badge color="info" variant="light" size="lg">
|
||||
Total events: {eventStats.total.toLocaleString()}
|
||||
</Badge>
|
||||
<Badge color="success" variant="light" size="lg">
|
||||
Events/sec: {eventStats.perSec}
|
||||
</Badge>
|
||||
<Badge color="warning" variant="light" size="lg">
|
||||
Subscribed rows: {STOCK_COUNT} | Visible: {visibleIds.length}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<Text size="xs" c="dimmed">
|
||||
Each row shows its own render count in the last column. Only rows receiving updates re-render.
|
||||
</Text>
|
||||
|
||||
{/* ── Data Grid ─────────────────────────────────────────── */}
|
||||
<div style={{ maxHeight: 500, overflow: 'auto', border: '1px solid var(--app-shell-border-color)' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
background: 'var(--mantine-color-body)',
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<th
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
textAlign: 'left',
|
||||
borderBottom: '2px solid var(--app-shell-border-color)',
|
||||
}}
|
||||
>
|
||||
Ticker
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
textAlign: 'right',
|
||||
borderBottom: '2px solid var(--app-shell-border-color)',
|
||||
}}
|
||||
>
|
||||
Price
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
textAlign: 'right',
|
||||
borderBottom: '2px solid var(--app-shell-border-color)',
|
||||
}}
|
||||
>
|
||||
Change
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
textAlign: 'right',
|
||||
borderBottom: '2px solid var(--app-shell-border-color)',
|
||||
}}
|
||||
>
|
||||
Volume
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
textAlign: 'right',
|
||||
borderBottom: '2px solid var(--app-shell-border-color)',
|
||||
}}
|
||||
>
|
||||
Renders
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{visibleIds.map((id) => (
|
||||
<StockRow key={id} stockId={id} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import { publish } from '@repo/core-events';
|
||||
import { WS_EVENTS } from '../../../../core/constants/events';
|
||||
|
||||
// ─── Stock Tickers ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Pool of realistic stock ticker symbols.
|
||||
* We generate 1000 unique IDs from these base tickers + numeric suffix.
|
||||
*/
|
||||
const BASE_TICKERS = [
|
||||
'AAPL', 'GOOG', 'MSFT', 'AMZN', 'META', 'NVDA', 'TSLA', 'AMD',
|
||||
'NFLX', 'ORCL', 'CRM', 'INTC', 'PYPL', 'ADBE', 'CSCO', 'QCOM',
|
||||
'AVGO', 'TXN', 'MU', 'SHOP',
|
||||
];
|
||||
|
||||
/**
|
||||
* Generate a deterministic list of 1000 stock IDs.
|
||||
*/
|
||||
export function generateStockIds(count: number = 1000): string[] {
|
||||
const ids: string[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
ids.push(`${BASE_TICKERS[i % BASE_TICKERS.length]}-${String(i).padStart(4, '0')}`);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
// ─── Mock WebSocket ─────────────────────────────────────────────
|
||||
|
||||
interface MockWebSocketConfig {
|
||||
/** All stock IDs to cycle through. */
|
||||
stockIds: string[];
|
||||
/** Interval in ms between events. @default 20 */
|
||||
intervalMs?: number;
|
||||
}
|
||||
|
||||
interface MockWebSocketHandle {
|
||||
/** Call to stop the mock WebSocket and clear the interval. */
|
||||
cleanup: () => void;
|
||||
/** Number of events emitted so far. */
|
||||
getEventCount: () => number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulates a WebSocket that pushes stock price updates at high frequency.
|
||||
*
|
||||
* Fires `WS:STOCK_UPDATE` every `intervalMs` (default 20ms = 50 updates/sec).
|
||||
* Each tick picks a random stock from the pool and generates a
|
||||
* realistic-looking price movement.
|
||||
*
|
||||
* Returns a handle with a `cleanup()` function to stop the simulation.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const ws = startMockWebSocket({ stockIds: ['AAPL-0001', ...] });
|
||||
* // Later:
|
||||
* ws.cleanup();
|
||||
* ```
|
||||
*/
|
||||
export function startMockWebSocket(config: MockWebSocketConfig): MockWebSocketHandle {
|
||||
const { stockIds, intervalMs = 20 } = config;
|
||||
let eventCount = 0;
|
||||
|
||||
// Seed initial prices for each stock
|
||||
const prices = new Map<string, number>();
|
||||
for (const id of stockIds) {
|
||||
prices.set(id, 100 + Math.random() * 400); // $100–$500
|
||||
}
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
// Pick a random stock
|
||||
const randomIndex = Math.floor(Math.random() * stockIds.length);
|
||||
const id = stockIds[randomIndex]!;
|
||||
const currentPrice = prices.get(id)!;
|
||||
|
||||
// Generate a small random price change (-2% to +2%)
|
||||
const changePercent = (Math.random() - 0.5) * 0.04;
|
||||
const change = +(currentPrice * changePercent).toFixed(2);
|
||||
const newPrice = +(currentPrice + change).toFixed(2);
|
||||
|
||||
// Update tracked price
|
||||
prices.set(id, newPrice);
|
||||
|
||||
// Publish to the event bus
|
||||
publish(WS_EVENTS.STOCK_UPDATE, {
|
||||
id,
|
||||
price: newPrice,
|
||||
change,
|
||||
volume: Math.floor(Math.random() * 100000),
|
||||
});
|
||||
|
||||
eventCount++;
|
||||
}, intervalMs);
|
||||
|
||||
return {
|
||||
cleanup: () => clearInterval(intervalId),
|
||||
getEventCount: () => eventCount,
|
||||
};
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import { memo, useState, useRef } from 'react';
|
||||
import { useAppEvent } from '@repo/core-events';
|
||||
import { WS_EVENTS } from '../../../../core/constants/events';
|
||||
|
||||
// ─── Props ──────────────────────────────────────────────────────
|
||||
|
||||
interface StockRowProps {
|
||||
stockId: string;
|
||||
}
|
||||
|
||||
// ─── Local State ────────────────────────────────────────────────
|
||||
|
||||
interface StockState {
|
||||
price: number;
|
||||
change: number;
|
||||
volume: number;
|
||||
lastUpdate: number;
|
||||
}
|
||||
|
||||
// ─── StockRow ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A single row in the stock grid.
|
||||
*
|
||||
* **Performance architecture:**
|
||||
* 1. Each StockRow subscribes to the global `WS:STOCK_UPDATE` event.
|
||||
* 2. The handler checks `payload.id === stockId` — if no match, it
|
||||
* does NOTHING (no setState, no re-render).
|
||||
* 3. Only the targeted row updates its own local state.
|
||||
* 4. `React.memo` prevents re-renders from parent prop changes.
|
||||
*
|
||||
* Result: At 50 events/sec across 1000 rows, only ~1 row re-renders
|
||||
* per tick. The parent `LiveStockGrid` NEVER re-renders.
|
||||
*/
|
||||
export const StockRow = memo(function StockRow({ stockId }: StockRowProps) {
|
||||
const [data, setData] = useState<StockState | null>(null);
|
||||
const renderCountRef = useRef(0);
|
||||
renderCountRef.current += 1;
|
||||
|
||||
useAppEvent(WS_EVENTS.STOCK_UPDATE, (payload) => {
|
||||
// ── Critical filter ──────────────────────────────────────────
|
||||
// This is the key performance optimization. Only the row whose
|
||||
// ID matches the event payload will call setState. All other
|
||||
// rows (999 out of 1000) do absolutely nothing.
|
||||
if (payload.id !== stockId) return;
|
||||
|
||||
setData({
|
||||
price: payload.price,
|
||||
change: payload.change,
|
||||
volume: payload.volume,
|
||||
lastUpdate: Date.now(),
|
||||
});
|
||||
});
|
||||
|
||||
const changeColor = data
|
||||
? data.change >= 0
|
||||
? '#40c057' // green
|
||||
: '#fa5252' // red
|
||||
: undefined;
|
||||
|
||||
const changeArrow = data
|
||||
? data.change >= 0
|
||||
? '▲'
|
||||
: '▼'
|
||||
: '';
|
||||
|
||||
return (
|
||||
<tr style={{ fontSize: 12, fontFamily: 'monospace' }}>
|
||||
<td style={{ padding: '2px 8px', fontWeight: 600 }}>{stockId}</td>
|
||||
<td style={{ padding: '2px 8px', textAlign: 'right' }}>
|
||||
{data ? `$${data.price.toFixed(2)}` : '—'}
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
padding: '2px 8px',
|
||||
textAlign: 'right',
|
||||
color: changeColor,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{data ? `${changeArrow} ${data.change >= 0 ? '+' : ''}${data.change.toFixed(2)}` : '—'}
|
||||
</td>
|
||||
<td style={{ padding: '2px 8px', textAlign: 'right' }}>
|
||||
{data ? data.volume.toLocaleString() : '—'}
|
||||
</td>
|
||||
<td style={{ padding: '2px 8px', textAlign: 'right', color: '#868e96' }}>
|
||||
{renderCountRef.current}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
});
|
||||
@@ -1,22 +0,0 @@
|
||||
import BookingSample from './features/booking/presentation/BookingSample';
|
||||
import StorageSample from './features/storage/presentation/StorageSample';
|
||||
import I18nSample from './features/i18n/presentation/I18nSample';
|
||||
|
||||
export default function ExamplePage() {
|
||||
return (
|
||||
<div className="bg-amber-200">
|
||||
example
|
||||
<div className="p-8">
|
||||
<h1 className="text-2xl font-bold mb-4">Enterprise Web App</h1>
|
||||
<BookingSample />
|
||||
</div>
|
||||
<div className="p-8">
|
||||
<h1 className="text-2xl font-bold mb-4">Enterprise Web App</h1>
|
||||
<StorageSample />
|
||||
</div>
|
||||
<div className="p-8 bg-slate-900">
|
||||
<I18nSample />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
-118
@@ -1,118 +0,0 @@
|
||||
import { BaseRemoteDataServices } from '@repo/core-api/data-services';
|
||||
import type { ApiResponse } from '@repo/core-api/http-client';
|
||||
import { apiClient } from '../../../../../../core/lib/api-client';
|
||||
import type { BookingEntity } from './booking.data-services';
|
||||
import type { BookingDTO } from './booking.transformer';
|
||||
import {
|
||||
AdvancedBookingTransformer,
|
||||
type AvailabilityChartRawData,
|
||||
type AvailabilityChartData,
|
||||
} from './advanced-booking.transformer';
|
||||
|
||||
// ─── Advanced Booking Data Services ─────────────────────────────
|
||||
|
||||
/**
|
||||
* Extended booking data services with custom methods for
|
||||
* advanced booking features beyond standard CRUD.
|
||||
*
|
||||
* Extends {@link BaseRemoteDataServices} directly (instead of using
|
||||
* `CommonRemoteDataServices`) to add domain-specific methods like
|
||||
* `getAvailabilityChart()`.
|
||||
*
|
||||
* Uses {@link AdvancedBookingTransformer} which provides:
|
||||
* - All standard DTO ↔ Entity mappings (inherited from BookingTransformer)
|
||||
* - Custom `transformAvailabilityChart()` for chart data
|
||||
* - Enhanced `transformGetManyResponse()` with status normalization
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Standard CRUD (inherited, with transformer)
|
||||
* const { data: bookings } = await advancedBookingServices.getMany();
|
||||
* const { data: booking } = await advancedBookingServices.getOne('42');
|
||||
*
|
||||
* // Custom method for chart data
|
||||
* const { data: chartData } = await advancedBookingServices.getAvailabilityChart({
|
||||
* startDate: '2026-07-01',
|
||||
* endDate: '2026-07-31',
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
class AdvancedBookingDataServices extends BaseRemoteDataServices<BookingEntity, BookingDTO> {
|
||||
/**
|
||||
* The concrete advanced transformer instance.
|
||||
*
|
||||
* Stored separately from the base `transformer` property
|
||||
* to access custom methods (like `transformAvailabilityChart`)
|
||||
* that aren't part of the `IDataTransformer` interface.
|
||||
*/
|
||||
private readonly advancedTransformer: AdvancedBookingTransformer;
|
||||
|
||||
constructor() {
|
||||
const advancedTransformer = new AdvancedBookingTransformer();
|
||||
|
||||
super(apiClient, {
|
||||
apiUrl: '/bookings',
|
||||
moduleKey: 'BOOKING',
|
||||
transformer: advancedTransformer,
|
||||
});
|
||||
|
||||
this.advancedTransformer = advancedTransformer;
|
||||
}
|
||||
|
||||
// ─── Custom Methods ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fetch the availability chart data for a given date range.
|
||||
*
|
||||
* Calls the `/bookings/availability-chart` endpoint and transforms
|
||||
* the raw API response into a UI-friendly chart format using
|
||||
* {@link AdvancedBookingTransformer.transformAvailabilityChart}.
|
||||
*
|
||||
* @param params - Date range parameters for the chart query
|
||||
* @param params.startDate - Start date (ISO format, e.g., '2026-07-01')
|
||||
* @param params.endDate - End date (ISO format, e.g., '2026-07-31')
|
||||
* @returns Transformed chart data ready for UI rendering
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const { data } = await advancedBookingServices.getAvailabilityChart({
|
||||
* startDate: '2026-07-01',
|
||||
* endDate: '2026-07-31',
|
||||
* });
|
||||
*
|
||||
* // data.dataPoints → Array of chart-ready data points
|
||||
* // data.summary → Aggregated metrics for the period
|
||||
* ```
|
||||
*/
|
||||
async getAvailabilityChart(params: {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
}): Promise<ApiResponse<AvailabilityChartData>> {
|
||||
const response = await this.customRequest<AvailabilityChartRawData>({
|
||||
url: '/bookings/availability-chart',
|
||||
method: 'GET',
|
||||
params: {
|
||||
start_date: params.startDate,
|
||||
end_date: params.endDate,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
data: this.advancedTransformer.transformAvailabilityChart(response.data),
|
||||
status: response.status,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Singleton Export ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Pre-configured advanced booking data services instance.
|
||||
*
|
||||
* Use this when you need both standard CRUD operations and
|
||||
* custom methods like `getAvailabilityChart()`.
|
||||
*
|
||||
* For standard CRUD-only usage, prefer `bookingServices` from
|
||||
* `booking.data-services.ts` instead.
|
||||
*/
|
||||
export const advancedBookingServices = new AdvancedBookingDataServices();
|
||||
-159
@@ -1,159 +0,0 @@
|
||||
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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import { CommonRemoteDataServices } from '@repo/core-api/data-services';
|
||||
import type { BaseEntity } from '@repo/core-api/data-services';
|
||||
import { apiClient } from '../../../../../../core/lib/api-client';
|
||||
import { BookingTransformer } from './booking.transformer';
|
||||
import type { BookingDTO } from './booking.transformer';
|
||||
|
||||
// ─── Domain Entity ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Booking domain entity.
|
||||
*
|
||||
* In a real module, this would be defined in the domain layer
|
||||
* (e.g., `features/booking/domain/entities.ts`) and imported here.
|
||||
*/
|
||||
export interface BookingEntity extends BaseEntity {
|
||||
bookingCode: string;
|
||||
customerName: string;
|
||||
checkInDate: string;
|
||||
checkOutDate: string;
|
||||
status: 'pending' | 'confirmed' | 'cancelled';
|
||||
totalAmount: number;
|
||||
}
|
||||
|
||||
// ─── Data Services Instance ─────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Booking data services — wired to the enterprise `apiClient`
|
||||
* with automatic DTO ↔ Entity transformation.
|
||||
*
|
||||
* All requests flow through the full interceptor chain:
|
||||
* Faro tracing → Bearer token injection → ApiError normalization.
|
||||
*
|
||||
* The injected {@link BookingTransformer} automatically:
|
||||
* - Maps snake_case API responses to camelCase entities on `getOne`/`getMany`
|
||||
* - Maps camelCase entity payloads to snake_case DTOs on `create`/`edit`
|
||||
* - Strips `id` from create payloads
|
||||
* - Computes `durationNights` on `getOne` responses
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const { data } = await bookingServices.getMany({ params: { page: 1 } });
|
||||
* // data is BookingEntity[] with camelCase fields
|
||||
*
|
||||
* const { data: booking } = await bookingServices.getOne('42');
|
||||
* // booking is BookingEntity with computed durationNights
|
||||
*
|
||||
* await bookingServices.create({ bookingCode: 'BK001', customerName: 'Alice', ... });
|
||||
* // Payload is automatically transformed to { booking_code: 'BK001', customer_name: 'Alice', ... }
|
||||
*
|
||||
* await bookingServices.confirmProcessTransaction('42');
|
||||
* ```
|
||||
*/
|
||||
export const bookingServices = new CommonRemoteDataServices<BookingEntity, BookingDTO>(apiClient, {
|
||||
apiUrl: '/bookings',
|
||||
moduleKey: 'BOOKING',
|
||||
transformer: new BookingTransformer(),
|
||||
});
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
import { BaseDataTransformer } from '@repo/core-api/data-services';
|
||||
import type { BookingEntity } from './booking.data-services';
|
||||
|
||||
// ─── Booking DTO (API Response Shape) ───────────────────────────
|
||||
|
||||
/**
|
||||
* Raw booking data as returned by the API.
|
||||
*
|
||||
* Uses snake_case field names matching the backend's JSON serialization.
|
||||
* This DTO is never used directly in UI components — it is transformed
|
||||
* into a {@link BookingEntity} by the {@link BookingTransformer}.
|
||||
*/
|
||||
export interface BookingDTO {
|
||||
id?: string;
|
||||
booking_code: string;
|
||||
customer_name: string;
|
||||
check_in_date: string;
|
||||
check_out_date: string;
|
||||
status: 'pending' | 'confirmed' | 'cancelled';
|
||||
total_amount: number;
|
||||
}
|
||||
|
||||
// ─── Booking Transformer ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Transforms between the API's `BookingDTO` (snake_case) and
|
||||
* the frontend's `BookingEntity` (camelCase).
|
||||
*
|
||||
* Handles:
|
||||
* - Field name mapping (snake_case ↔ camelCase)
|
||||
* - Computed field derivation (e.g., `durationNights` on `getOne`)
|
||||
* - Payload sanitization (e.g., stripping `id` on create)
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const transformer = new BookingTransformer();
|
||||
*
|
||||
* // API response → Domain entity
|
||||
* const entity = transformer.transformToEntity({
|
||||
* id: '42',
|
||||
* booking_code: 'BK042',
|
||||
* customer_name: 'Alice',
|
||||
* check_in_date: '2026-07-01',
|
||||
* check_out_date: '2026-07-03',
|
||||
* status: 'confirmed',
|
||||
* total_amount: 500000,
|
||||
* });
|
||||
* // → { id: '42', bookingCode: 'BK042', customerName: 'Alice', ... }
|
||||
* ```
|
||||
*/
|
||||
export class BookingTransformer extends BaseDataTransformer<BookingEntity, BookingDTO> {
|
||||
/**
|
||||
* Map an API booking DTO to a frontend booking entity.
|
||||
*
|
||||
* @param dto - Raw booking data from the API
|
||||
* @returns Mapped booking entity with camelCase fields
|
||||
*/
|
||||
override transformToEntity(dto: BookingDTO): BookingEntity {
|
||||
return {
|
||||
id: dto.id,
|
||||
bookingCode: dto.booking_code,
|
||||
customerName: dto.customer_name,
|
||||
checkInDate: dto.check_in_date,
|
||||
checkOutDate: dto.check_out_date,
|
||||
status: dto.status,
|
||||
totalAmount: dto.total_amount,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a frontend booking entity to an API booking DTO.
|
||||
*
|
||||
* @param entity - Booking entity from the frontend
|
||||
* @returns Mapped booking DTO with snake_case fields
|
||||
*/
|
||||
override transformToDTO(entity: BookingEntity): BookingDTO {
|
||||
return {
|
||||
id: entity.id as string,
|
||||
booking_code: entity.bookingCode,
|
||||
customer_name: entity.customerName,
|
||||
check_in_date: entity.checkInDate,
|
||||
check_out_date: entity.checkOutDate,
|
||||
status: entity.status,
|
||||
total_amount: entity.totalAmount,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform a single booking response with computed fields.
|
||||
*
|
||||
* Adds `durationNights` as a derived convenience field
|
||||
* that is only relevant when viewing a single booking detail.
|
||||
*
|
||||
* @param dto - Raw booking DTO from the API
|
||||
* @returns Booking entity with computed fields
|
||||
*/
|
||||
override transformGetOneResponse(dto: BookingDTO): BookingEntity {
|
||||
const entity = this.transformToEntity(dto);
|
||||
const checkIn = new Date(dto.check_in_date);
|
||||
const checkOut = new Date(dto.check_out_date);
|
||||
const durationMs = checkOut.getTime() - checkIn.getTime();
|
||||
const durationNights = Math.max(0, Math.ceil(durationMs / (1000 * 60 * 60 * 24)));
|
||||
|
||||
return {
|
||||
...entity,
|
||||
// Attach computed field via type assertion since
|
||||
// durationNights is a view-layer convenience
|
||||
...(durationNights > 0 ? { durationNights } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip `id` from create payloads since the backend generates IDs.
|
||||
*
|
||||
* @param entity - Partial booking entity from the create form
|
||||
* @returns Sanitized DTO payload without `id`
|
||||
*/
|
||||
override transformCreatePayload(entity: Partial<BookingEntity>): Partial<BookingDTO> {
|
||||
const dto = this.transformToDTO(entity as BookingEntity);
|
||||
const { id: _, ...rest } = dto;
|
||||
return rest;
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { bookingServices } from '../data/booking.data-services';
|
||||
import type { BookingEntity } from '../data/booking.data-services';
|
||||
import type { ApiResponse } from '@repo/core-api/http-client';
|
||||
import { ApiError } from '@repo/core-api/errors';
|
||||
|
||||
/**
|
||||
* Sample component demonstrating `@repo/core-api` integration
|
||||
* with the advanced TelemetryContext escape hatch.
|
||||
*
|
||||
* Pipeline: Faro auto-instrumentation → Bearer token → GET /bookings
|
||||
* + Custom span "booking.list.fetch" with enriched tags
|
||||
*/
|
||||
export default function BookingSample() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [result, setResult] = useState<ApiResponse<BookingEntity[]> | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleFetch = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
|
||||
try {
|
||||
const response = await bookingServices.getMany<BookingEntity[]>({
|
||||
params: { page: 1, limit: 20 },
|
||||
// ── Telemetry Escape Hatch ──────────────────────────────
|
||||
// This creates a custom OTel span named "booking.list.fetch",
|
||||
// attaches business tags, and pushes a Faro event on success.
|
||||
telemetryContext: {
|
||||
customSpanName: 'booking.list.fetch',
|
||||
tags: {
|
||||
'feature': 'booking',
|
||||
'ui.component': 'BookingSample',
|
||||
'ui.action': 'list_fetch',
|
||||
'page': 1,
|
||||
},
|
||||
pushEventOnSuccess: 'booking_list_loaded',
|
||||
},
|
||||
});
|
||||
setResult(response);
|
||||
console.log('[BookingSample] Response:', response);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(`[${err.code}] ${err.message} (HTTP ${err.status})`);
|
||||
console.error('[BookingSample] ApiError:', err.toJSON());
|
||||
} else {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24, fontFamily: 'monospace' }}>
|
||||
<h2>🧪 Booking Data Services — Integration Test</h2>
|
||||
<p style={{ color: '#888', fontSize: 14 }}>
|
||||
Pipeline: Faro + Custom Span "booking.list.fetch" → Bearer Token → GET /bookings
|
||||
</p>
|
||||
|
||||
<button
|
||||
onClick={handleFetch}
|
||||
disabled={loading}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
fontSize: 16,
|
||||
cursor: loading ? 'wait' : 'pointer',
|
||||
background: loading ? '#555' : '#4f46e5',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: 6,
|
||||
marginTop: 12,
|
||||
}}
|
||||
>
|
||||
{loading ? 'Fetching…' : 'Test Fetch Bookings'}
|
||||
</button>
|
||||
|
||||
{error && (
|
||||
<pre style={{ color: '#ef4444', marginTop: 16, whiteSpace: 'pre-wrap' }}>
|
||||
❌ {error}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<pre style={{ marginTop: 16, background: '#1e1e2e', color: '#a6e3a1', padding: 16, borderRadius: 8, overflow: 'auto' }}>
|
||||
{JSON.stringify(result, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,408 +0,0 @@
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Button, Paper, Title, Group, Stack, Code, Divider, Text, Chip, Radio } from '@repo/ui/components';
|
||||
import {
|
||||
FieldTextInput, FieldPasswordInput, FieldTextarea, FieldNumberInput,
|
||||
FieldJsonInput, FieldPinInput, FieldAutocomplete, FieldSelect,
|
||||
FieldMultiSelect, FieldNativeSelect, FieldTagsInput, FieldCheckbox,
|
||||
FieldRadioGroup, FieldSwitch, FieldChipGroup, FieldSegmentedControl,
|
||||
FieldSlider, FieldRangeSlider, FieldRating, FieldColorInput,
|
||||
FieldColorPicker, FieldFileInput, FieldLocalSelect, FieldAsyncSelect,
|
||||
FieldRichTextEditor
|
||||
} from '@repo/ui/form';
|
||||
import type { LoadOptionsFn } from '@repo/ui/form';
|
||||
import { useFormDemoTranslation } from '../i18n/useFormDemoTranslation';
|
||||
|
||||
const MOCK_POKEMON = Array.from({ length: 100 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
name: `Pokemon ${i + 1}`,
|
||||
}));
|
||||
|
||||
const loadMockPokemonOptions: LoadOptionsFn<any> = async (search, page) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
const filtered = MOCK_POKEMON.filter((p) => p.name.toLowerCase().includes(search.toLowerCase()));
|
||||
const pageSize = 20;
|
||||
const start = (page - 1) * pageSize;
|
||||
const paginated = filtered.slice(start, start + pageSize);
|
||||
return {
|
||||
options: paginated,
|
||||
hasMore: start + pageSize < filtered.length,
|
||||
};
|
||||
};
|
||||
|
||||
const loadRealPokemonOptions: LoadOptionsFn<any> = async (_search, page) => {
|
||||
const limit = 20;
|
||||
const offset = (page - 1) * limit;
|
||||
const res = await fetch(`https://pokeapi.co/api/v2/pokemon?limit=${limit}&offset=${offset}`);
|
||||
const data = await res.json();
|
||||
return {
|
||||
options: data.results.map((p: any, i: number) => ({ id: offset + i + 1, ...p })),
|
||||
hasMore: !!data.next,
|
||||
};
|
||||
};
|
||||
|
||||
const MOCK_VENDORS = [
|
||||
{ id: 'V1', code: 'VN-01', name: 'Vendor One' },
|
||||
{ id: 'V2', code: 'VN-02', name: 'Vendor Two' },
|
||||
{ id: 'V3', code: 'VN-03', name: 'Vendor Three' },
|
||||
];
|
||||
|
||||
const loadMockVendorsOptions: LoadOptionsFn<any> = async (search, _page) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
const filtered = MOCK_VENDORS.filter(v => v.name.toLowerCase().includes(search.toLowerCase()) || v.code.toLowerCase().includes(search.toLowerCase()));
|
||||
return { options: filtered, hasMore: false };
|
||||
};
|
||||
|
||||
export default function AllFieldsDemo() {
|
||||
const t = useFormDemoTranslation();
|
||||
|
||||
const { control, handleSubmit, watch } = useForm<any>({
|
||||
defaultValues: {
|
||||
customerName: '',
|
||||
email: '',
|
||||
password: '',
|
||||
description: '',
|
||||
age: undefined,
|
||||
jsonConfig: '',
|
||||
pin: '',
|
||||
country: '',
|
||||
orderType: '',
|
||||
categories: [],
|
||||
nativeOrderType: '',
|
||||
tags: [],
|
||||
terms: false,
|
||||
priority: '',
|
||||
receiveEmails: false,
|
||||
chipSelection: '',
|
||||
segmentedPriority: 'normal',
|
||||
satisfaction: 5,
|
||||
priceRange: [0, 100],
|
||||
rating: 0,
|
||||
themeColor: '',
|
||||
colorPicker: '#1c7ed6',
|
||||
avatar: null,
|
||||
localSelectEmpty: null,
|
||||
localSelectPrefilled: { id: 'V2', code: 'VN-02', name: 'Vendor Two' },
|
||||
asyncSelectEmpty: null,
|
||||
asyncSelectPrefilled: { id: 999, code: 'ASYNC-99', name: 'Pre-loaded Async Vendor' },
|
||||
localMultiPrefilled: [
|
||||
{ id: 'V1', code: 'VN-01', name: 'Vendor One' },
|
||||
{ id: 'V2', code: 'VN-02', name: 'Vendor Two' }
|
||||
],
|
||||
asyncMultiPrefilled: [
|
||||
{ id: 888, code: 'ASYNC-88', name: 'Ghost Async Vendor 1' },
|
||||
{ id: 999, code: 'ASYNC-99', name: 'Ghost Async Vendor 2' }
|
||||
],
|
||||
richTextEmpty: "",
|
||||
richTextPrefilled: "<h2 style=\"text-align: center\">ERP Release Notes</h2><p>This is a <b>highly important</b> update. Please observe the following:</p><ul><li>System maintenance at <i>midnight</i>.</li><li><u style=\"text-align: justify\">All users must log out.</u></li></ul><p style=\"text-align: justify\">Thank you for your cooperation.</p>",
|
||||
realPokeSelect: null,
|
||||
multiRealPokeSelect: []
|
||||
}
|
||||
});
|
||||
|
||||
const onSubmit = (data: any) => console.log('All Fields Submitted:', data);
|
||||
const data = watch();
|
||||
|
||||
return (
|
||||
<Stack gap="xl">
|
||||
<Paper p="xl" withBorder radius="md">
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack gap="xl">
|
||||
{/* --- Text & Numbers --- */}
|
||||
<div>
|
||||
<Title order={5} mb="sm" c="brand">{t.sections.textAndNumbers}</Title>
|
||||
<Divider mb="md" />
|
||||
<Group grow align="flex-start" mb="md">
|
||||
<FieldTextInput name="customerName" control={control} label={t.fields.customerName} />
|
||||
<FieldTextInput name="email" control={control} label={t.fields.email} />
|
||||
</Group>
|
||||
<Group grow align="flex-start" mb="md">
|
||||
<FieldPasswordInput name="password" control={control} label={t.fields.password} />
|
||||
<FieldNumberInput name="age" control={control} label={t.fields.age} />
|
||||
</Group>
|
||||
<Group grow align="flex-start" mb="md">
|
||||
<FieldTextarea name="description" control={control} label={t.fields.description} minRows={3} />
|
||||
<FieldJsonInput name="jsonConfig" control={control} label={t.fields.jsonConfig} formatOnBlur />
|
||||
</Group>
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb={3}>{t.fields.pin}</Text>
|
||||
<FieldPinInput name="pin" control={control} length={6} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* --- Selections --- */}
|
||||
<div>
|
||||
<Title order={5} mb="sm" c="brand">{t.sections.selections}</Title>
|
||||
<Divider mb="md" />
|
||||
<Group grow align="flex-start" mb="md">
|
||||
<FieldSelect
|
||||
name="orderType"
|
||||
control={control}
|
||||
label={t.fields.orderType}
|
||||
data={['BULK', 'RETAIL']}
|
||||
/>
|
||||
<FieldNativeSelect
|
||||
name="nativeOrderType"
|
||||
control={control}
|
||||
label={`Native ${t.fields.orderType}`}
|
||||
data={['BULK', 'RETAIL']}
|
||||
/>
|
||||
</Group>
|
||||
<Group grow align="flex-start" mb="md">
|
||||
<FieldAutocomplete
|
||||
name="country"
|
||||
control={control}
|
||||
label={t.fields.country}
|
||||
data={['Indonesia', 'Singapore', 'Malaysia']}
|
||||
/>
|
||||
<FieldMultiSelect
|
||||
name="categories"
|
||||
control={control}
|
||||
label={t.fields.categories}
|
||||
data={['Electronics', 'Fashion', 'Food']}
|
||||
/>
|
||||
</Group>
|
||||
<Group grow align="flex-start" mb="md">
|
||||
<FieldLocalSelect
|
||||
name="localSelect"
|
||||
control={control}
|
||||
label={t.fields.localSelect}
|
||||
placeholder={t.placeholders.selectComplexObject}
|
||||
options={[
|
||||
{ id: 1, name: 'Apple', type: 'Fruit' },
|
||||
{ id: 2, name: 'Carrot', type: 'Vegetable' },
|
||||
{ id: 3, name: 'Banana', type: 'Fruit' }
|
||||
]}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
clearable
|
||||
/>
|
||||
<FieldLocalSelect
|
||||
multiple
|
||||
name="multiLocalSelect"
|
||||
control={control}
|
||||
label={t.fields.multiLocalSelect}
|
||||
placeholder={t.placeholders.selectMultipleObjects}
|
||||
options={[
|
||||
{ id: 1, name: 'Red', hex: '#f00' },
|
||||
{ id: 2, name: 'Green', hex: '#0f0' },
|
||||
{ id: 3, name: 'Blue', hex: '#00f' }
|
||||
]}
|
||||
valueKey="id"
|
||||
renderLabel={(item) => `${item.name} (${item.hex})`}
|
||||
clearable
|
||||
/>
|
||||
</Group>
|
||||
<Group grow align="flex-start" mb="md">
|
||||
<FieldAsyncSelect
|
||||
name="asyncSelect"
|
||||
control={control}
|
||||
label={t.fields.asyncSelectMock}
|
||||
placeholder={t.placeholders.searchPokemon}
|
||||
loadOptions={loadMockPokemonOptions}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
clearable
|
||||
/>
|
||||
<FieldAsyncSelect
|
||||
multiple
|
||||
name="multiAsyncSelect"
|
||||
control={control}
|
||||
label={t.fields.multiAsyncSelect}
|
||||
placeholder={t.placeholders.selectMultiplePokemon}
|
||||
loadOptions={loadMockPokemonOptions}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
clearable
|
||||
/>
|
||||
</Group>
|
||||
<Group grow align="flex-start" mb="md">
|
||||
<FieldAsyncSelect
|
||||
name="realPokeSelect"
|
||||
control={control}
|
||||
label={t.fields.realPokeSingle}
|
||||
placeholder={t.placeholders.scrollDeduplication}
|
||||
loadOptions={loadRealPokemonOptions}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
clearable
|
||||
/>
|
||||
<FieldAsyncSelect
|
||||
multiple
|
||||
name="multiRealPokeSelect"
|
||||
control={control}
|
||||
label={t.fields.realPokeMulti}
|
||||
placeholder={t.placeholders.scrollDeduplication}
|
||||
loadOptions={loadRealPokemonOptions}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
clearable
|
||||
/>
|
||||
</Group>
|
||||
<FieldTagsInput name="tags" control={control} label={t.fields.tags} />
|
||||
|
||||
<Title order={5} mb="sm" mt="lg" c="brand">{t.sections.advancedObjectSelects}</Title>
|
||||
<Divider mb="md" />
|
||||
<Group grow align="flex-start" mb="md">
|
||||
<FieldLocalSelect
|
||||
name="localSelectEmpty"
|
||||
control={control}
|
||||
label={t.fields.localEmpty}
|
||||
options={MOCK_VENDORS}
|
||||
valueKey="id"
|
||||
renderLabel={(item) => `[${item.code}] ${item.name}`}
|
||||
clearable
|
||||
/>
|
||||
<FieldLocalSelect
|
||||
name="localSelectPrefilled"
|
||||
control={control}
|
||||
label={t.fields.localPrefilled}
|
||||
options={MOCK_VENDORS}
|
||||
valueKey="id"
|
||||
renderLabel={(item) => `[${item.code}] ${item.name}`}
|
||||
clearable
|
||||
/>
|
||||
</Group>
|
||||
<Group grow align="flex-start" mb="md">
|
||||
<FieldAsyncSelect
|
||||
name="asyncSelectEmpty"
|
||||
control={control}
|
||||
label={t.fields.asyncEmpty}
|
||||
loadOptions={loadMockVendorsOptions}
|
||||
valueKey="id"
|
||||
renderLabel={(item) => `[${item.code}] ${item.name}`}
|
||||
clearable
|
||||
/>
|
||||
<FieldAsyncSelect
|
||||
name="asyncSelectPrefilled"
|
||||
control={control}
|
||||
label={t.fields.asyncPrefilled}
|
||||
loadOptions={loadMockVendorsOptions}
|
||||
valueKey="id"
|
||||
renderLabel={(item) => `[${item.code}] ${item.name}`}
|
||||
defaultOptions={[{ id: 999, code: 'ASYNC-99', name: 'Pre-loaded Async Vendor' }]}
|
||||
clearable
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Title order={5} mb="sm" mt="lg" c="brand">{t.sections.multiSelectEditMode}</Title>
|
||||
<Divider mb="md" />
|
||||
<Group grow align="flex-start" mb="md">
|
||||
<FieldLocalSelect
|
||||
multiple
|
||||
name="localMultiPrefilled"
|
||||
control={control}
|
||||
label={t.fields.localMultiPrefilled}
|
||||
options={MOCK_VENDORS}
|
||||
valueKey="id"
|
||||
renderLabel={(item) => `[${item.code}] ${item.name}`}
|
||||
clearable
|
||||
/>
|
||||
<FieldAsyncSelect
|
||||
multiple
|
||||
name="asyncMultiPrefilled"
|
||||
control={control}
|
||||
label={t.fields.asyncMultiPrefilled}
|
||||
loadOptions={loadMockVendorsOptions}
|
||||
valueKey="id"
|
||||
renderLabel={(item) => `[${item.code}] ${item.name}`}
|
||||
clearable
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Title order={5} mb="sm" mt="lg" c="brand">{t.sections.richTextEditor}</Title>
|
||||
<Divider mb="md" />
|
||||
<FieldRichTextEditor
|
||||
name="richTextEmpty"
|
||||
control={control}
|
||||
label={t.fields.richTextEmpty}
|
||||
description={t.descriptions.freshTipTap}
|
||||
/>
|
||||
<div style={{ marginTop: '16px' }}>
|
||||
<FieldRichTextEditor
|
||||
name="richTextPrefilled"
|
||||
control={control}
|
||||
label={t.fields.richTextPrefilled}
|
||||
description={t.descriptions.htmlStringLoaded}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* --- Toggles & Choices --- */}
|
||||
<div>
|
||||
<Title order={5} mb="sm" c="brand">{t.sections.togglesAndChoices}</Title>
|
||||
<Divider mb="md" />
|
||||
<Group mb="md">
|
||||
<FieldCheckbox name="terms" control={control} label={t.fields.terms} />
|
||||
<FieldSwitch name="receiveEmails" control={control} label={t.fields.receiveEmails} />
|
||||
</Group>
|
||||
<FieldRadioGroup
|
||||
name="priority"
|
||||
control={control}
|
||||
label={t.fields.priority}
|
||||
mb="md"
|
||||
>
|
||||
<Group mt="xs">
|
||||
<Radio value="low" label="Low" />
|
||||
<Radio value="high" label="High" />
|
||||
</Group>
|
||||
</FieldRadioGroup>
|
||||
<FieldSegmentedControl
|
||||
name="segmentedPriority"
|
||||
control={control}
|
||||
label={t.fields.priority}
|
||||
data={[
|
||||
{ label: 'Normal', value: 'normal' },
|
||||
{ label: 'Urgent', value: 'urgent' }
|
||||
]}
|
||||
mb="md"
|
||||
/>
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb={3}>Chip Selection</Text>
|
||||
<FieldChipGroup
|
||||
name="chipSelection"
|
||||
control={control}
|
||||
>
|
||||
<Group>
|
||||
<Chip value="1">Option 1</Chip>
|
||||
<Chip value="2">Option 2</Chip>
|
||||
</Group>
|
||||
</FieldChipGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* --- Ranges & Specialized --- */}
|
||||
<div>
|
||||
<Title order={5} mb="sm" c="brand">{t.sections.rangesAndSpecialized}</Title>
|
||||
<Divider mb="md" />
|
||||
<Group grow align="flex-start" mb="md">
|
||||
<FieldSlider name="satisfaction" control={control} label={t.fields.rating} />
|
||||
<FieldRangeSlider name="priceRange" control={control} label={t.fields.priceRange} />
|
||||
</Group>
|
||||
<Group grow align="flex-start" mb="md">
|
||||
<FieldColorInput name="themeColor" control={control} label={t.fields.themeColor} />
|
||||
<FieldFileInput name="avatar" control={control} label={t.fields.avatar} />
|
||||
</Group>
|
||||
<Group grow align="flex-start" mb="md">
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb={3}>{t.fields.themeColor} Picker</Text>
|
||||
<FieldColorPicker name="colorPicker" control={control} />
|
||||
</div>
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb={3}>{t.fields.rating}</Text>
|
||||
<FieldRating name="rating" control={control} />
|
||||
</div>
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<Button type="submit" mt="md">{t.common.submit}</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</Paper>
|
||||
|
||||
<Paper p="md" withBorder radius="md" bg="var(--mantine-color-gray-0)">
|
||||
<Title order={6} mb="xs">{t.common.submittedData}</Title>
|
||||
<Code block>{JSON.stringify(data, null, 2)}</Code>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
-368
@@ -1,368 +0,0 @@
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { Button, Paper, Title, Divider, Stack, Code, Alert, TypographyStylesProvider } from '@repo/ui/components';
|
||||
import { FieldTextInput, FieldSelect, FieldSwitch, FieldLocalSelect, FieldAsyncSelect, FieldRichTextEditor } from '@repo/ui/form';
|
||||
import { useConditionalField } from '@repo/ui/hooks';
|
||||
import { compose, required, emailValidator } from '@repo/ui/validators';
|
||||
import { useFormDemoTranslation } from '../i18n/useFormDemoTranslation';
|
||||
import { Info } from 'lucide-react';
|
||||
import { useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
|
||||
interface Region {
|
||||
id: string;
|
||||
code: string;
|
||||
taxRate: number;
|
||||
}
|
||||
|
||||
interface Warehouse {
|
||||
id: string;
|
||||
regionId: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const REGIONS: Region[] = [
|
||||
{ id: 'R1', code: 'APAC', taxRate: 0.1 },
|
||||
{ id: 'R2', code: 'EMEA', taxRate: 0.2 }
|
||||
];
|
||||
|
||||
const mockFetchWarehouses = async (regionIds: string[], search: string, page: number) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
const allWarehouses: Warehouse[] = [
|
||||
{ id: 'W1', regionId: 'R1', name: 'Singapore Hub' },
|
||||
{ id: 'W2', regionId: 'R1', name: 'Tokyo Depot' },
|
||||
{ id: 'W3', regionId: 'R2', name: 'London Central' },
|
||||
{ id: 'W4', regionId: 'R2', name: 'Berlin Storage' },
|
||||
];
|
||||
|
||||
const filtered = allWarehouses.filter(w => regionIds.includes(w.regionId) && w.name.toLowerCase().includes(search.toLowerCase()));
|
||||
const pageSize = 10;
|
||||
const start = (page - 1) * pageSize;
|
||||
const paginated = filtered.slice(start, start + pageSize);
|
||||
|
||||
return {
|
||||
options: paginated,
|
||||
hasMore: start + pageSize < filtered.length
|
||||
};
|
||||
};
|
||||
|
||||
export default function ReactiveWatchDemo() {
|
||||
const t = useFormDemoTranslation();
|
||||
|
||||
// Define atomic validators for conditional fields
|
||||
const taxIdValidator = compose(z.string(), required(t.watch.corporateTaxId));
|
||||
const spouseNameValidator = compose(z.string(), required(t.watch.spouseName));
|
||||
const newsletterEmailValidator = compose(z.string(), required(t.fields.email), emailValidator());
|
||||
const roleValidator = compose(z.string(), required(t.fields.role));
|
||||
|
||||
const reactiveSchema = useMemo(() => z
|
||||
.object({
|
||||
userType: z.enum(['PERSONAL', 'CORPORATE']),
|
||||
corporateTaxId: z.string().optional(),
|
||||
hasSpouse: z.boolean(),
|
||||
spouseName: z.string().optional(),
|
||||
newsletter: z.boolean(),
|
||||
newsletterEmail: z.string().optional(),
|
||||
department: z.string().optional(),
|
||||
role: z.string().optional(),
|
||||
regions: z.array(z.object({ id: z.string(), code: z.string(), taxRate: z.number() })).optional(),
|
||||
warehouses: z.array(z.object({ id: z.string(), regionId: z.string(), name: z.string() })).optional(),
|
||||
richTextLive: z.string().optional(),
|
||||
})
|
||||
.and(
|
||||
z.discriminatedUnion('userType', [
|
||||
z.object({ userType: z.literal('PERSONAL') }),
|
||||
z.object({ userType: z.literal('CORPORATE'), corporateTaxId: taxIdValidator }),
|
||||
]),
|
||||
)
|
||||
.and(
|
||||
z.union([
|
||||
z.object({ hasSpouse: z.literal(false) }),
|
||||
z.object({ hasSpouse: z.literal(true), spouseName: spouseNameValidator }),
|
||||
]),
|
||||
)
|
||||
.and(
|
||||
z.union([
|
||||
z.object({ newsletter: z.literal(false) }),
|
||||
z.object({ newsletter: z.literal(true), newsletterEmail: newsletterEmailValidator }),
|
||||
]),
|
||||
)
|
||||
.and(
|
||||
z.union([
|
||||
z.object({ department: z.string().min(1), role: roleValidator }),
|
||||
z.object({ department: z.union([z.literal(''), z.undefined(), z.null()]).optional() }),
|
||||
]),
|
||||
), [t, taxIdValidator, spouseNameValidator, newsletterEmailValidator, roleValidator]);
|
||||
|
||||
const { control, handleSubmit, setValue, unregister, clearErrors } = useForm<any>({
|
||||
resolver: zodResolver(reactiveSchema as any),
|
||||
defaultValues: {
|
||||
userType: 'PERSONAL',
|
||||
corporateTaxId: '',
|
||||
hasSpouse: false,
|
||||
spouseName: '',
|
||||
newsletter: false,
|
||||
newsletterEmail: '',
|
||||
department: '',
|
||||
role: '',
|
||||
regions: [{ id: 'R1', code: 'APAC', taxRate: 0.1 }],
|
||||
warehouses: [
|
||||
{ id: 'W-99', regionId: 'R1', name: 'APAC Central Hub' },
|
||||
{ id: 'W-98', regionId: 'R1', name: 'APAC Backup Hub' }
|
||||
],
|
||||
richTextLive: "<p>Start typing to see the live preview...</p>",
|
||||
},
|
||||
});
|
||||
|
||||
// Micro-subscriptions via useWatch
|
||||
const userType = useWatch({ control, name: 'userType' });
|
||||
const hasSpouse = useWatch({ control, name: 'hasSpouse' });
|
||||
const newsletter = useWatch({ control, name: 'newsletter' });
|
||||
const department = useWatch({ control, name: 'department' });
|
||||
const role = useWatch({ control, name: 'role' });
|
||||
const regions = useWatch({ control, name: 'regions' });
|
||||
const watchedRichTextLive = useWatch({ control, name: 'richTextLive' });
|
||||
|
||||
// Use the custom hook to cleanly unregister and reset fields when hidden
|
||||
useConditionalField({
|
||||
condition: userType === 'CORPORATE',
|
||||
name: 'corporateTaxId',
|
||||
setValue,
|
||||
unregister,
|
||||
mode: 'unregister',
|
||||
defaultValue: '',
|
||||
});
|
||||
|
||||
useConditionalField({
|
||||
condition: hasSpouse === true,
|
||||
name: 'spouseName',
|
||||
setValue,
|
||||
unregister,
|
||||
mode: 'unregister',
|
||||
defaultValue: '',
|
||||
});
|
||||
|
||||
// Use the reset mode to clear values and errors without unregistering the field
|
||||
useConditionalField({
|
||||
condition: newsletter === true,
|
||||
name: 'newsletterEmail',
|
||||
setValue,
|
||||
clearErrors,
|
||||
mode: 'reset',
|
||||
defaultValue: '',
|
||||
});
|
||||
|
||||
// Cascading Dropdown Logic: Department -> Role
|
||||
const roleOptions: Record<string, { value: string; label: string }[]> = {
|
||||
IT: [
|
||||
{ value: 'FRONTEND', label: 'Frontend Engineer' },
|
||||
{ value: 'BACKEND', label: 'Backend Engineer' },
|
||||
],
|
||||
HR: [
|
||||
{ value: 'RECRUITER', label: 'Technical Recruiter' },
|
||||
{ value: 'MANAGER', label: 'HR Manager' },
|
||||
],
|
||||
FINANCE: [
|
||||
{ value: 'ACCOUNTANT', label: 'Accountant' },
|
||||
{ value: 'ANALYST', label: 'Financial Analyst' },
|
||||
],
|
||||
};
|
||||
|
||||
const currentRoleOptions = department ? roleOptions[department] : [];
|
||||
const isRoleValid = !role || (!!department && currentRoleOptions.some((opt) => opt.value === role));
|
||||
|
||||
// Reset Mode: Automatically clears the 'role' field value and errors if the department changes
|
||||
// and the currently selected role is no longer valid for the new department.
|
||||
useConditionalField({
|
||||
condition: isRoleValid,
|
||||
name: 'role',
|
||||
setValue,
|
||||
clearErrors,
|
||||
mode: 'reset',
|
||||
defaultValue: '',
|
||||
});
|
||||
|
||||
const isMounted = useRef(false);
|
||||
const prevRegionIds = useRef<string[]>(regions?.map((r: Region) => r.id) || []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMounted.current) {
|
||||
isMounted.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const currentIds = regions?.map((r: Region) => r.id) || [];
|
||||
const prevIds = prevRegionIds.current;
|
||||
|
||||
const hasChanged = currentIds.length !== prevIds.length || currentIds.some((id: string) => !prevIds.includes(id));
|
||||
|
||||
if (hasChanged) {
|
||||
setValue('warehouses', []);
|
||||
clearErrors('warehouses');
|
||||
prevRegionIds.current = currentIds;
|
||||
}
|
||||
}, [regions, setValue, clearErrors]);
|
||||
|
||||
// Use watch only to display the JSON output at the bottom
|
||||
const allValues = useWatch({ control });
|
||||
|
||||
const onSubmit = (data: any) => console.log('Reactive Passed:', data);
|
||||
|
||||
return (
|
||||
<Stack gap="xl">
|
||||
<Paper p="xl" withBorder radius="md">
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack gap="md">
|
||||
<Title order={5} mb="sm" c="brand">
|
||||
{t.sections.reactiveWatchCascading}
|
||||
</Title>
|
||||
<Divider mb="sm" />
|
||||
|
||||
<Alert icon={<Info size={16} />} title="Micro-subscription Pattern" color="blue" variant="light">
|
||||
This form demonstrates isolated re-rendering using <code>useWatch</code>. Instead of re-rendering the
|
||||
entire form root when typing, only the specific conditional fields update their display states.
|
||||
</Alert>
|
||||
|
||||
<Divider label="Hidden/Unmounted Pattern" labelPosition="center" my="sm" />
|
||||
|
||||
<FieldSelect
|
||||
name="userType"
|
||||
control={control}
|
||||
label={t.watch.userType}
|
||||
data={[
|
||||
{ value: 'PERSONAL', label: t.watch.typePersonal || 'Personal' },
|
||||
{ value: 'CORPORATE', label: t.watch.typeCorporate || 'Corporate' },
|
||||
]}
|
||||
withAsterisk
|
||||
/>
|
||||
|
||||
{userType === 'CORPORATE' && (
|
||||
<FieldTextInput name="corporateTaxId" control={control} label={t.watch.corporateTaxId} withAsterisk />
|
||||
)}
|
||||
|
||||
<FieldSwitch name="hasSpouse" control={control} label={t.watch.hasSpouse} mt="md" />
|
||||
|
||||
{hasSpouse && (
|
||||
<FieldTextInput name="spouseName" control={control} label={t.watch.spouseName} withAsterisk />
|
||||
)}
|
||||
|
||||
<Divider label="Visible but Disabled Pattern" labelPosition="center" my="md" />
|
||||
|
||||
<FieldSwitch name="newsletter" control={control} label="Subscribe to Newsletter" />
|
||||
|
||||
<FieldTextInput
|
||||
name="newsletterEmail"
|
||||
control={control}
|
||||
label="Newsletter Email"
|
||||
disabled={!newsletter}
|
||||
placeholder="Enter your email to subscribe"
|
||||
withAsterisk={newsletter}
|
||||
/>
|
||||
|
||||
<Divider label="Reset Mode (Cascading Dependencies)" labelPosition="center" my="md" />
|
||||
|
||||
<FieldSelect
|
||||
name="department"
|
||||
control={control}
|
||||
label={t.fields.department}
|
||||
placeholder={t.placeholders.selectComplexObject}
|
||||
data={[
|
||||
{ value: 'IT', label: 'Information Technology' },
|
||||
{ value: 'HR', label: 'Human Resources' },
|
||||
{ value: 'FINANCE', label: 'Finance' },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* * ⚠️ CRITICAL UI FIX: DYNAMIC KEY
|
||||
* Why bind the 'key' to the parent dependency (department)?
|
||||
* * Mantine's Select component caches its internal visual state. When the parent
|
||||
* 'department' changes, our useConditionalField hook successfully clears the RHF
|
||||
* payload state, but Mantine might visually retain the old text on the screen.
|
||||
* * By changing the 'key' whenever the department changes, we force React to
|
||||
* completely unmount and remount this component. This destroys Mantine's old
|
||||
* internal cache and guarantees a perfectly clean UI sync.
|
||||
*/}
|
||||
<FieldSelect
|
||||
key={`role-select-${department}`}
|
||||
name="role"
|
||||
control={control}
|
||||
label={t.fields.role}
|
||||
placeholder={t.placeholders.selectComplexObject}
|
||||
disabled={!department}
|
||||
data={currentRoleOptions}
|
||||
withAsterisk={!!department}
|
||||
/>
|
||||
|
||||
<Title order={5} mb="sm" c="brand" mt="lg">
|
||||
{t.sections.reactiveWatchCascading}
|
||||
</Title>
|
||||
<Divider mb="sm" />
|
||||
|
||||
<FieldLocalSelect<Region>
|
||||
multiple
|
||||
name="regions"
|
||||
control={control as any}
|
||||
label={t.fields.regions}
|
||||
options={REGIONS}
|
||||
valueKey="id"
|
||||
labelKey="code"
|
||||
clearable
|
||||
/>
|
||||
|
||||
<FieldAsyncSelect<Warehouse>
|
||||
multiple
|
||||
key={`warehouse-select-${regions?.map((r: any) => r.id).join(',')}`}
|
||||
name="warehouses"
|
||||
control={control as any}
|
||||
label={t.fields.warehouses}
|
||||
disabled={!regions || regions.length === 0}
|
||||
loadOptions={useCallback(async (search, page) => {
|
||||
if (!regions || regions.length === 0) return { options: [], hasMore: false };
|
||||
return mockFetchWarehouses(regions.map((r: any) => r.id), search, page);
|
||||
}, [regions])}
|
||||
valueKey="id"
|
||||
renderLabel={(item) => `[${item.id}] ${item.name}`}
|
||||
clearable
|
||||
/>
|
||||
|
||||
{regions && regions.length > 0 && (
|
||||
<Alert mt="sm" color="teal">
|
||||
{t.descriptions.selectedRegionsTax} {regions.map((r: any) => `${r.code} (${(r.taxRate * 100).toFixed(0)}%)`).join(', ')}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Title order={5} mb="sm" c="brand" mt="lg">
|
||||
{t.sections.reactiveRichTextPreview}
|
||||
</Title>
|
||||
<Divider mb="sm" />
|
||||
|
||||
<FieldRichTextEditor
|
||||
name="richTextLive"
|
||||
control={control as any}
|
||||
label={t.fields.liveEditor}
|
||||
description={t.descriptions.typeToSeePreview}
|
||||
/>
|
||||
|
||||
<Paper p="md" withBorder radius="md" mt="sm">
|
||||
<Title order={6} mb="xs">{t.sections.liveHtmlPreview}</Title>
|
||||
<TypographyStylesProvider>
|
||||
<div dangerouslySetInnerHTML={{ __html: watchedRichTextLive }} />
|
||||
</TypographyStylesProvider>
|
||||
</Paper>
|
||||
|
||||
<Button type="submit" mt="md">
|
||||
{t.common.submitReactive}
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</Paper>
|
||||
|
||||
<Paper p="md" withBorder radius="md" bg="var(--mantine-color-gray-0)">
|
||||
<Title order={6} mb="xs">
|
||||
{t.common?.submittedData || 'Submitted Data'}
|
||||
</Title>
|
||||
<Code block>{JSON.stringify(allValues, null, 2)}</Code>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
-254
@@ -1,254 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { Button, Paper, Title, Group, Stack, Code, Divider } from '@repo/ui/components';
|
||||
import {
|
||||
FieldTextInput, FieldPasswordInput, FieldNumberInput,
|
||||
FieldLocalSelect, FieldAsyncSelect, FieldRichTextEditor
|
||||
} from '@repo/ui/form';
|
||||
import type { LoadOptionsFn } from '@repo/ui/form';
|
||||
|
||||
interface Department {
|
||||
code: string;
|
||||
name: string;
|
||||
costCenter: string;
|
||||
}
|
||||
|
||||
interface Assignee {
|
||||
id: number;
|
||||
email: string;
|
||||
}
|
||||
|
||||
const MOCK_DEPARTMENTS: Department[] = [
|
||||
{ code: 'IT', name: 'Information Technology', costCenter: 'CC-100' },
|
||||
{ code: 'HR', name: 'Human Resources', costCenter: 'CC-200' },
|
||||
{ code: 'FIN', name: 'Finance', costCenter: 'CC-300' },
|
||||
];
|
||||
|
||||
const mockFetchUsers: LoadOptionsFn<Assignee> = async (search, page) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
const allUsers = Array.from({ length: 20 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
email: `user${i + 1}@company.com`
|
||||
}));
|
||||
const filtered = allUsers.filter(u => u.email.toLowerCase().includes(search.toLowerCase()));
|
||||
const pageSize = 5;
|
||||
const start = (page - 1) * pageSize;
|
||||
const paginated = filtered.slice(start, start + pageSize);
|
||||
|
||||
return {
|
||||
options: paginated,
|
||||
hasMore: start + pageSize < filtered.length
|
||||
};
|
||||
};
|
||||
|
||||
const MOCK_VENDORS = [
|
||||
{ id: 'V1', code: 'VN-01', name: 'Vendor One' },
|
||||
{ id: 'V2', code: 'VN-02', name: 'Vendor Two' },
|
||||
];
|
||||
|
||||
const mockFetchVendors: LoadOptionsFn<any> = async (search, _page) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
const filtered = MOCK_VENDORS.filter(v => v.name.toLowerCase().includes(search.toLowerCase()) || v.code.toLowerCase().includes(search.toLowerCase()));
|
||||
return { options: filtered, hasMore: false };
|
||||
};
|
||||
import {
|
||||
compose, required, rangeLength,
|
||||
positiveNumber, simplePassword,
|
||||
complexPassword, phoneValidator, rangeValue
|
||||
} from '@repo/ui/validators';
|
||||
import { useFormDemoTranslation } from '../i18n/useFormDemoTranslation';
|
||||
|
||||
export default function ValidationBankDemo() {
|
||||
const t = useFormDemoTranslation();
|
||||
|
||||
// Compose the Zod schema using the atomic validators
|
||||
const validationSchema = useMemo(() => z.object({
|
||||
username: compose(z.string(), required(t.fields.customerName), rangeLength(3, 15, t.fields.customerName)),
|
||||
simplePass: compose(z.string(), required(t.validation.simplePassword), simplePassword(6)),
|
||||
complexPass: compose(z.string(), required(t.validation.complexPassword), complexPassword(8)),
|
||||
age: compose(z.number(), required(t.fields.age), rangeValue(18, 65, t.fields.age)),
|
||||
score: compose(z.number(), required(t.validation.score), positiveNumber(t.validation.score)),
|
||||
phone: compose(z.string(), required(t.validation.phone), phoneValidator()),
|
||||
department: z.object({ code: z.string(), name: z.string() }, { required_error: t.errors.departmentRequired }),
|
||||
assignees: z.array(z.object({ id: z.number(), email: z.string() })).min(2, t.errors.min2Assignees),
|
||||
prefilledVendor: z.object({ id: z.union([z.string(), z.number()]), code: z.string(), name: z.string() }, { required_error: t.errors.vendorRequired }),
|
||||
emptyVendor: z.object({ id: z.union([z.string(), z.number()]), code: z.string(), name: z.string() }, { required_error: t.errors.vendorRequired }),
|
||||
prefilledAsyncMulti: z.array(z.object({ id: z.union([z.string(), z.number()]), code: z.string(), name: z.string() })).min(1, t.errors.min1Vendor),
|
||||
richTextNotes: z.string().min(15, t.errors.notesMin15),
|
||||
}), [t]);
|
||||
|
||||
type ValidationFormValues = z.infer<typeof validationSchema>;
|
||||
|
||||
const { control, handleSubmit, watch } = useForm<ValidationFormValues>({
|
||||
resolver: zodResolver(validationSchema),
|
||||
defaultValues: {
|
||||
username: '',
|
||||
simplePass: '',
|
||||
complexPass: '',
|
||||
age: undefined as any,
|
||||
score: undefined as any,
|
||||
phone: '',
|
||||
department: null as any,
|
||||
assignees: [],
|
||||
prefilledVendor: { id: 'V1', code: 'VN-01', name: 'Vendor One' } as any,
|
||||
emptyVendor: null as any,
|
||||
prefilledAsyncMulti: [
|
||||
{ id: 'V1', code: 'VN-01', name: 'Vendor One' },
|
||||
{ id: 'V2', code: 'VN-02', name: 'Vendor Two' }
|
||||
] as any,
|
||||
richTextNotes: '',
|
||||
}
|
||||
});
|
||||
|
||||
const onSubmit = (data: ValidationFormValues) => console.log('Validation Passed:', data);
|
||||
const data = watch();
|
||||
|
||||
return (
|
||||
<Stack gap="xl">
|
||||
<Paper p="xl" withBorder radius="md">
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack gap="md">
|
||||
<Title order={5} c="brand">{t.sections.validationBankTitle}</Title>
|
||||
<Divider mb="sm" />
|
||||
|
||||
<FieldTextInput
|
||||
name="username"
|
||||
control={control}
|
||||
label={t.fields.customerName}
|
||||
description={t.validation.usernameRange}
|
||||
withAsterisk
|
||||
/>
|
||||
|
||||
<Group grow align="flex-start">
|
||||
<FieldPasswordInput
|
||||
name="simplePass"
|
||||
control={control}
|
||||
label={t.validation.simplePassword}
|
||||
description={t.descriptions.min6Chars}
|
||||
withAsterisk
|
||||
/>
|
||||
<FieldPasswordInput
|
||||
name="complexPass"
|
||||
control={control}
|
||||
label={t.validation.complexPassword}
|
||||
description={t.descriptions.min8Complex}
|
||||
withAsterisk
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group grow align="flex-start">
|
||||
<FieldNumberInput
|
||||
name="age"
|
||||
control={control}
|
||||
label={t.fields.age}
|
||||
description={t.validation.ageRange}
|
||||
withAsterisk
|
||||
/>
|
||||
<FieldNumberInput
|
||||
name="score"
|
||||
control={control}
|
||||
label={t.validation.score}
|
||||
description={t.descriptions.mustBePositive}
|
||||
withAsterisk
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<FieldTextInput
|
||||
name="phone"
|
||||
control={control}
|
||||
label={t.validation.phone}
|
||||
description={t.descriptions.formatPhone}
|
||||
withAsterisk
|
||||
/>
|
||||
|
||||
<Title order={5} c="brand" mt="md">{t.sections.objectLevelValidations}</Title>
|
||||
<Divider mb="sm" />
|
||||
|
||||
<FieldLocalSelect<Department>
|
||||
name="department"
|
||||
control={control as any}
|
||||
label={t.fields.department}
|
||||
options={MOCK_DEPARTMENTS}
|
||||
valueKey="code"
|
||||
renderLabel={(item) => `[${item.code}] ${item.name}`}
|
||||
clearable
|
||||
withAsterisk
|
||||
/>
|
||||
|
||||
<FieldAsyncSelect<Assignee>
|
||||
multiple
|
||||
name="assignees"
|
||||
control={control as any}
|
||||
label={t.fields.assignees}
|
||||
loadOptions={mockFetchUsers}
|
||||
valueKey="id"
|
||||
labelKey="email"
|
||||
searchable
|
||||
clearable
|
||||
withAsterisk
|
||||
/>
|
||||
|
||||
<Title order={5} c="brand" mt="lg">{t.sections.validatedPrefilledObjects}</Title>
|
||||
<Divider mb="sm" />
|
||||
|
||||
<Group grow align="flex-start">
|
||||
<FieldAsyncSelect
|
||||
name="emptyVendor"
|
||||
control={control as any}
|
||||
label={t.fields.emptyVendor}
|
||||
loadOptions={mockFetchVendors}
|
||||
valueKey="id"
|
||||
renderLabel={(item) => `[${item.code}] ${item.name}`}
|
||||
clearable
|
||||
withAsterisk
|
||||
/>
|
||||
<FieldAsyncSelect
|
||||
name="prefilledVendor"
|
||||
control={control as any}
|
||||
label={t.fields.prefilledVendor}
|
||||
loadOptions={mockFetchVendors}
|
||||
valueKey="id"
|
||||
renderLabel={(item) => `[${item.code}] ${item.name}`}
|
||||
defaultOptions={[{ id: 'V1', code: 'VN-01', name: 'Vendor One' }]}
|
||||
clearable
|
||||
withAsterisk
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<FieldAsyncSelect
|
||||
multiple
|
||||
name="prefilledAsyncMulti"
|
||||
control={control as any}
|
||||
label={t.fields.prefilledAsyncMulti}
|
||||
loadOptions={mockFetchVendors}
|
||||
valueKey="id"
|
||||
renderLabel={(item) => `[${item.code}] ${item.name}`}
|
||||
clearable
|
||||
withAsterisk
|
||||
/>
|
||||
|
||||
<Title order={5} c="brand" mt="lg">{t.sections.richTextValidations}</Title>
|
||||
<Divider mb="sm" />
|
||||
|
||||
<FieldRichTextEditor
|
||||
name="richTextNotes"
|
||||
control={control as any}
|
||||
label={t.fields.importantNotes}
|
||||
description={t.descriptions.zodMinLengthString}
|
||||
withAsterisk
|
||||
/>
|
||||
|
||||
<Button type="submit" mt="md">{t.common.submit}</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</Paper>
|
||||
|
||||
<Paper p="md" withBorder radius="md" bg="var(--mantine-color-gray-0)">
|
||||
<Title order={6} mb="xs">{t.common.submittedData}</Title>
|
||||
<Code block>{JSON.stringify(data, null, 2)}</Code>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { Tabs } from '@repo/ui/components';
|
||||
import AllFieldsDemo from './components/all-fields-demo';
|
||||
import ValidationBankDemo from './components/validation-bank-demo';
|
||||
import ReactiveWatchDemo from './components/reactive-watch-demo';
|
||||
import { useFormDemoTranslation } from './i18n/useFormDemoTranslation';
|
||||
|
||||
export default function FormDemoView() {
|
||||
const t = useFormDemoTranslation();
|
||||
|
||||
return (
|
||||
<Tabs defaultValue="all-fields" variant="outline" radius="md">
|
||||
<Tabs.List mb="md">
|
||||
<Tabs.Tab value="all-fields">{t.tabs.allFields}</Tabs.Tab>
|
||||
<Tabs.Tab value="validation-bank">{t.tabs.validationBank}</Tabs.Tab>
|
||||
<Tabs.Tab value="reactive-watch">{t.tabs.reactiveWatch}</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="all-fields">
|
||||
<AllFieldsDemo />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="validation-bank">
|
||||
<ValidationBankDemo />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="reactive-watch">
|
||||
<ReactiveWatchDemo />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from './form-demo-view';
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"module_name": "Purchasing",
|
||||
"select_date": "Select Date",
|
||||
"header": {
|
||||
"title": "Transaction List",
|
||||
"subtitle": "Manage all your transactions here"
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"module_name": "Pembelanjaan",
|
||||
"select_date": "Pilih Tanggal",
|
||||
"header": {
|
||||
"title": "Daftar Transaksi",
|
||||
"subtitle": "Kelola semua transaksi Anda di sini"
|
||||
}
|
||||
}
|
||||
@@ -1,347 +0,0 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslation, changeLanguage, applyTenantOverrides, i18n } from '@repo/core-i18n';
|
||||
import { secureIndexedDB, AppStorageKey } from '../../../../../../core/storage/local';
|
||||
|
||||
// Decentralized languages imports
|
||||
import bookingId from '../languages/id/booking.json';
|
||||
import bookingEn from '../languages/en/booking.json';
|
||||
|
||||
// ─── Shared Styles ──────────────────────────────────────────────
|
||||
|
||||
const sectionStyle = {
|
||||
marginTop: 24,
|
||||
padding: 24,
|
||||
border: '1px solid #334155',
|
||||
borderRadius: 8,
|
||||
background: '#0f172a',
|
||||
};
|
||||
|
||||
const btnStyle = (color: string, isActive: boolean = false) => ({
|
||||
padding: '8px 16px',
|
||||
fontSize: 14,
|
||||
fontWeight: isActive ? 700 : 600,
|
||||
cursor: 'pointer' as const,
|
||||
background: color,
|
||||
color: '#fff',
|
||||
border: isActive ? '2px solid #fff' : '2px solid transparent',
|
||||
borderRadius: 6,
|
||||
marginRight: 8,
|
||||
});
|
||||
|
||||
// ─── Module-Level Flag ──────────────────────────────────────────
|
||||
// Bendera penanda statis agar kamus hanya dimuat satu kali
|
||||
let isBookingDictLoaded = false;
|
||||
|
||||
// ─── Component ──────────────────────────────────────────────────
|
||||
|
||||
export default function I18nSample() {
|
||||
// 1. Eksekusi SINKRONUS tepat sebelum render pertama
|
||||
if (!isBookingDictLoaded) {
|
||||
i18n.addResourceBundle('id', 'booking', bookingId, true, false);
|
||||
i18n.addResourceBundle('en', 'booking', bookingEn, true, false);
|
||||
isBookingDictLoaded = true;
|
||||
}
|
||||
|
||||
// 2. Sekarang useTranslation dijamin mendapat kamus yang sudah terisi penuh
|
||||
const { t } = useTranslation(['common', 'booking']);
|
||||
|
||||
// State untuk melacak bahasa aktif secara real-time
|
||||
const [activeLang, setActiveLang] = useState(i18n.language);
|
||||
const [syncStatus, setSyncStatus] = useState<string>('');
|
||||
const [activeTenant, setActiveTenant] = useState<string>('default');
|
||||
const [isFetchingConfig, setIsFetchingConfig] = useState(false);
|
||||
|
||||
// Dengarkan perubahan bahasa dari engine
|
||||
useEffect(() => {
|
||||
const handleLangChange = (lng: string) => setActiveLang(lng);
|
||||
i18n.on('languageChanged', handleLangChange);
|
||||
return () => {
|
||||
i18n.off('languageChanged', handleLangChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ─── Admin Panel State ──────────────────────────────────────────
|
||||
const [adminModuleName, setAdminModuleName] = useState('PENGELUARAN');
|
||||
const [adminHeaderTitle, setAdminHeaderTitle] = useState('Daftar Pengeluaran');
|
||||
const [dbPayloadStr, setDbPayloadStr] = useState<string>('No data in DB');
|
||||
|
||||
const MOCK_DB_KEY = AppStorageKey.MOCK_DB_COMPANY_A;
|
||||
|
||||
const loadDbPayload = useCallback(async () => {
|
||||
try {
|
||||
const data = await secureIndexedDB.getItem<any>(MOCK_DB_KEY);
|
||||
setDbPayloadStr(data ? JSON.stringify(data, null, 2) : 'No data in DB');
|
||||
setAdminHeaderTitle(data?.overrides?.header?.title || 'Daftar Pengeluaran');
|
||||
setAdminModuleName(data?.overrides?.module_name || 'PENGELUARAN');
|
||||
} catch (e) {
|
||||
setDbPayloadStr('Error reading DB');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadDbPayload();
|
||||
}, [loadDbPayload]);
|
||||
|
||||
const handleAdminSave = async () => {
|
||||
const payload = {
|
||||
namespace: 'booking',
|
||||
overrides: {
|
||||
module_name: adminModuleName,
|
||||
header: { title: adminHeaderTitle },
|
||||
},
|
||||
};
|
||||
await secureIndexedDB.setItem(MOCK_DB_KEY, payload);
|
||||
setSyncStatus('✅ Saved tenant config to IndexedDB!');
|
||||
await loadDbPayload();
|
||||
};
|
||||
|
||||
// ─── Mock API ───────────────────────────────────────────────────
|
||||
const mockFetchTenantConfig = async (companyId: string): Promise<any> => {
|
||||
if (companyId === 'company-a') {
|
||||
const data = await secureIndexedDB.getItem<any>(MOCK_DB_KEY);
|
||||
if (!data) {
|
||||
throw new Error('Company A config not found in DB. Please save via Admin Panel first.');
|
||||
}
|
||||
return data;
|
||||
} else if (companyId === 'company-b') {
|
||||
return {
|
||||
namespace: 'booking',
|
||||
overrides: {
|
||||
module_name: 'PROCUREMENT (B)',
|
||||
header: { title: 'Procurement List (B)' },
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error('Unknown company');
|
||||
};
|
||||
|
||||
// ─── Section A: Language Switcher ──────────────────────────────
|
||||
|
||||
const handleLanguageChange = async (newLng: string, shouldFail: boolean = false) => {
|
||||
setSyncStatus('Syncing with backend...');
|
||||
|
||||
try {
|
||||
await changeLanguage(newLng, async (lng, _prevLng) => {
|
||||
await new Promise((resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
if (shouldFail) {
|
||||
reject(new Error('Mock API 500: Failed to save preference'));
|
||||
} else {
|
||||
resolve(true);
|
||||
}
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
setSyncStatus(`✅ Successfully synced language '${lng}' to backend.`);
|
||||
});
|
||||
} catch (error) {
|
||||
setSyncStatus(`❌ Rollback triggered: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Section B: Tenant Overrides (Real-World Flow) ─────────────
|
||||
|
||||
const handleSimulateLogin = async (companyId: string) => {
|
||||
setIsFetchingConfig(true);
|
||||
setActiveTenant(companyId);
|
||||
|
||||
try {
|
||||
const config = await mockFetchTenantConfig(companyId);
|
||||
applyTenantOverrides(config.namespace, config.overrides, 'id');
|
||||
applyTenantOverrides(config.namespace, config.overrides, 'en');
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch config', err);
|
||||
} finally {
|
||||
setIsFetchingConfig(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetTenant = () => {
|
||||
i18n.addResourceBundle('id', 'booking', bookingId, true, true);
|
||||
i18n.addResourceBundle('en', 'booking', bookingEn, true, true);
|
||||
setActiveTenant('default');
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ fontFamily: 'sans-serif', color: '#f8fafc' }}>
|
||||
<h2 style={{ fontSize: 24, fontWeight: 'bold' }}>🌐 Enterprise i18n Demo</h2>
|
||||
<p style={{ color: '#94a3b8' }}>
|
||||
Current Active Language: <strong style={{ color: '#38bdf8' }}>{activeLang}</strong>
|
||||
</p>
|
||||
|
||||
{/* ─── Admin Panel ──────────────────────────────────────────── */}
|
||||
<div style={sectionStyle}>
|
||||
<h3 style={{ fontSize: 18, marginBottom: 16, color: '#fbbf24' }}>Admin Panel (Company A Config)</h3>
|
||||
<p style={{ fontSize: 14, color: '#94a3b8', marginBottom: 16 }}>
|
||||
Simulate a backend CMS. Save the vocabulary overrides to IndexedDB.
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginBottom: 16 }}>
|
||||
<label style={{ fontSize: 14 }}>
|
||||
<span style={{ display: 'inline-block', width: 120 }}>Module Name:</span>
|
||||
<input
|
||||
type="text"
|
||||
value={adminModuleName}
|
||||
onChange={(e) => setAdminModuleName(e.target.value)}
|
||||
style={{
|
||||
padding: 6,
|
||||
borderRadius: 4,
|
||||
background: '#1e293b',
|
||||
border: '1px solid #475569',
|
||||
color: '#fff',
|
||||
width: 250,
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ fontSize: 14 }}>
|
||||
<span style={{ display: 'inline-block', width: 120 }}>Header Title:</span>
|
||||
<input
|
||||
type="text"
|
||||
value={adminHeaderTitle}
|
||||
onChange={(e) => setAdminHeaderTitle(e.target.value)}
|
||||
style={{
|
||||
padding: 6,
|
||||
borderRadius: 4,
|
||||
background: '#1e293b',
|
||||
border: '1px solid #475569',
|
||||
color: '#fff',
|
||||
width: 250,
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button onClick={handleAdminSave} style={btnStyle('#d97706')}>
|
||||
Save to Database (IndexedDB)
|
||||
</button>
|
||||
|
||||
<div style={{ marginTop: 16, padding: 12, background: '#1e293b', borderRadius: 6 }}>
|
||||
<div style={{ fontSize: 12, color: '#94a3b8', marginBottom: 4 }}>Raw JSON in DB:</div>
|
||||
<pre style={{ margin: 0, fontSize: 12, color: '#a7f3d0' }}>
|
||||
<code>{dbPayloadStr}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── Section A ────────────────────────────────────────────── */}
|
||||
<div style={sectionStyle}>
|
||||
<h3 style={{ fontSize: 18, marginBottom: 16 }}>A. Language Switcher & Backend Sync</h3>
|
||||
<p style={{ fontSize: 14, color: '#94a3b8', marginBottom: 16 }}>
|
||||
Change the language. The callback simulates a 1-second backend API request.
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<button
|
||||
onClick={() => handleLanguageChange('id')}
|
||||
style={btnStyle(activeLang === 'id' ? '#1d4ed8' : '#0ea5e9', activeLang === 'id')}
|
||||
>
|
||||
ID (Lokal & Sync)
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleLanguageChange('en')}
|
||||
style={btnStyle(activeLang === 'en' ? '#1d4ed8' : '#0ea5e9', activeLang === 'en')}
|
||||
>
|
||||
EN (Lokal & Sync)
|
||||
</button>
|
||||
<button onClick={() => handleLanguageChange('en', true)} style={btnStyle('#dc2626')}>
|
||||
Force Error (Test Rollback)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{syncStatus && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 16,
|
||||
padding: 12,
|
||||
background: '#1e293b',
|
||||
borderRadius: 6,
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
{syncStatus}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* UI Result untuk Section A */}
|
||||
<div style={{ background: '#1e293b', padding: 16, borderRadius: 8, marginTop: 16 }}>
|
||||
<h4 style={{ color: '#cbd5e1', marginBottom: 12 }}>UI Result (Live Dictionary):</h4>
|
||||
<p style={{ margin: '4px 0', display: 'flex', alignItems: 'center' }}>
|
||||
<code style={{ color: '#94a3b8', width: 180, display: 'inline-block' }}>common:save</code>
|
||||
<strong style={{ fontSize: 16, color: '#10b981' }}>{t('common:save')}</strong>
|
||||
</p>
|
||||
<p style={{ margin: '4px 0', display: 'flex', alignItems: 'center' }}>
|
||||
<code style={{ color: '#94a3b8', width: 180, display: 'inline-block' }}>booking:select_date</code>
|
||||
<strong style={{ fontSize: 16, color: '#10b981' }}>{t('booking:select_date')}</strong>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── Section B ────────────────────────────────────────────── */}
|
||||
<div style={sectionStyle}>
|
||||
<h3 style={{ fontSize: 18, marginBottom: 16 }}>B. Dynamic Tenant Overrides (End-to-End)</h3>
|
||||
<p style={{ fontSize: 14, color: '#94a3b8', marginBottom: 16 }}>
|
||||
Simulates a user logging in. It fetches the config directly from IndexedDB (mock database) and applies the
|
||||
deep-merge override.
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 24 }}>
|
||||
<button
|
||||
onClick={resetTenant}
|
||||
style={btnStyle(activeTenant === 'default' ? '#16a34a' : '#475569', activeTenant === 'default')}
|
||||
>
|
||||
Default Company
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSimulateLogin('company-a')}
|
||||
style={btnStyle(activeTenant === 'company-a' ? '#16a34a' : '#475569', activeTenant === 'company-a')}
|
||||
disabled={isFetchingConfig}
|
||||
>
|
||||
Simulate Login as Company A
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSimulateLogin('company-b')}
|
||||
style={btnStyle(activeTenant === 'company-b' ? '#16a34a' : '#475569', activeTenant === 'company-b')}
|
||||
disabled={isFetchingConfig}
|
||||
>
|
||||
Simulate Login as Company B
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isFetchingConfig && (
|
||||
<div style={{ marginBottom: 16, color: '#fbbf24', fontSize: 14 }}>⏳ Fetching tenant config...</div>
|
||||
)}
|
||||
|
||||
{/* Display the localized strings */}
|
||||
<div style={{ background: '#1e293b', padding: 16, borderRadius: 8 }}>
|
||||
<h4 style={{ color: '#cbd5e1', marginBottom: 12 }}>UI Result (Tenant Overlay):</h4>
|
||||
<table style={{ width: '100%', textAlign: 'left', borderCollapse: 'collapse' }}>
|
||||
<tbody>
|
||||
<tr style={{ borderBottom: '1px solid #334155' }}>
|
||||
<th style={{ padding: 8, color: '#94a3b8' }}>Key</th>
|
||||
<th style={{ padding: 8, color: '#94a3b8' }}>Value</th>
|
||||
</tr>
|
||||
<tr style={{ borderBottom: '1px solid #334155' }}>
|
||||
<td style={{ padding: 8 }}>
|
||||
<code>booking:module_name</code>
|
||||
</td>
|
||||
<td style={{ padding: 8, fontWeight: 'bold' }}>{t('booking:module_name')}</td>
|
||||
</tr>
|
||||
<tr style={{ borderBottom: '1px solid #334155' }}>
|
||||
<td style={{ padding: 8 }}>
|
||||
<code>booking:header.title</code>
|
||||
</td>
|
||||
<td style={{ padding: 8, fontWeight: 'bold' }}>{t('booking:header.title')}</td>
|
||||
</tr>
|
||||
<tr style={{ borderBottom: '1px solid #334155' }}>
|
||||
<td style={{ padding: 8 }}>
|
||||
<code>booking:header.subtitle</code>
|
||||
</td>
|
||||
<td style={{ padding: 8, fontWeight: 'bold' }}>{t('booking:header.subtitle')}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,280 +0,0 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { secureStorage, secureIndexedDB, AppStorageKey } from '../../../../../../core/storage/local';
|
||||
|
||||
// ─── Demo Data ──────────────────────────────────────────────────
|
||||
|
||||
interface DemoUser {
|
||||
id: string;
|
||||
name: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
interface DemoDraft {
|
||||
id: number;
|
||||
type: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
const DEMO_USER: DemoUser = {
|
||||
id: 'u-123',
|
||||
name: 'Firman Ramdhani',
|
||||
role: 'admin',
|
||||
};
|
||||
|
||||
const DEMO_DRAFT: DemoDraft = { id: 101, type: 'offline_draft', content: 'Draft data saved offline' };
|
||||
|
||||
const LS_KEY = AppStorageKey.USER_PROFILE; // Encrypted at rest (in ENCRYPTED_KEYS)
|
||||
const IDB_KEY = AppStorageKey.OFFLINE_DRAFT; // Plain key for IndexedDB demo
|
||||
|
||||
// ─── Shared Styles ──────────────────────────────────────────────
|
||||
|
||||
const btnStyle = (color: string) => ({
|
||||
padding: '8px 16px',
|
||||
fontSize: 14,
|
||||
fontWeight: 600 as const,
|
||||
cursor: 'pointer' as const,
|
||||
background: color,
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: 6,
|
||||
});
|
||||
|
||||
const preStyle = {
|
||||
marginTop: 16,
|
||||
background: '#1e1e2e',
|
||||
color: '#a6e3a1',
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
minHeight: 60,
|
||||
overflow: 'auto' as const,
|
||||
fontSize: 13,
|
||||
};
|
||||
|
||||
const logContainerStyle = {
|
||||
background: '#0f0f17',
|
||||
color: '#94a3b8',
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
maxHeight: 200,
|
||||
overflow: 'auto' as const,
|
||||
fontSize: 12,
|
||||
};
|
||||
|
||||
// ─── Reusable CRUD Button Row ───────────────────────────────────
|
||||
|
||||
interface CRUDAction {
|
||||
label: string;
|
||||
handler: () => void;
|
||||
color: string;
|
||||
}
|
||||
|
||||
function CRUDButtons({ actions }: { actions: CRUDAction[] }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
{actions.map(({ label, handler, color }) => (
|
||||
<button key={label} onClick={handler} style={btnStyle(color)}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Component ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Interactive demo for `@repo/core-storage`.
|
||||
*
|
||||
* Demonstrates the full CRUD lifecycle for BOTH storage backends:
|
||||
* - **localStorage** (encrypted via AES for sensitive keys)
|
||||
* - **IndexedDB** (Promise-wrapped, suitable for large payloads)
|
||||
*
|
||||
* Open the browser's DevTools:
|
||||
* - **Application → Local Storage** to see AES-encrypted payloads
|
||||
* - **Application → IndexedDB → app_db → kv_store** to see IDB entries
|
||||
*/
|
||||
export default function StorageSample() {
|
||||
const [lsResult, setLsResult] = useState<string>('(no data read yet)');
|
||||
const [idbResult, setIdbResult] = useState<string>('(no data read yet)');
|
||||
const [log, setLog] = useState<string[]>([]);
|
||||
|
||||
const pushLog = useCallback((msg: string) => {
|
||||
setLog((prev) => [...prev, `[${new Date().toLocaleTimeString()}] ${msg}`]);
|
||||
}, []);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// ── localStorage CRUD ─────────────────────────────────────────
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
const lsCreate = useCallback(async () => {
|
||||
await secureStorage.setItem(LS_KEY, DEMO_USER);
|
||||
pushLog(`[LS] CREATE → Stored encrypted: ${JSON.stringify(DEMO_USER)}`);
|
||||
}, [pushLog]);
|
||||
|
||||
const lsRead = useCallback(async () => {
|
||||
const result = await secureStorage.getItem<DemoUser>(LS_KEY);
|
||||
if (result) {
|
||||
setLsResult(JSON.stringify(result, null, 2));
|
||||
pushLog(`[LS] READ → Decrypted: ${JSON.stringify(result)}`);
|
||||
} else {
|
||||
setLsResult('(null — no data found)');
|
||||
pushLog('[LS] READ → null (key does not exist)');
|
||||
}
|
||||
}, [pushLog]);
|
||||
|
||||
const lsUpdate = useCallback(async () => {
|
||||
const existing = await secureStorage.getItem<DemoUser>(LS_KEY);
|
||||
if (!existing) {
|
||||
pushLog('[LS] UPDATE → Failed: key does not exist. Create first.');
|
||||
return;
|
||||
}
|
||||
const updated: DemoUser = { ...existing, role: 'superadmin', id: existing.id + 1 };
|
||||
await secureStorage.setItem(LS_KEY, updated);
|
||||
pushLog(`[LS] UPDATE → Re-encrypted: ${JSON.stringify(updated)}`);
|
||||
}, [pushLog]);
|
||||
|
||||
const lsDelete = useCallback(async () => {
|
||||
await secureStorage.removeItem(LS_KEY);
|
||||
setLsResult('(deleted)');
|
||||
pushLog(`[LS] DELETE → Removed key "${LS_KEY}"`);
|
||||
}, [pushLog]);
|
||||
|
||||
const lsClear = useCallback(async () => {
|
||||
await secureStorage.clear();
|
||||
setLsResult('(cleared)');
|
||||
pushLog('[LS] CLEAR → All localStorage keys removed');
|
||||
}, [pushLog]);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// ── IndexedDB CRUD ────────────────────────────────────────────
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
const idbCreate = useCallback(async () => {
|
||||
try {
|
||||
await secureIndexedDB.setItem(IDB_KEY, DEMO_DRAFT);
|
||||
pushLog(`[IDB] CREATE → Stored: ${JSON.stringify(DEMO_DRAFT)}`);
|
||||
} catch (err) {
|
||||
pushLog(`[IDB] CREATE → ERROR: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}, [pushLog]);
|
||||
|
||||
const idbRead = useCallback(async () => {
|
||||
try {
|
||||
const result = await secureIndexedDB.getItem<DemoDraft>(IDB_KEY);
|
||||
if (result) {
|
||||
setIdbResult(JSON.stringify(result, null, 2));
|
||||
pushLog(`[IDB] READ → Retrieved: ${JSON.stringify(result)}`);
|
||||
} else {
|
||||
setIdbResult('(null — no data found)');
|
||||
pushLog('[IDB] READ → null (key does not exist)');
|
||||
}
|
||||
} catch (err) {
|
||||
pushLog(`[IDB] READ → ERROR: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}, [pushLog]);
|
||||
|
||||
const idbUpdate = useCallback(async () => {
|
||||
try {
|
||||
const existing = await secureIndexedDB.getItem<DemoDraft>(IDB_KEY);
|
||||
if (!existing) {
|
||||
pushLog('[IDB] UPDATE → Failed: key does not exist. Create first.');
|
||||
return;
|
||||
}
|
||||
const updated: DemoDraft = {
|
||||
...existing,
|
||||
id: existing.id + 1,
|
||||
content: `Updated at ${new Date().toLocaleTimeString()}`,
|
||||
};
|
||||
await secureIndexedDB.setItem(IDB_KEY, updated);
|
||||
pushLog(`[IDB] UPDATE → Persisted: ${JSON.stringify(updated)}`);
|
||||
} catch (err) {
|
||||
pushLog(`[IDB] UPDATE → ERROR: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}, [pushLog]);
|
||||
|
||||
const idbDelete = useCallback(async () => {
|
||||
try {
|
||||
await secureIndexedDB.removeItem(IDB_KEY);
|
||||
setIdbResult('(deleted)');
|
||||
pushLog(`[IDB] DELETE → Removed key "${IDB_KEY}"`);
|
||||
} catch (err) {
|
||||
pushLog(`[IDB] DELETE → ERROR: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}, [pushLog]);
|
||||
|
||||
const idbClear = useCallback(async () => {
|
||||
try {
|
||||
await secureIndexedDB.clear();
|
||||
setIdbResult('(cleared)');
|
||||
pushLog('[IDB] CLEAR → All IndexedDB entries removed');
|
||||
} catch (err) {
|
||||
pushLog(`[IDB] CLEAR → ERROR: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}, [pushLog]);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// ── Render ────────────────────────────────────────────────────
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24, fontFamily: 'monospace' }}>
|
||||
<h2>🔐 @repo/core-storage — Dual Backend CRUD Demo</h2>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, marginTop: 16 }}>
|
||||
{/* ── Left: localStorage ─────────────────────────────────── */}
|
||||
<div>
|
||||
<h3 style={{ color: '#22c55e' }}>📦 localStorage (AES Encrypted)</h3>
|
||||
<p style={{ color: '#888', fontSize: 12, marginBottom: 12 }}>
|
||||
Key: <code>{LS_KEY}</code> — stored encrypted at rest
|
||||
<br />
|
||||
Verify: <strong>DevTools → Application → Local Storage</strong>
|
||||
</p>
|
||||
|
||||
<CRUDButtons
|
||||
actions={[
|
||||
{ label: '➕ Create', handler: lsCreate, color: '#22c55e' },
|
||||
{ label: '📖 Read', handler: lsRead, color: '#3b82f6' },
|
||||
{ label: '✏️ Update', handler: lsUpdate, color: '#f59e0b' },
|
||||
{ label: '🗑️ Delete', handler: lsDelete, color: '#ef4444' },
|
||||
{ label: '💣 Clear', handler: lsClear, color: '#6b7280' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<pre style={preStyle}>{lsResult}</pre>
|
||||
</div>
|
||||
|
||||
{/* ── Right: IndexedDB ───────────────────────────────────── */}
|
||||
<div>
|
||||
<h3 style={{ color: '#8b5cf6' }}>🗃️ IndexedDB (app_db / kv_store)</h3>
|
||||
<p style={{ color: '#888', fontSize: 12, marginBottom: 12 }}>
|
||||
Key: <code>{IDB_KEY}</code> — plain JSON (not in ENCRYPTED_KEYS)
|
||||
<br />
|
||||
Verify: <strong>DevTools → Application → IndexedDB → app_db</strong>
|
||||
</p>
|
||||
|
||||
<CRUDButtons
|
||||
actions={[
|
||||
{ label: '➕ Create', handler: idbCreate, color: '#8b5cf6' },
|
||||
{ label: '📖 Read', handler: idbRead, color: '#06b6d4' },
|
||||
{ label: '✏️ Update', handler: idbUpdate, color: '#f59e0b' },
|
||||
{ label: '🗑️ Delete', handler: idbDelete, color: '#ef4444' },
|
||||
{ label: '💣 Clear', handler: idbClear, color: '#6b7280' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<pre style={preStyle}>{idbResult}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Shared Action Log ────────────────────────────────────── */}
|
||||
<h3 style={{ marginTop: 24 }}>📋 Action Log</h3>
|
||||
<div style={logContainerStyle}>
|
||||
{log.length === 0 ? (
|
||||
<span style={{ color: '#475569' }}>(no actions yet)</span>
|
||||
) : (
|
||||
log.map((entry, i) => <div key={i}>{entry}</div>)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,324 +0,0 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Button, Card, Group, Stack, Title, Text, Table, Badge } from '@repo/ui/components';
|
||||
|
||||
import { itemDB, posConfigDB, newItemDB } from '../../core/storage/pouch-db';
|
||||
import type { ItemEntity, POSConfigurationEntity } from '../../core/storage/pouch-db/entities';
|
||||
|
||||
export default function PouchSample() {
|
||||
const [configs, setConfigs] = useState<POSConfigurationEntity[]>([]);
|
||||
const [items, setItems] = useState<ItemEntity[]>([]);
|
||||
const [newItems, setNewItems] = useState<ItemEntity[]>([]);
|
||||
|
||||
// Load initial data
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
const allConfigs = await posConfigDB.find({ selector: {} });
|
||||
setConfigs(allConfigs);
|
||||
|
||||
const allItems = await itemDB.find({ selector: {} });
|
||||
setItems(allItems);
|
||||
|
||||
// We can use getAll() or find() on Envelope DB. It's automatically scoped.
|
||||
const allNewItems = await newItemDB.getAll();
|
||||
console.log('allNewItems', allNewItems);
|
||||
setNewItems(allNewItems as ItemEntity[]);
|
||||
|
||||
console.log({ allConfigs, allItems, allNewItems });
|
||||
} catch (err) {
|
||||
console.error('Failed to load PouchDB data', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// 1. Initial Data Load
|
||||
loadData();
|
||||
|
||||
// 2. Setup Real-Time Listeners
|
||||
const unsubscribeItems = itemDB.onChange(() => {
|
||||
loadData();
|
||||
});
|
||||
|
||||
const unsubscribePos = posConfigDB.onChange(() => {
|
||||
loadData();
|
||||
});
|
||||
|
||||
const unsubscribeNewItems = newItemDB.onChange(() => {
|
||||
loadData();
|
||||
});
|
||||
|
||||
// 3. CRITICAL: Cleanup to prevent memory leaks on unmount
|
||||
return () => {
|
||||
unsubscribeItems();
|
||||
unsubscribePos();
|
||||
unsubscribeNewItems();
|
||||
};
|
||||
}, [loadData]);
|
||||
|
||||
// ─── POS Configuration Handlers ─────────────────────────────────
|
||||
|
||||
const handleSeedConfig = async () => {
|
||||
try {
|
||||
const id = `pos-${Date.now()}`;
|
||||
await posConfigDB.create({
|
||||
_id: id,
|
||||
pos_number: '1111111111666',
|
||||
pos_name: 'Premium Test POS TESTING COUNCH',
|
||||
items: items, // mapping current items
|
||||
payment_methods: [{ id: 'cash', name: 'Cash' }],
|
||||
});
|
||||
loadData();
|
||||
} catch (err) {
|
||||
console.error('Failed to seed config', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteConfig = async (id: string) => {
|
||||
try {
|
||||
await posConfigDB.delete(id);
|
||||
loadData();
|
||||
} catch (err) {
|
||||
console.error('Failed to delete config', err);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Items Inventory Handlers ───────────────────────────────────
|
||||
|
||||
const handleAddItem = async () => {
|
||||
try {
|
||||
const id = `item-${Date.now()}`;
|
||||
await itemDB.create({
|
||||
_id: id,
|
||||
name: 'PLAYGROUND ALL DAY TESTING POUCH',
|
||||
base_price: '75000',
|
||||
item_type: 'wahana',
|
||||
usage_type: 'ticket',
|
||||
item_category: [{ name: 'Entertainment' }],
|
||||
item_rates: [
|
||||
{ season_period: 'weekday', price: 50000 },
|
||||
{ season_period: 'weekend', price: 75000 },
|
||||
],
|
||||
});
|
||||
|
||||
loadData();
|
||||
} catch (err) {
|
||||
console.error('Failed to add item', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteItem = async (id: string) => {
|
||||
try {
|
||||
await itemDB.delete(id);
|
||||
loadData();
|
||||
} catch (err) {
|
||||
console.error('Failed to delete item', err);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── New Items Envelope Handlers ────────────────────────────────
|
||||
|
||||
const handleAddNewItem = async () => {
|
||||
try {
|
||||
const id = `new-item-${Date.now()}`;
|
||||
await newItemDB.create({
|
||||
_id: id,
|
||||
name: 'ENVELOPE ITEM TEST',
|
||||
base_price: '100000',
|
||||
item_type: 'souvenir',
|
||||
usage_type: 'retail',
|
||||
item_category: [{ name: 'Merchandise' }],
|
||||
});
|
||||
|
||||
loadData();
|
||||
} catch (err) {
|
||||
console.error('Failed to add new item', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteNewItem = async (id: string) => {
|
||||
try {
|
||||
await newItemDB.delete(id);
|
||||
loadData();
|
||||
} catch (err) {
|
||||
console.error('Failed to delete new item', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearAll = async () => {
|
||||
try {
|
||||
await posConfigDB.cleanAllData();
|
||||
await itemDB.cleanAllData();
|
||||
await newItemDB.cleanAllData();
|
||||
loadData();
|
||||
} catch (err) {
|
||||
console.error('Failed to clear data', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="xl">
|
||||
<Group justify="space-between">
|
||||
<Title order={2}>Enterprise PouchDB Sync</Title>
|
||||
<Button color="error" variant="outline" onClick={handleClearAll}>
|
||||
Clear All Local Data
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{/* Items Inventory Table */}
|
||||
<Card withBorder shadow="sm" radius="md" p="md">
|
||||
<Group justify="space-between" mb="md">
|
||||
<Title order={4}>Items Database (Standard)</Title>
|
||||
<Button onClick={handleAddItem} color="success">
|
||||
Inject Mock ERP Item
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<div className="max-h-[400px] overflow-y-auto border border-gray-200 rounded-lg scrollbar-thin scrollbar-thumb-gray-300">
|
||||
<Table striped highlightOnHover withTableBorder withColumnBorders>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th className="sticky top-0 bg-white z-10 shadow-sm">ID</Table.Th>
|
||||
<Table.Th className="sticky top-0 bg-white z-10 shadow-sm">Name</Table.Th>
|
||||
<Table.Th className="sticky top-0 bg-white z-10 shadow-sm">Type</Table.Th>
|
||||
<Table.Th className="sticky top-0 bg-white z-10 shadow-sm">Base Price</Table.Th>
|
||||
<Table.Th className="sticky top-0 bg-white z-10 shadow-sm">Rates Count</Table.Th>
|
||||
<Table.Th className="sticky top-0 bg-white z-10 shadow-sm">Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{items.length > 0 ? (
|
||||
items.map((item) => (
|
||||
<Table.Tr key={item._id}>
|
||||
<Table.Td>{item._id}</Table.Td>
|
||||
<Table.Td>{item.name}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color="brand" variant="light">
|
||||
{item.item_type}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>${Number(item.base_price).toFixed(2)}</Table.Td>
|
||||
<Table.Td>{item.item_rates?.length || 0}</Table.Td>
|
||||
<Table.Td>
|
||||
<Button size="xs" color="error" variant="subtle" onClick={() => handleDeleteItem(item._id)}>
|
||||
Delete
|
||||
</Button>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))
|
||||
) : (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={6} align="center">
|
||||
<Text c="dimmed">No items found.</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* New Items Envelope Table */}
|
||||
<Card withBorder shadow="sm" radius="md" p="md">
|
||||
<Group justify="space-between" mb="md">
|
||||
<Title order={4}>New Items Database (Envelope Pattern)</Title>
|
||||
<Button onClick={handleAddNewItem} color="brand">
|
||||
Inject Mock Envelope Item
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<div className="max-h-[400px] overflow-y-auto border border-gray-200 rounded-lg scrollbar-thin scrollbar-thumb-gray-300">
|
||||
<Table striped highlightOnHover withTableBorder withColumnBorders>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th className="sticky top-0 bg-white z-10 shadow-sm">ID</Table.Th>
|
||||
<Table.Th className="sticky top-0 bg-white z-10 shadow-sm">Name</Table.Th>
|
||||
<Table.Th className="sticky top-0 bg-white z-10 shadow-sm">Type</Table.Th>
|
||||
<Table.Th className="sticky top-0 bg-white z-10 shadow-sm">Base Price</Table.Th>
|
||||
<Table.Th className="sticky top-0 bg-white z-10 shadow-sm">Rates Count</Table.Th>
|
||||
<Table.Th className="sticky top-0 bg-white z-10 shadow-sm">Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{newItems.length > 0 ? (
|
||||
newItems.map((item) => (
|
||||
<Table.Tr key={item._id}>
|
||||
<Table.Td>{item._id}</Table.Td>
|
||||
<Table.Td>{item.name}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color="violet" variant="light">
|
||||
{item.item_type}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>${Number(item.base_price).toFixed(2)}</Table.Td>
|
||||
<Table.Td>{item.item_rates?.length || 0}</Table.Td>
|
||||
<Table.Td>
|
||||
<Button size="xs" color="error" variant="subtle" onClick={() => handleDeleteNewItem(item._id)}>
|
||||
Delete
|
||||
</Button>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))
|
||||
) : (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={6} align="center">
|
||||
<Text c="dimmed">No items found.</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* POS Configuration Table */}
|
||||
<Card withBorder shadow="sm" radius="md" p="md">
|
||||
<Group justify="space-between" mb="md">
|
||||
<Title order={4}>POS Configurations</Title>
|
||||
<Button onClick={handleSeedConfig} variant="light" color="brand">
|
||||
Inject Mock POS Config
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<div className="max-h-[400px] overflow-y-auto border border-gray-200 rounded-lg scrollbar-thin scrollbar-thumb-gray-300">
|
||||
<Table striped highlightOnHover withTableBorder withColumnBorders>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th className="sticky top-0 bg-white z-10 shadow-sm">ID</Table.Th>
|
||||
<Table.Th className="sticky top-0 bg-white z-10 shadow-sm">POS Name</Table.Th>
|
||||
<Table.Th className="sticky top-0 bg-white z-10 shadow-sm">POS Number</Table.Th>
|
||||
<Table.Th className="sticky top-0 bg-white z-10 shadow-sm">Mapped Items</Table.Th>
|
||||
<Table.Th className="sticky top-0 bg-white z-10 shadow-sm">Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{configs.length > 0 ? (
|
||||
configs.map((cfg) => (
|
||||
<Table.Tr key={cfg._id}>
|
||||
<Table.Td>{cfg._id}</Table.Td>
|
||||
<Table.Td>{cfg.pos_name}</Table.Td>
|
||||
<Table.Td>{cfg.pos_number}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color="info" variant="outline">
|
||||
{cfg.items?.length || 0} Items
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Button size="xs" color="error" variant="subtle" onClick={() => handleDeleteConfig(cfg._id)}>
|
||||
Delete
|
||||
</Button>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))
|
||||
) : (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={5} align="center">
|
||||
<Text c="dimmed">No configurations found.</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</div>
|
||||
</Card>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
// 1. Import hooks yang baru saja dibuat Opus
|
||||
import { Button } from '@repo/ui/components';
|
||||
import { useElectronPrinter } from '../../core/hooks/use-electron-printer';
|
||||
import { useElectronUpdater } from '../../core/hooks/use-electron-updater';
|
||||
|
||||
export default function App() {
|
||||
// 2. Panggil hooks-nya
|
||||
const { printers, refreshPrinters } = useElectronPrinter();
|
||||
const { status } = useElectronUpdater();
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px', border: '2px solid blue', margin: '20px' }}>
|
||||
<h2>🧪 Test Integrasi Electron</h2>
|
||||
|
||||
<p>
|
||||
<strong>Status Auto-Update:</strong> {status}
|
||||
</p>
|
||||
|
||||
<Button variant="filled" color="brand" onClick={refreshPrinters}>
|
||||
Refresh Printer
|
||||
</Button>
|
||||
|
||||
<h3>🖨️ Daftar Printer di Komputer Ini:</h3>
|
||||
<ul>
|
||||
{printers.length === 0 ? (
|
||||
<li>Mencari printer... (Atau tidak ada printer terdeteksi)</li>
|
||||
) : (
|
||||
printers.map((printer, index) => (
|
||||
<li key={index}>
|
||||
{printer.name} {printer.isDefault ? '(Default)' : ''}
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { lazy } from 'react';
|
||||
import { Layers, Table2 } from 'lucide-react';
|
||||
|
||||
export const COMPONENTS_REGISTRY = {
|
||||
actionTools: {
|
||||
id: 'action-tools',
|
||||
name: 'Action Tools',
|
||||
description: 'Showcase for PageActions and RowActions components',
|
||||
icon: Layers,
|
||||
component: lazy(() => import('./components/ActionToolsShowcase')),
|
||||
},
|
||||
agGrid: {
|
||||
id: 'ag-grid',
|
||||
name: 'AG Grid',
|
||||
description: 'Enterprise Data Grid with Mantine Theme Integration',
|
||||
icon: Table2,
|
||||
component: lazy(() => import('./components/AgGridShowcase')),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,346 +0,0 @@
|
||||
import { useState, useMemo, ReactNode } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { Box, Button, Group, SegmentedControl, Stack, Text, Paper, Switch, Select, Burger } from '@repo/ui/components';
|
||||
import {
|
||||
CoreAppShell,
|
||||
CorePageContainer,
|
||||
CoreAppShellConfig,
|
||||
LayoutVariant,
|
||||
DesktopCollapseVariant,
|
||||
useCoreAppShell,
|
||||
} from '@repo/ui/components';
|
||||
import { Home, BarChart2, Settings as SettingsIcon } from 'lucide-react';
|
||||
|
||||
// Sub-component to test hook methods
|
||||
function LayoutControls() {
|
||||
const { toggleDesktop, toggleMobile, sidebarVariant, setSidebarVariant, toggleAside, toggleNavbarPanel } =
|
||||
useCoreAppShell();
|
||||
|
||||
return (
|
||||
<Group mb="md">
|
||||
<Button onClick={toggleDesktop} variant="default" size="xs">
|
||||
Toggle Desktop Sidebar
|
||||
</Button>
|
||||
<Button onClick={toggleMobile} variant="default" size="xs" hiddenFrom="sm">
|
||||
Toggle Mobile Sidebar
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setSidebarVariant(sidebarVariant === 'expanded' ? 'mini' : 'expanded')}
|
||||
variant="default"
|
||||
size="xs"
|
||||
>
|
||||
Toggle Sidebar Variant (Current: {sidebarVariant})
|
||||
</Button>
|
||||
<Button onClick={toggleNavbarPanel} variant="light" color="grape" size="xs">
|
||||
Toggle Secondary Panel (Double Sidebar)
|
||||
</Button>
|
||||
<Button onClick={toggleAside} variant="filled" color="cyan" size="xs">
|
||||
Toggle Aside via Context
|
||||
</Button>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function MockHeader() {
|
||||
const { mobileOpened, toggleMobile } = useCoreAppShell();
|
||||
return (
|
||||
<Group
|
||||
h="100%"
|
||||
px="md"
|
||||
justify="space-between"
|
||||
bg="green.1"
|
||||
c="green.9"
|
||||
style={{ borderBottom: '1px solid var(--mantine-color-green-3)' }}
|
||||
>
|
||||
<Group>
|
||||
<Burger opened={mobileOpened} onClick={toggleMobile} hiddenFrom="sm" size="sm" />
|
||||
<Text fw={700} size="lg">
|
||||
Mock Header (bg="green.1")
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function MockMobileDrawer() {
|
||||
return (
|
||||
<Box p="md" h="100%" bg="yellow.1" c="yellow.9">
|
||||
<Text fw={700} mb="sm">
|
||||
Mock Mobile Drawer (bg="yellow.1")
|
||||
</Text>
|
||||
<Stack gap="xs">
|
||||
<Button variant="light" color="yellow" justify="flex-start" fullWidth>
|
||||
Mobile Dashboard
|
||||
</Button>
|
||||
<Button variant="light" color="yellow" justify="flex-start" fullWidth>
|
||||
Mobile Settings
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingRow({ title, description, control }: { title: string; description: string; control: ReactNode }) {
|
||||
return (
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Stack gap={0}>
|
||||
<Text fw={500}>{title}</Text>
|
||||
<Text c="dimmed" size="sm">
|
||||
{description}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Box>{control}</Box>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ShellDemo() {
|
||||
const [layoutVariant, setLayoutVariant] = useState<LayoutVariant>('header-first');
|
||||
const [collapseVariant, setCollapseVariant] = useState<DesktopCollapseVariant>('hide');
|
||||
const [withUtilityBar, setWithUtilityBar] = useState(true);
|
||||
const [withAside, setWithAside] = useState(true);
|
||||
const [withFooter, setWithFooter] = useState(true);
|
||||
const [withDoubleSidebar, setWithDoubleSidebar] = useState(true);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const liveConfig = useMemo(() => {
|
||||
return {
|
||||
variant: layoutVariant,
|
||||
features: {
|
||||
desktopCollapseVariant: collapseVariant,
|
||||
withUtilityBar: withUtilityBar ? undefined : false,
|
||||
withAside: withAside ? undefined : false,
|
||||
withFooter: withFooter ? undefined : false,
|
||||
withDoubleSidebar,
|
||||
},
|
||||
};
|
||||
}, [layoutVariant, collapseVariant, withUtilityBar, withAside, withFooter, withDoubleSidebar]);
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(JSON.stringify(liveConfig, null, 2));
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2500);
|
||||
};
|
||||
|
||||
const config: CoreAppShellConfig = {
|
||||
variant: layoutVariant,
|
||||
features: {
|
||||
desktopCollapseVariant: collapseVariant,
|
||||
withUtilityBar: withUtilityBar ? undefined : false,
|
||||
withAside: withAside ? undefined : false,
|
||||
withFooter: withFooter ? undefined : false,
|
||||
withDoubleSidebar,
|
||||
persistState: false, // Don't persist for the demo to avoid confusing other showcases
|
||||
disabled: false, // Allow nested render overrides if needed
|
||||
zIndex: 100,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<CoreAppShell
|
||||
config={config}
|
||||
slots={{
|
||||
utilityBar: (
|
||||
<Group h="100%" px="md" justify="flex-end" bg="blue.1" c="blue.9">
|
||||
<Text size="xs" fw={600}>
|
||||
Mock Utility Bar (bg="blue.1")
|
||||
</Text>
|
||||
</Group>
|
||||
),
|
||||
header: <MockHeader />,
|
||||
sidebarMobile: <MockMobileDrawer />,
|
||||
sidebar: !withDoubleSidebar ? (
|
||||
<Box
|
||||
p="md"
|
||||
h="100%"
|
||||
bg="grape.1"
|
||||
c="grape.9"
|
||||
style={{ borderRight: '1px solid var(--mantine-color-grape-3)' }}
|
||||
>
|
||||
<Text fw={700} mb="sm">
|
||||
Mock Standard Navbar (bg="grape.1")
|
||||
</Text>
|
||||
<Stack gap="xs">
|
||||
<Button variant="light" color="grape" justify="flex-start" fullWidth>
|
||||
Dashboard
|
||||
</Button>
|
||||
<Button variant="light" color="grape" justify="flex-start" fullWidth>
|
||||
Users
|
||||
</Button>
|
||||
<Button variant="light" color="grape" justify="flex-start" fullWidth>
|
||||
Reports
|
||||
</Button>
|
||||
<Button variant="light" color="grape" justify="flex-start" fullWidth>
|
||||
Settings
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
) : undefined,
|
||||
sidebarRail: withDoubleSidebar ? (
|
||||
<Stack align="center" gap="lg" pt="md" h="100%" bg="orange.1" c="orange.9">
|
||||
<Text size="xs" fw={700} style={{ writingMode: 'vertical-rl', transform: 'rotate(180deg)' }}>
|
||||
Mock Rail (bg="orange.1")
|
||||
</Text>
|
||||
<Home size={24} />
|
||||
<BarChart2 size={24} />
|
||||
<SettingsIcon size={24} />
|
||||
</Stack>
|
||||
) : undefined,
|
||||
sidebarPanel: withDoubleSidebar ? (
|
||||
<Box
|
||||
p="md"
|
||||
h="100%"
|
||||
bg="grape.1"
|
||||
c="grape.9"
|
||||
style={{ borderRight: '1px solid var(--mantine-color-grape-3)' }}
|
||||
>
|
||||
<Text fw={700} mb="sm">
|
||||
Mock Panel (bg="grape.1")
|
||||
</Text>
|
||||
<Stack gap="xs">
|
||||
<Button variant="light" color="grape" justify="flex-start" fullWidth>
|
||||
Dashboard
|
||||
</Button>
|
||||
<Button variant="light" color="grape" justify="flex-start" fullWidth>
|
||||
Users
|
||||
</Button>
|
||||
<Button variant="light" color="grape" justify="flex-start" fullWidth>
|
||||
Reports
|
||||
</Button>
|
||||
<Button variant="light" color="grape" justify="flex-start" fullWidth>
|
||||
Settings
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
) : undefined,
|
||||
aside: (
|
||||
<Box p="md" h="100%" bg="cyan.1" c="cyan.9">
|
||||
<Text fw={700} mb="md">
|
||||
Mock Aside
|
||||
</Text>
|
||||
<Text size="sm">This area could be used for notifications, help text, or contextual settings.</Text>
|
||||
</Box>
|
||||
),
|
||||
footer: (
|
||||
<Group
|
||||
h="100%"
|
||||
px="md"
|
||||
justify="space-between"
|
||||
bg="gray.1"
|
||||
style={{ borderTop: '1px solid var(--app-shell-border-color)' }}
|
||||
>
|
||||
<Text size="sm" fw={600}>
|
||||
Mock Footer (bg="gray.1")
|
||||
</Text>
|
||||
</Group>
|
||||
),
|
||||
}}
|
||||
>
|
||||
<CorePageContainer
|
||||
headerSlot={
|
||||
<Group justify="space-between" align="center">
|
||||
<Text component="h1" size="xl" fw={700} m={0}>
|
||||
Layout Engine Interactive Demo
|
||||
</Text>
|
||||
<Group>
|
||||
<Button component={Link} to="/showcase" leftSection={<ArrowLeft size={16} />} variant="default">
|
||||
Back to Showcase
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
}
|
||||
stickyHeader
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Paper withBorder p="md" bg="var(--mantine-color-gray-0)">
|
||||
<LayoutControls />
|
||||
</Paper>
|
||||
|
||||
<SettingRow
|
||||
title="Layout Variant"
|
||||
description="Switch between standard cloud or SaaS layout styles."
|
||||
control={
|
||||
<Select
|
||||
value={layoutVariant}
|
||||
onChange={(value) => setLayoutVariant((value as LayoutVariant) || 'sidebar-first')}
|
||||
data={[
|
||||
{ label: 'Sidebar First (Alt)', value: 'sidebar-first' },
|
||||
{ label: 'Header First (Default)', value: 'header-first' },
|
||||
{ label: 'Top Nav (Hidden Sidebar)', value: 'top-nav' },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
title="Desktop Collapse Strategy"
|
||||
description="Determine if the sidebar shrinks to icons or slides out completely."
|
||||
control={
|
||||
<SegmentedControl
|
||||
value={collapseVariant}
|
||||
onChange={(value) => setCollapseVariant(value as DesktopCollapseVariant)}
|
||||
data={[
|
||||
{ label: 'Hide (Slide Out)', value: 'hide' },
|
||||
{ label: 'Mini (Shrink)', value: 'mini' },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
title="Enable Double Sidebar"
|
||||
description="Activate the Google-style rail and contextual panel navigation."
|
||||
control={
|
||||
<Switch
|
||||
checked={withDoubleSidebar}
|
||||
onChange={(event) => setWithDoubleSidebar(event.currentTarget.checked)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
title="Render Utility Bar"
|
||||
description="Show a system-level announcement bar above the main header."
|
||||
control={
|
||||
<Switch checked={withUtilityBar} onChange={(event) => setWithUtilityBar(event.currentTarget.checked)} />
|
||||
}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
title="Render Aside"
|
||||
description="Toggle the right-hand properties or filter panel."
|
||||
control={<Switch checked={withAside} onChange={(event) => setWithAside(event.currentTarget.checked)} />}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
title="Render Footer"
|
||||
description="Toggle the bottom application footer."
|
||||
control={<Switch checked={withFooter} onChange={(event) => setWithFooter(event.currentTarget.checked)} />}
|
||||
/>
|
||||
|
||||
<Box mt="xl">
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Text fw={700} size="lg">
|
||||
Configuration Preview
|
||||
</Text>
|
||||
<Button
|
||||
variant={copied ? 'filled' : 'light'}
|
||||
color={copied ? 'teal' : 'blue'}
|
||||
size="xs"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{copied ? 'Copied to Clipboard!' : 'Copy JSON'}
|
||||
</Button>
|
||||
</Group>
|
||||
<Paper withBorder p="md" bg="dark.8" c="gray.0" style={{ fontFamily: 'monospace', overflowX: 'auto' }}>
|
||||
<pre style={{ margin: 0 }}>{JSON.stringify(liveConfig, null, 2)}</pre>
|
||||
</Paper>
|
||||
</Box>
|
||||
</Stack>
|
||||
</CorePageContainer>
|
||||
</CoreAppShell>
|
||||
);
|
||||
}
|
||||
@@ -1,437 +0,0 @@
|
||||
import { useState, Suspense } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ColorSchemeType, DensityType } from '@repo/ui/provider';
|
||||
import { useThemeStore } from '../../core/stores/theme.store';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
Select,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
TextInput,
|
||||
Checkbox,
|
||||
PasswordInput,
|
||||
NumberInput,
|
||||
Textarea,
|
||||
Switch,
|
||||
Table,
|
||||
Badge,
|
||||
Divider,
|
||||
Tabs,
|
||||
Box,
|
||||
Paper,
|
||||
StatusBadge,
|
||||
STATUS_DATA,
|
||||
} from '@repo/ui/components';
|
||||
import { ShieldCheck, Database, Lock, Layout, Activity, Printer, FileText, LayoutDashboard } from 'lucide-react';
|
||||
import { Globe } from 'lucide-react';
|
||||
import { useTranslation } from '@repo/core-i18n';
|
||||
import PrinterList from './printer-list';
|
||||
import ExamplePage from './example/example.page';
|
||||
import EventsDemoPage from './events-demo';
|
||||
import PouchSample from './pouch-sample';
|
||||
import FormDemoView from './example/features/form-demo';
|
||||
import { COMPONENTS_REGISTRY } from './registry';
|
||||
|
||||
interface ShowcaseViewProps {
|
||||
density: DensityType;
|
||||
setDensity: (val: DensityType) => void;
|
||||
}
|
||||
|
||||
export default function ShowcaseView({ density, setDensity }: ShowcaseViewProps) {
|
||||
const { colorScheme, setColorScheme } = useThemeStore();
|
||||
const [activeTab, setActiveTab] = useState<string | null>('ui-components');
|
||||
const { i18n } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Mock data for the table
|
||||
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' },
|
||||
];
|
||||
|
||||
const getSubtitle = () => {
|
||||
const registryItem = Object.values(COMPONENTS_REGISTRY).find((item) => item.id === activeTab);
|
||||
if (registryItem) {
|
||||
return registryItem.description;
|
||||
}
|
||||
|
||||
switch (activeTab) {
|
||||
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';
|
||||
default:
|
||||
return 'Architecture Showcase';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className="min-h-screen" style={{ backgroundColor: 'var(--mantine-color-body)' }}>
|
||||
<Tabs
|
||||
orientation="vertical"
|
||||
placement="left"
|
||||
value={activeTab}
|
||||
onChange={(val) => {
|
||||
if (val === 'layout-engine') {
|
||||
navigate('/shell-demo');
|
||||
} else if (val === 'apps') {
|
||||
navigate('/app');
|
||||
} else {
|
||||
setActiveTab(val);
|
||||
}
|
||||
}}
|
||||
variant="pills"
|
||||
radius="md"
|
||||
className="h-screen"
|
||||
styles={{
|
||||
root: { display: 'flex', height: '100vh', overflow: 'hidden' },
|
||||
list: {
|
||||
minWidth: 260,
|
||||
padding: '1rem',
|
||||
borderRight: '1px solid var(--app-shell-border-color)',
|
||||
backgroundColor: 'var(--mantine-color-default-element-bg)',
|
||||
},
|
||||
panel: { flex: 1, display: 'flex', flexDirection: 'column', height: '100vh', overflow: 'hidden' },
|
||||
tab: { justifyContent: 'flex-start' }, // Ensure the entire tab content aligns left
|
||||
tabLabel: { textAlign: 'left', flex: 1 }, // Ensure the text pushes to fill and aligns left
|
||||
}}
|
||||
>
|
||||
<Tabs.List>
|
||||
<Box mb="xl" px="sm">
|
||||
<Title order={3} c="brand.7">
|
||||
Eigen ERP
|
||||
</Title>
|
||||
<Text size="xs" c="dimmed">
|
||||
Architecture Showcase
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Tabs.Tab value="ui-components" leftSection={<Layout size={18} />}>
|
||||
UI Components
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="layout-engine" leftSection={<Layout size={18} />}>
|
||||
Layout Engine
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="forms" leftSection={<FileText size={18} />}>
|
||||
Form Engine
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="storage" leftSection={<Database size={18} />}>
|
||||
Offline Storage
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="rbac" leftSection={<ShieldCheck size={18} />}>
|
||||
RBAC Engine
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="auth" leftSection={<Lock size={18} />}>
|
||||
Auth & Security
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="events" leftSection={<Activity size={18} />}>
|
||||
Events
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="hardware" leftSection={<Printer size={18} />}>
|
||||
Hardware
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="apps" leftSection={<LayoutDashboard size={18} />}>
|
||||
Apps
|
||||
</Tabs.Tab>
|
||||
|
||||
{Object.values(COMPONENTS_REGISTRY).map((registryItem) => {
|
||||
const Icon = registryItem.icon;
|
||||
return (
|
||||
<Tabs.Tab key={registryItem.id} value={registryItem.id} leftSection={<Icon size={18} />}>
|
||||
{registryItem.name}
|
||||
</Tabs.Tab>
|
||||
);
|
||||
})}
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value={activeTab as string}>
|
||||
{/* Header */}
|
||||
<Paper
|
||||
p="md"
|
||||
radius={0}
|
||||
withBorder
|
||||
style={{
|
||||
borderTop: 0,
|
||||
borderLeft: 0,
|
||||
borderRight: 0,
|
||||
zIndex: 10,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between">
|
||||
<Stack gap={0}>
|
||||
<Title order={3}>Architecture Showcase</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
{getSubtitle()}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Select
|
||||
w={180}
|
||||
size="sm"
|
||||
variant="filled"
|
||||
leftSection={<Globe size={16} />}
|
||||
data={[
|
||||
{ value: 'en', label: 'English' },
|
||||
{ value: 'id', label: 'Bahasa Indonesia' },
|
||||
]}
|
||||
value={i18n.resolvedLanguage || i18n.language}
|
||||
onChange={(val) => val && i18n.changeLanguage(val)}
|
||||
/>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
{/* Scrollable Content Area */}
|
||||
<Box className="flex-1 overflow-y-auto p-6" style={{ height: 'calc(100vh - 80px)' }}>
|
||||
<Container size="xl" m={0} p={0}>
|
||||
{/* --- UI COMPONENTS TAB --- */}
|
||||
{activeTab === 'ui-components' && (
|
||||
<Stack gap="xl">
|
||||
{/* Control Panel */}
|
||||
<Card withBorder shadow="sm" radius="md" p="md">
|
||||
<Title order={4} mb="md">
|
||||
Theme Controls
|
||||
</Title>
|
||||
<Group grow align="flex-end">
|
||||
<Select
|
||||
label="Color Scheme"
|
||||
value={colorScheme}
|
||||
onChange={(val) => setColorScheme((val as ColorSchemeType) || 'light')}
|
||||
data={[
|
||||
{ value: 'light', label: 'Light' },
|
||||
{ value: 'dark', label: 'Dark' },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
label="Density (Spacing & Sizing)"
|
||||
value={density}
|
||||
onChange={(val) => setDensity((val as DensityType) || 'standard')}
|
||||
data={[
|
||||
{ value: 'compact', label: 'Compact (ERP Mode)' },
|
||||
{ value: 'standard', label: 'Standard (UI Mode)' },
|
||||
]}
|
||||
/>
|
||||
</Group>
|
||||
</Card>
|
||||
|
||||
{/* Typography & Buttons */}
|
||||
<Card withBorder shadow="sm" radius="md" p="md">
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<Title order={4} mb="xs">
|
||||
Typography & Badges
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
This is dimmed small text indicating a subtitle.
|
||||
</Text>
|
||||
<Group mt="md">
|
||||
<Badge color="brand">Brand Badge</Badge>
|
||||
<Badge color="success" variant="light">
|
||||
Success Status
|
||||
</Badge>
|
||||
<Badge color="error" variant="outline">
|
||||
Error State
|
||||
</Badge>
|
||||
</Group>
|
||||
</div>
|
||||
<Divider />
|
||||
<div>
|
||||
<Title order={4} mb="xs">
|
||||
Enterprise Status Badges
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" mb="md">
|
||||
Pre-configured status badges for transaction and master data.
|
||||
</Text>
|
||||
<Group>
|
||||
<StatusBadge status={STATUS_DATA.DRAFT} />
|
||||
<StatusBadge status={STATUS_DATA.PENDING} />
|
||||
<StatusBadge status={STATUS_DATA.PROCESS} />
|
||||
<StatusBadge status={STATUS_DATA.APPROVED} />
|
||||
<StatusBadge status={STATUS_DATA.CANCELLED} />
|
||||
<StatusBadge status={STATUS_DATA.WAITING} />
|
||||
<StatusBadge status={STATUS_DATA.ON_HOLD} />
|
||||
<StatusBadge status={STATUS_DATA.REFUNDED} />
|
||||
</Group>
|
||||
</div>
|
||||
<Divider />
|
||||
<div>
|
||||
<Title order={4} mb="md">
|
||||
Buttons
|
||||
</Title>
|
||||
<Group>
|
||||
<Button variant="filled" color="brand">
|
||||
Filled Button
|
||||
</Button>
|
||||
<Button variant="outline" color="brand">
|
||||
Outline Button
|
||||
</Button>
|
||||
<Button variant="light" color="info">
|
||||
Light Info
|
||||
</Button>
|
||||
<Button variant="subtle" color="error">
|
||||
Cancel
|
||||
</Button>
|
||||
</Group>
|
||||
</div>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* Forms */}
|
||||
<Card withBorder shadow="sm" radius="md" p="md">
|
||||
<Title order={4} mb="md">
|
||||
Form Elements
|
||||
</Title>
|
||||
<Stack gap="md">
|
||||
<Group grow align="flex-start">
|
||||
<TextInput label="First Name" placeholder="Enter your first name" withAsterisk />
|
||||
<TextInput label="Last Name" placeholder="Enter your last name" />
|
||||
</Group>
|
||||
<Group grow align="flex-start">
|
||||
<NumberInput label="Age" placeholder="25" min={0} max={100} />
|
||||
<PasswordInput label="Password" placeholder="Your secret password" withAsterisk />
|
||||
</Group>
|
||||
<Textarea label="Bio" placeholder="Tell us about yourself" minRows={3} />
|
||||
<Group mt="sm">
|
||||
<Checkbox label="I agree to the terms and conditions" defaultChecked />
|
||||
<Switch label="Enable notifications" defaultChecked />
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* Data Grid */}
|
||||
<Card withBorder shadow="sm" radius="md" p="md">
|
||||
<Title order={4} mb="md">
|
||||
Data Grid
|
||||
</Title>
|
||||
<Table striped highlightOnHover withTableBorder withColumnBorders>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Order ID</Table.Th>
|
||||
<Table.Th>Customer</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>Total</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{tableData.map((row) => (
|
||||
<Table.Tr key={row.id}>
|
||||
<Table.Td>{row.id}</Table.Td>
|
||||
<Table.Td>{row.customer}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
size="sm"
|
||||
color={
|
||||
row.status === 'Delivered' ? 'success' : row.status === 'Shipped' ? 'info' : 'warning'
|
||||
}
|
||||
>
|
||||
{row.status}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>{row.total}</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Card>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* --- FORMS TAB --- */}
|
||||
{activeTab === 'forms' && (
|
||||
<Stack gap="xl">
|
||||
<FormDemoView />
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* --- STORAGE TAB --- */}
|
||||
{activeTab === 'storage' && (
|
||||
<Stack gap="xl">
|
||||
<PouchSample />
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* --- RBAC TAB --- */}
|
||||
{activeTab === 'rbac' && (
|
||||
<Stack gap="xl">
|
||||
<Card withBorder shadow="sm" radius="md" p="md">
|
||||
<Title order={4} mb="md">
|
||||
RBAC Engine
|
||||
</Title>
|
||||
<Text c="dimmed">RBAC Demo Component Coming Soon...</Text>
|
||||
</Card>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* --- AUTH TAB --- */}
|
||||
{activeTab === 'auth' && (
|
||||
<Stack gap="xl">
|
||||
<Card withBorder shadow="sm" radius="md" p="md">
|
||||
<Title order={4} mb="md">
|
||||
Auth & Security
|
||||
</Title>
|
||||
<Text c="dimmed">Auth Demo Component Coming Soon...</Text>
|
||||
</Card>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* --- EVENTS TAB --- */}
|
||||
{activeTab === 'events' && (
|
||||
<Stack gap="xl">
|
||||
<Card withBorder shadow="sm" radius="md" p="md">
|
||||
<Title order={4} mb="md">
|
||||
Nested Showcase Example
|
||||
</Title>
|
||||
<Text mb="md">This is an example of a nested showcase component.</Text>
|
||||
<ExamplePage />
|
||||
</Card>
|
||||
<Card withBorder shadow="sm" radius="md" p="md">
|
||||
<EventsDemoPage />
|
||||
</Card>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* --- HARDWARE TAB --- */}
|
||||
{activeTab === 'hardware' && (
|
||||
<Stack gap="xl">
|
||||
<Card withBorder shadow="sm" radius="md" p="md">
|
||||
<PrinterList />
|
||||
</Card>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* --- DYNAMIC COMPONENTS FROM REGISTRY --- */}
|
||||
{Object.values(COMPONENTS_REGISTRY).map((registryItem) => {
|
||||
const Component = registryItem.component;
|
||||
return (
|
||||
activeTab === registryItem.id && (
|
||||
<Stack gap="xl" key={registryItem.id}>
|
||||
<Suspense fallback={<Text>Loading {registryItem.name}...</Text>}>
|
||||
<Component />
|
||||
</Suspense>
|
||||
</Stack>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</Container>
|
||||
</Box>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user