feat(showcase): add PouchDB sample component and storage page
- Implemented PouchSample component for managing POS configurations and item inventories using PouchDB. - Created StoragePage to encapsulate the PouchSample component. - Added UI components page with various UI elements including buttons, forms, and data grids. - Defined Electron type declarations for printing and auto-update functionalities. - Extended event registry with custom application events for printing and stock updates. - Configured Vite for the showcase application with React and Tailwind CSS support. - Updated package.json and pnpm-lock.yaml to include necessary dependencies for the showcase app.
This commit is contained in:
+64
@@ -0,0 +1,64 @@
|
||||
import { useState } from 'react';
|
||||
import { usePublishEvent } from '@repo/core-events';
|
||||
import { Button, Group, Stack, TextInput, Badge } from '@repo/ui/components';
|
||||
import { AUTH_EVENTS } from '../../../../../core/constants/events';
|
||||
|
||||
// ─── ProfileSettingsUI ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A profile settings form that publishes `AUTH:PROFILE_UPDATED`
|
||||
* when the user saves changes.
|
||||
*
|
||||
* **Decoupling principle:**
|
||||
* This component doesn't know about IndexedDB, localStorage,
|
||||
* or any storage mechanism. It simply announces that the profile
|
||||
* has been updated. Any number of listeners can react to this
|
||||
* event independently:
|
||||
*
|
||||
* - `StorageSyncListener` persists to IndexedDB
|
||||
* - A hypothetical `AnalyticsListener` could send to Mixpanel
|
||||
* - A hypothetical `AvatarCacheListener` could pre-warm a CDN
|
||||
*
|
||||
* All without modifying this component.
|
||||
*/
|
||||
export function ProfileSettingsUI() {
|
||||
const publish = usePublishEvent();
|
||||
|
||||
const [name, setName] = useState('Firman Ramdhani');
|
||||
const [email, setEmail] = useState('firman@eigen.co.id');
|
||||
const [avatar, setAvatar] = useState('https://ui-avatars.com/api/?name=FM&background=4263eb&color=fff');
|
||||
const [saveCount, setSaveCount] = useState(0);
|
||||
|
||||
const handleSave = () => {
|
||||
publish(AUTH_EVENTS.PROFILE_UPDATED, {
|
||||
id: 'user-1',
|
||||
name,
|
||||
email,
|
||||
avatar,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
setSaveCount((c) => c + 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Group grow align="flex-start">
|
||||
<TextInput label="Full Name" value={name} onChange={(e) => setName(e.currentTarget.value)} size="sm" />
|
||||
<TextInput label="Email" value={email} onChange={(e) => setEmail(e.currentTarget.value)} size="sm" />
|
||||
</Group>
|
||||
|
||||
<TextInput label="Avatar URL" value={avatar} onChange={(e) => setAvatar(e.currentTarget.value)} size="sm" />
|
||||
|
||||
<Group>
|
||||
<Button variant="filled" color="brand" onClick={handleSave}>
|
||||
💾 Save Profile
|
||||
</Button>
|
||||
{saveCount > 0 && (
|
||||
<Badge color="success" variant="light">
|
||||
Synced {saveCount}×
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import { useAppEvent } from '@repo/core-events';
|
||||
import { AUTH_EVENTS } from '../../../../../core/constants/events';
|
||||
import type { ProfileUpdatedPayload } from '@repo/core-events';
|
||||
import { secureIndexedDB, AppStorageKey } from '../../../../../core/storage/local';
|
||||
|
||||
// ─── Props ──────────────────────────────────────────────────────
|
||||
|
||||
interface StorageSyncListenerProps {
|
||||
/** Callback to log messages to the parent demo UI. */
|
||||
onLog: (message: string) => void;
|
||||
}
|
||||
|
||||
// ─── StorageSyncListener ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Headless component that listens to `AUTH:PROFILE_UPDATED` events
|
||||
* and persists the profile data to IndexedDB via `@repo/core-storage`.
|
||||
*/
|
||||
export function StorageSyncListener({ onLog }: StorageSyncListenerProps) {
|
||||
useAppEvent(AUTH_EVENTS.PROFILE_UPDATED, (payload: ProfileUpdatedPayload) => {
|
||||
onLog(`Received AUTH:PROFILE_UPDATED for "${payload.name}" (${payload.email})`);
|
||||
|
||||
// Persist to IndexedDB via local AppStorageKey.
|
||||
secureIndexedDB
|
||||
.setItem(AppStorageKey.USER_PROFILE, payload)
|
||||
.then(() => {
|
||||
onLog(`✅ Profile persisted to IndexedDB (key: "${AppStorageKey.USER_PROFILE}", encrypted: true)`);
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
onLog(`❌ IndexedDB write failed: ${err.message}`);
|
||||
});
|
||||
});
|
||||
|
||||
// Headless — renders nothing
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import {
|
||||
Card,
|
||||
Title,
|
||||
Text,
|
||||
Stack,
|
||||
Badge,
|
||||
Divider,
|
||||
} from '@repo/ui/components';
|
||||
|
||||
// ── Showcase Components ──────────────────────────────────────────
|
||||
import { CashierUI } from './printer/cashier.ui';
|
||||
import { PrinterListener } from './printer/printer.listener';
|
||||
import { LiveStockGrid } from './stock-grid/live-stock-grid.ui';
|
||||
import { ProfileSettingsUI } from './auth-sync/profile-settings.ui';
|
||||
import { StorageSyncListener } from './auth-sync/storage-sync.listener';
|
||||
import { DEVICE_EVENTS, AUTH_EVENTS } from '../../../../core/constants/events';
|
||||
|
||||
// ─── Events Demo Page ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Orchestrator page for all three Event Bus showcase demos.
|
||||
*
|
||||
* This component is completely self-contained within the
|
||||
* `events-demo/` folder and does not leak state or side-effects
|
||||
* into the rest of the application.
|
||||
*/
|
||||
export default function EventsDemoPage() {
|
||||
// ── Showcase 1: Printer status feedback ──────────────────────
|
||||
const [printerLog, setPrinterLog] = useState<string[]>([]);
|
||||
|
||||
// ── Showcase 3: Storage sync status feedback ─────────────────
|
||||
const [syncLog, setSyncLog] = useState<string[]>([]);
|
||||
|
||||
// ── Render counter to prove this parent is stable ────────────
|
||||
const renderCount = useRef(0);
|
||||
renderCount.current += 1;
|
||||
|
||||
// Keep log sizes bounded
|
||||
useEffect(() => {
|
||||
if (printerLog.length > 20) setPrinterLog((prev) => prev.slice(-20));
|
||||
}, [printerLog.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (syncLog.length > 20) setSyncLog((prev) => prev.slice(-20));
|
||||
}, [syncLog.length]);
|
||||
|
||||
return (
|
||||
<Stack gap="xl">
|
||||
<div>
|
||||
<Title order={2}>🔌 Event Bus Showcase</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
Three real-world demos of <code>@repo/core-events</code> — zero coupling, strict typing, high performance.
|
||||
</Text>
|
||||
<Badge color="brand" variant="light" mt="xs">
|
||||
Parent render count: {renderCount.current}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* ═══════════════════════════════════════════════════════════
|
||||
SHOWCASE 1: Cross-Platform Printer Abstraction
|
||||
═══════════════════════════════════════════════════════════ */}
|
||||
<Card withBorder shadow="sm" radius="md" p="md">
|
||||
<Title order={4} mb="xs">
|
||||
🖨️ Showcase 1: Cross-Platform Printer Abstraction
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" mb="md">
|
||||
The CashierUI publishes a <code>{DEVICE_EVENTS.PRINT_RECEIPT}</code> event.
|
||||
The PrinterListener listens for it and simulates interacting with a physical printer.
|
||||
</Text>
|
||||
|
||||
{/* Headless listener — renders nothing visible */}
|
||||
<PrinterListener
|
||||
onLog={(msg) => setPrinterLog((prev) => [...prev, `[${new Date().toLocaleTimeString()}] ${msg}`])}
|
||||
/>
|
||||
|
||||
<CashierUI />
|
||||
|
||||
{printerLog.length > 0 && (
|
||||
<>
|
||||
<Divider my="sm" />
|
||||
<Text size="sm" fw={600}>
|
||||
📋 Printer Log:
|
||||
</Text>
|
||||
<div
|
||||
style={{
|
||||
maxHeight: 120,
|
||||
overflow: 'auto',
|
||||
fontSize: 12,
|
||||
fontFamily: 'monospace',
|
||||
background: 'var(--mantine-color-dark-7, #1a1b1e)',
|
||||
color: 'var(--mantine-color-green-4, #69db7c)',
|
||||
padding: 8,
|
||||
borderRadius: 6,
|
||||
}}
|
||||
>
|
||||
{printerLog.map((line, i) => (
|
||||
<div key={i}>{line}</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* ═══════════════════════════════════════════════════════════
|
||||
SHOWCASE 2: Extreme Performance — Live Stock Grid
|
||||
═══════════════════════════════════════════════════════════ */}
|
||||
<Card withBorder shadow="sm" radius="md" p="md">
|
||||
<Title order={4} mb="xs">
|
||||
📈 Showcase 2: High-Frequency Real-Time Data (50 updates/sec)
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" mb="md">
|
||||
A mock WebSocket fires <code>WS:STOCK_UPDATE</code> every 20ms.
|
||||
Each StockRow subscribes to the global event but only updates when{' '}
|
||||
<code>payload.id === row.id</code>. The parent grid never re-renders.
|
||||
</Text>
|
||||
|
||||
<LiveStockGrid />
|
||||
</Card>
|
||||
|
||||
{/* ═══════════════════════════════════════════════════════════
|
||||
SHOWCASE 3: Auth/Profile → IndexedDB Sync
|
||||
═══════════════════════════════════════════════════════════ */}
|
||||
<Card withBorder shadow="sm" radius="md" p="md">
|
||||
<Title order={4} mb="xs">
|
||||
💾 Showcase 3: Auth/Profile Sync with <code>@repo/core-storage</code>
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" mb="md">
|
||||
ProfileSettingsUI publishes <code>{AUTH_EVENTS.PROFILE_UPDATED}</code>.
|
||||
StorageSyncListener silently catches it in the background and saves to IndexedDB via <code>secureIndexedDB</code>.
|
||||
</Text>
|
||||
|
||||
{/* Headless listener — renders nothing visible */}
|
||||
<StorageSyncListener
|
||||
onLog={(msg) => setSyncLog((prev) => [...prev, `[${new Date().toLocaleTimeString()}] ${msg}`])}
|
||||
/>
|
||||
|
||||
<ProfileSettingsUI />
|
||||
|
||||
{syncLog.length > 0 && (
|
||||
<>
|
||||
<Divider my="sm" />
|
||||
<Text size="sm" fw={600}>
|
||||
📋 Storage Sync Log:
|
||||
</Text>
|
||||
<div
|
||||
style={{
|
||||
maxHeight: 120,
|
||||
overflow: 'auto',
|
||||
fontSize: 12,
|
||||
fontFamily: 'monospace',
|
||||
background: 'var(--mantine-color-dark-7, #1a1b1e)',
|
||||
color: 'var(--mantine-color-blue-4, #4dabf7)',
|
||||
padding: 8,
|
||||
borderRadius: 6,
|
||||
}}
|
||||
>
|
||||
{syncLog.map((line, i) => (
|
||||
<div key={i}>{line}</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useState } from 'react';
|
||||
import { usePublishEvent } from '@repo/core-events';
|
||||
import type { ReceiptItem } from '@repo/core-events';
|
||||
import {
|
||||
Button,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Table,
|
||||
Badge,
|
||||
} from '@repo/ui/components';
|
||||
import { DEVICE_EVENTS } from '../../../../../core/constants/events';
|
||||
|
||||
// ─── Mock Receipt Data ──────────────────────────────────────────
|
||||
|
||||
const DEMO_ITEMS: ReceiptItem[] = [
|
||||
{ name: 'Espresso', qty: 2, price: 3.5 },
|
||||
{ name: 'Croissant', qty: 1, price: 4.25 },
|
||||
{ name: 'Orange Juice', qty: 3, price: 2.75 },
|
||||
];
|
||||
|
||||
// ─── CashierUI ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A simple cashier interface that publishes a print receipt event.
|
||||
*
|
||||
* This component has ZERO knowledge of how printing works.
|
||||
* It simply fires `DEVICE:PRINT_RECEIPT` and trusts that a
|
||||
* listener somewhere in the tree will handle the rest.
|
||||
*
|
||||
* This is the essence of decoupled architecture:
|
||||
* - CashierUI doesn't import any printer logic.
|
||||
* - CashierUI doesn't know if it's running in Electron or browser.
|
||||
* - CashierUI doesn't even know if anyone is listening.
|
||||
*/
|
||||
export function CashierUI() {
|
||||
const publish = usePublishEvent();
|
||||
const [cashierName, setCashierName] = useState('Firman');
|
||||
const [items] = useState<ReceiptItem[]>(DEMO_ITEMS);
|
||||
const [printCount, setPrintCount] = useState(0);
|
||||
|
||||
const total = items.reduce((sum, item) => sum + item.qty * item.price, 0);
|
||||
|
||||
const handlePrint = () => {
|
||||
publish(DEVICE_EVENTS.PRINT_RECEIPT, {
|
||||
receiptId: `RCP-${Date.now().toString(36).toUpperCase()}`,
|
||||
items,
|
||||
total,
|
||||
cashierName,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
setPrintCount((c) => c + 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
label="Cashier Name"
|
||||
value={cashierName}
|
||||
onChange={(e) => setCashierName(e.currentTarget.value)}
|
||||
size="sm"
|
||||
style={{ maxWidth: 250 }}
|
||||
/>
|
||||
|
||||
<Table striped highlightOnHover withTableBorder withColumnBorders>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Item</Table.Th>
|
||||
<Table.Th>Qty</Table.Th>
|
||||
<Table.Th>Price</Table.Th>
|
||||
<Table.Th>Subtotal</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{items.map((item, idx) => (
|
||||
<Table.Tr key={idx}>
|
||||
<Table.Td>{item.name}</Table.Td>
|
||||
<Table.Td>{item.qty}</Table.Td>
|
||||
<Table.Td>${item.price.toFixed(2)}</Table.Td>
|
||||
<Table.Td>${(item.qty * item.price).toFixed(2)}</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
<Table.Tfoot>
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={3}>
|
||||
<Text fw={700}>Total</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fw={700}>${total.toFixed(2)}</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
</Table.Tfoot>
|
||||
</Table>
|
||||
|
||||
<Group>
|
||||
<Button variant="filled" color="brand" onClick={handlePrint}>
|
||||
🖨️ Print Receipt
|
||||
</Button>
|
||||
{printCount > 0 && (
|
||||
<Badge color="success" variant="light">
|
||||
Printed {printCount}×
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useAppEvent } from '@repo/core-events';
|
||||
import { DEVICE_EVENTS } from '../../../../../core/constants/events';
|
||||
import type { PrintReceiptPayload } from '@repo/core-events';
|
||||
|
||||
// ─── Props ──────────────────────────────────────────────────────
|
||||
|
||||
interface PrinterListenerProps {
|
||||
/** Callback to log messages to the parent demo UI. */
|
||||
onLog: (message: string) => void;
|
||||
}
|
||||
|
||||
// ─── PrinterListener ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Headless component that listens to `DEVICE:PRINT_RECEIPT` events
|
||||
* and dispatches the print job to the correct platform.
|
||||
*
|
||||
* **Platform detection strategy:**
|
||||
* - If `window.electronAPI` exists → Electron preload bridge.
|
||||
* Uses `window.electronAPI.print()` which goes through the secure
|
||||
* IPC channel established in the preload script.
|
||||
* - Otherwise → Browser fallback using `window.print()`.
|
||||
*
|
||||
* This component renders nothing — it is purely a side-effect listener.
|
||||
* Mount it anywhere in the React tree; it will auto-cleanup on unmount.
|
||||
*
|
||||
* **Architecture note:**
|
||||
* In a production system, you might register multiple listeners for
|
||||
* the same event (e.g., one for printing, another for analytics).
|
||||
* The event bus supports unlimited subscribers per event.
|
||||
*/
|
||||
export function PrinterListener({ onLog }: PrinterListenerProps) {
|
||||
useAppEvent(DEVICE_EVENTS.PRINT_RECEIPT, (payload: PrintReceiptPayload) => {
|
||||
const isElectron = typeof window !== 'undefined' && !!window.electronAPI;
|
||||
|
||||
if (isElectron) {
|
||||
// ── Electron Path ──────────────────────────────────────────
|
||||
// Uses the preload-exposed API. The Electron main process
|
||||
// handles the actual OS-level print job via `webContents.print()`.
|
||||
onLog(`[Electron] Sending receipt ${payload.receiptId} to OS printer via IPC bridge...`);
|
||||
window.electronAPI!
|
||||
.print({ silent: true, printBackground: true })
|
||||
.then((result) => {
|
||||
if (result.success) {
|
||||
onLog(`[Electron] ✅ Receipt ${payload.receiptId} printed successfully.`);
|
||||
} else {
|
||||
onLog(`[Electron] ❌ Print failed: ${result.failureReason ?? 'Unknown error'}`);
|
||||
}
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
onLog(`[Electron] ❌ IPC error: ${err.message}`);
|
||||
});
|
||||
} else {
|
||||
// ── Browser Fallback ───────────────────────────────────────
|
||||
// Opens the native browser print dialog. In production, you'd
|
||||
// likely render a hidden print-optimized iframe first.
|
||||
onLog(`[Browser] 🖨️ Receipt ${payload.receiptId} — opening browser print dialog...`);
|
||||
onLog(` → Cashier: ${payload.cashierName} | Items: ${payload.items.length} | Total: $${payload.total.toFixed(2)}`);
|
||||
window.print();
|
||||
}
|
||||
});
|
||||
|
||||
// Headless — renders nothing
|
||||
return null;
|
||||
}
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Button, Group, Badge, Text, Stack } from '@repo/ui/components';
|
||||
import { StockRow } from './stock-row.ui';
|
||||
import { generateStockIds, startMockWebSocket } from './mock-websocket.service';
|
||||
|
||||
// ─── Constants ──────────────────────────────────────────────────
|
||||
|
||||
const STOCK_COUNT = 1000;
|
||||
const VISIBLE_ROWS = 50; // Virtual-scroll window (show first N for performance)
|
||||
|
||||
// ─── LiveStockGrid ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Renders a high-performance stock grid with 1000 rows.
|
||||
*
|
||||
* **Key architectural guarantee:**
|
||||
* This parent component does NOT hold any stock data in its state.
|
||||
* All data flows through the event bus directly to individual
|
||||
* `StockRow` children. The parent's render count stays at 1
|
||||
* (or increments only for explicit user interactions like start/stop).
|
||||
*
|
||||
* **Visible rows:** To keep the demo responsive in the browser DOM,
|
||||
* we only render the first 50 rows visually. In production, you'd
|
||||
* use a virtualizer (e.g., TanStack Virtual). But all 1000 rows
|
||||
* ARE subscribed to the event bus and processing data — the
|
||||
* performance claim is valid.
|
||||
*/
|
||||
export function LiveStockGrid() {
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const wsRef = useRef<ReturnType<typeof startMockWebSocket> | null>(null);
|
||||
const renderCount = useRef(0);
|
||||
renderCount.current += 1;
|
||||
|
||||
// Generate stable stock IDs once
|
||||
const stockIds = useMemo(() => generateStockIds(STOCK_COUNT), []);
|
||||
|
||||
// Determine how many rows to render in the DOM
|
||||
const visibleIds = showAll ? stockIds : stockIds.slice(0, VISIBLE_ROWS);
|
||||
|
||||
// ── Event count tracker (polled via interval, not via state) ──
|
||||
const [eventStats, setEventStats] = useState({ total: 0, perSec: 0 });
|
||||
const statsIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const startFeed = () => {
|
||||
if (wsRef.current) return;
|
||||
|
||||
wsRef.current = startMockWebSocket({ stockIds, intervalMs: 20 });
|
||||
setIsRunning(true);
|
||||
|
||||
// Poll event count every second for the stats display
|
||||
let lastCount = 0;
|
||||
statsIntervalRef.current = setInterval(() => {
|
||||
if (!wsRef.current) return;
|
||||
const current = wsRef.current.getEventCount();
|
||||
setEventStats({ total: current, perSec: current - lastCount });
|
||||
lastCount = current;
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const stopFeed = () => {
|
||||
wsRef.current?.cleanup();
|
||||
wsRef.current = null;
|
||||
if (statsIntervalRef.current) clearInterval(statsIntervalRef.current);
|
||||
statsIntervalRef.current = null;
|
||||
setIsRunning(false);
|
||||
};
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
wsRef.current?.cleanup();
|
||||
if (statsIntervalRef.current) clearInterval(statsIntervalRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
{/* ── Controls ──────────────────────────────────────────── */}
|
||||
<Group>
|
||||
{!isRunning ? (
|
||||
<Button variant="filled" color="brand" onClick={startFeed} size="sm">
|
||||
▶ Start Feed (50 events/sec)
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="filled" color="error" onClick={stopFeed} size="sm">
|
||||
■ Stop Feed
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="light" color="info" onClick={() => setShowAll((s) => !s)} size="sm">
|
||||
{showAll ? `Show ${VISIBLE_ROWS} rows` : `Show all ${STOCK_COUNT} rows`}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{/* ── Stats Bar ─────────────────────────────────────────── */}
|
||||
<Group gap="md">
|
||||
<Badge color="brand" variant="light" size="lg">
|
||||
Grid renders: {renderCount.current}
|
||||
</Badge>
|
||||
<Badge color="info" variant="light" size="lg">
|
||||
Total events: {eventStats.total.toLocaleString()}
|
||||
</Badge>
|
||||
<Badge color="success" variant="light" size="lg">
|
||||
Events/sec: {eventStats.perSec}
|
||||
</Badge>
|
||||
<Badge color="warning" variant="light" size="lg">
|
||||
Subscribed rows: {STOCK_COUNT} | Visible: {visibleIds.length}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<Text size="xs" c="dimmed">
|
||||
Each row shows its own render count in the last column. Only rows receiving updates re-render.
|
||||
</Text>
|
||||
|
||||
{/* ── Data Grid ─────────────────────────────────────────── */}
|
||||
<div style={{ maxHeight: 500, overflow: 'auto', border: '1px solid var(--app-shell-border-color)' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
background: 'var(--mantine-color-body)',
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<th
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
textAlign: 'left',
|
||||
borderBottom: '2px solid var(--app-shell-border-color)',
|
||||
}}
|
||||
>
|
||||
Ticker
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
textAlign: 'right',
|
||||
borderBottom: '2px solid var(--app-shell-border-color)',
|
||||
}}
|
||||
>
|
||||
Price
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
textAlign: 'right',
|
||||
borderBottom: '2px solid var(--app-shell-border-color)',
|
||||
}}
|
||||
>
|
||||
Change
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
textAlign: 'right',
|
||||
borderBottom: '2px solid var(--app-shell-border-color)',
|
||||
}}
|
||||
>
|
||||
Volume
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
textAlign: 'right',
|
||||
borderBottom: '2px solid var(--app-shell-border-color)',
|
||||
}}
|
||||
>
|
||||
Renders
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{visibleIds.map((id) => (
|
||||
<StockRow key={id} stockId={id} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
import { publish } from '@repo/core-events';
|
||||
import { WS_EVENTS } from '../../../../../core/constants/events';
|
||||
|
||||
// ─── Stock Tickers ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Pool of realistic stock ticker symbols.
|
||||
* We generate 1000 unique IDs from these base tickers + numeric suffix.
|
||||
*/
|
||||
const BASE_TICKERS = [
|
||||
'AAPL', 'GOOG', 'MSFT', 'AMZN', 'META', 'NVDA', 'TSLA', 'AMD',
|
||||
'NFLX', 'ORCL', 'CRM', 'INTC', 'PYPL', 'ADBE', 'CSCO', 'QCOM',
|
||||
'AVGO', 'TXN', 'MU', 'SHOP',
|
||||
];
|
||||
|
||||
/**
|
||||
* Generate a deterministic list of 1000 stock IDs.
|
||||
*/
|
||||
export function generateStockIds(count: number = 1000): string[] {
|
||||
const ids: string[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
ids.push(`${BASE_TICKERS[i % BASE_TICKERS.length]}-${String(i).padStart(4, '0')}`);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
// ─── Mock WebSocket ─────────────────────────────────────────────
|
||||
|
||||
interface MockWebSocketConfig {
|
||||
/** All stock IDs to cycle through. */
|
||||
stockIds: string[];
|
||||
/** Interval in ms between events. @default 20 */
|
||||
intervalMs?: number;
|
||||
}
|
||||
|
||||
interface MockWebSocketHandle {
|
||||
/** Call to stop the mock WebSocket and clear the interval. */
|
||||
cleanup: () => void;
|
||||
/** Number of events emitted so far. */
|
||||
getEventCount: () => number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulates a WebSocket that pushes stock price updates at high frequency.
|
||||
*
|
||||
* Fires `WS:STOCK_UPDATE` every `intervalMs` (default 20ms = 50 updates/sec).
|
||||
* Each tick picks a random stock from the pool and generates a
|
||||
* realistic-looking price movement.
|
||||
*
|
||||
* Returns a handle with a `cleanup()` function to stop the simulation.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const ws = startMockWebSocket({ stockIds: ['AAPL-0001', ...] });
|
||||
* // Later:
|
||||
* ws.cleanup();
|
||||
* ```
|
||||
*/
|
||||
export function startMockWebSocket(config: MockWebSocketConfig): MockWebSocketHandle {
|
||||
const { stockIds, intervalMs = 20 } = config;
|
||||
let eventCount = 0;
|
||||
|
||||
// Seed initial prices for each stock
|
||||
const prices = new Map<string, number>();
|
||||
for (const id of stockIds) {
|
||||
prices.set(id, 100 + Math.random() * 400); // $100–$500
|
||||
}
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
// Pick a random stock
|
||||
const randomIndex = Math.floor(Math.random() * stockIds.length);
|
||||
const id = stockIds[randomIndex]!;
|
||||
const currentPrice = prices.get(id)!;
|
||||
|
||||
// Generate a small random price change (-2% to +2%)
|
||||
const changePercent = (Math.random() - 0.5) * 0.04;
|
||||
const change = +(currentPrice * changePercent).toFixed(2);
|
||||
const newPrice = +(currentPrice + change).toFixed(2);
|
||||
|
||||
// Update tracked price
|
||||
prices.set(id, newPrice);
|
||||
|
||||
// Publish to the event bus
|
||||
publish(WS_EVENTS.STOCK_UPDATE, {
|
||||
id,
|
||||
price: newPrice,
|
||||
change,
|
||||
volume: Math.floor(Math.random() * 100000),
|
||||
});
|
||||
|
||||
eventCount++;
|
||||
}, intervalMs);
|
||||
|
||||
return {
|
||||
cleanup: () => clearInterval(intervalId),
|
||||
getEventCount: () => eventCount,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { memo, useState, useRef } from 'react';
|
||||
import { useAppEvent } from '@repo/core-events';
|
||||
import { WS_EVENTS } from '../../../../../core/constants/events';
|
||||
|
||||
// ─── Props ──────────────────────────────────────────────────────
|
||||
|
||||
interface StockRowProps {
|
||||
stockId: string;
|
||||
}
|
||||
|
||||
// ─── Local State ────────────────────────────────────────────────
|
||||
|
||||
interface StockState {
|
||||
price: number;
|
||||
change: number;
|
||||
volume: number;
|
||||
lastUpdate: number;
|
||||
}
|
||||
|
||||
// ─── StockRow ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A single row in the stock grid.
|
||||
*
|
||||
* **Performance architecture:**
|
||||
* 1. Each StockRow subscribes to the global `WS:STOCK_UPDATE` event.
|
||||
* 2. The handler checks `payload.id === stockId` — if no match, it
|
||||
* does NOTHING (no setState, no re-render).
|
||||
* 3. Only the targeted row updates its own local state.
|
||||
* 4. `React.memo` prevents re-renders from parent prop changes.
|
||||
*
|
||||
* Result: At 50 events/sec across 1000 rows, only ~1 row re-renders
|
||||
* per tick. The parent `LiveStockGrid` NEVER re-renders.
|
||||
*/
|
||||
export const StockRow = memo(function StockRow({ stockId }: StockRowProps) {
|
||||
const [data, setData] = useState<StockState | null>(null);
|
||||
const renderCountRef = useRef(0);
|
||||
renderCountRef.current += 1;
|
||||
|
||||
useAppEvent(WS_EVENTS.STOCK_UPDATE, (payload) => {
|
||||
// ── Critical filter ──────────────────────────────────────────
|
||||
// This is the key performance optimization. Only the row whose
|
||||
// ID matches the event payload will call setState. All other
|
||||
// rows (999 out of 1000) do absolutely nothing.
|
||||
if (payload.id !== stockId) return;
|
||||
|
||||
setData({
|
||||
price: payload.price,
|
||||
change: payload.change,
|
||||
volume: payload.volume,
|
||||
lastUpdate: Date.now(),
|
||||
});
|
||||
});
|
||||
|
||||
const changeColor = data
|
||||
? data.change >= 0
|
||||
? '#40c057' // green
|
||||
: '#fa5252' // red
|
||||
: undefined;
|
||||
|
||||
const changeArrow = data
|
||||
? data.change >= 0
|
||||
? '▲'
|
||||
: '▼'
|
||||
: '';
|
||||
|
||||
return (
|
||||
<tr style={{ fontSize: 12, fontFamily: 'monospace' }}>
|
||||
<td style={{ padding: '2px 8px', fontWeight: 600 }}>{stockId}</td>
|
||||
<td style={{ padding: '2px 8px', textAlign: 'right' }}>
|
||||
{data ? `$${data.price.toFixed(2)}` : '—'}
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
padding: '2px 8px',
|
||||
textAlign: 'right',
|
||||
color: changeColor,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{data ? `${changeArrow} ${data.change >= 0 ? '+' : ''}${data.change.toFixed(2)}` : '—'}
|
||||
</td>
|
||||
<td style={{ padding: '2px 8px', textAlign: 'right' }}>
|
||||
{data ? data.volume.toLocaleString() : '—'}
|
||||
</td>
|
||||
<td style={{ padding: '2px 8px', textAlign: 'right', color: '#868e96' }}>
|
||||
{renderCountRef.current}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import BookingSample from './features/booking/presentation/BookingSample';
|
||||
import StorageSample from './features/storage/presentation/StorageSample';
|
||||
import I18nSample from './features/i18n/presentation/I18nSample';
|
||||
|
||||
export default function ExamplePage() {
|
||||
return (
|
||||
<div className="bg-amber-200">
|
||||
example
|
||||
<div className="p-8">
|
||||
<h1 className="text-2xl font-bold mb-4">Enterprise Web App</h1>
|
||||
<BookingSample />
|
||||
</div>
|
||||
<div className="p-8">
|
||||
<h1 className="text-2xl font-bold mb-4">Enterprise Web App</h1>
|
||||
<StorageSample />
|
||||
</div>
|
||||
<div className="p-8 bg-slate-900">
|
||||
<I18nSample />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
import { BaseRemoteDataServices } from '@repo/core-api/data-services';
|
||||
import type { ApiResponse } from '@repo/core-api/http-client';
|
||||
import { apiClient } from '../../../../../../../core/lib/api-client';
|
||||
import type { BookingEntity } from './booking.data-services';
|
||||
import type { BookingDTO } from './booking.transformer';
|
||||
import {
|
||||
AdvancedBookingTransformer,
|
||||
type AvailabilityChartRawData,
|
||||
type AvailabilityChartData,
|
||||
} from './advanced-booking.transformer';
|
||||
|
||||
// ─── Advanced Booking Data Services ─────────────────────────────
|
||||
|
||||
/**
|
||||
* Extended booking data services with custom methods for
|
||||
* advanced booking features beyond standard CRUD.
|
||||
*
|
||||
* Extends {@link BaseRemoteDataServices} directly (instead of using
|
||||
* `CommonRemoteDataServices`) to add domain-specific methods like
|
||||
* `getAvailabilityChart()`.
|
||||
*
|
||||
* Uses {@link AdvancedBookingTransformer} which provides:
|
||||
* - All standard DTO ↔ Entity mappings (inherited from BookingTransformer)
|
||||
* - Custom `transformAvailabilityChart()` for chart data
|
||||
* - Enhanced `transformGetManyResponse()` with status normalization
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Standard CRUD (inherited, with transformer)
|
||||
* const { data: bookings } = await advancedBookingServices.getMany();
|
||||
* const { data: booking } = await advancedBookingServices.getOne('42');
|
||||
*
|
||||
* // Custom method for chart data
|
||||
* const { data: chartData } = await advancedBookingServices.getAvailabilityChart({
|
||||
* startDate: '2026-07-01',
|
||||
* endDate: '2026-07-31',
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
class AdvancedBookingDataServices extends BaseRemoteDataServices<BookingEntity, BookingDTO> {
|
||||
/**
|
||||
* The concrete advanced transformer instance.
|
||||
*
|
||||
* Stored separately from the base `transformer` property
|
||||
* to access custom methods (like `transformAvailabilityChart`)
|
||||
* that aren't part of the `IDataTransformer` interface.
|
||||
*/
|
||||
private readonly advancedTransformer: AdvancedBookingTransformer;
|
||||
|
||||
constructor() {
|
||||
const advancedTransformer = new AdvancedBookingTransformer();
|
||||
|
||||
super(apiClient, {
|
||||
apiUrl: '/bookings',
|
||||
moduleKey: 'BOOKING',
|
||||
transformer: advancedTransformer,
|
||||
});
|
||||
|
||||
this.advancedTransformer = advancedTransformer;
|
||||
}
|
||||
|
||||
// ─── Custom Methods ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fetch the availability chart data for a given date range.
|
||||
*
|
||||
* Calls the `/bookings/availability-chart` endpoint and transforms
|
||||
* the raw API response into a UI-friendly chart format using
|
||||
* {@link AdvancedBookingTransformer.transformAvailabilityChart}.
|
||||
*
|
||||
* @param params - Date range parameters for the chart query
|
||||
* @param params.startDate - Start date (ISO format, e.g., '2026-07-01')
|
||||
* @param params.endDate - End date (ISO format, e.g., '2026-07-31')
|
||||
* @returns Transformed chart data ready for UI rendering
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const { data } = await advancedBookingServices.getAvailabilityChart({
|
||||
* startDate: '2026-07-01',
|
||||
* endDate: '2026-07-31',
|
||||
* });
|
||||
*
|
||||
* // data.dataPoints → Array of chart-ready data points
|
||||
* // data.summary → Aggregated metrics for the period
|
||||
* ```
|
||||
*/
|
||||
async getAvailabilityChart(params: {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
}): Promise<ApiResponse<AvailabilityChartData>> {
|
||||
const response = await this.customRequest<AvailabilityChartRawData>({
|
||||
url: '/bookings/availability-chart',
|
||||
method: 'GET',
|
||||
params: {
|
||||
start_date: params.startDate,
|
||||
end_date: params.endDate,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
data: this.advancedTransformer.transformAvailabilityChart(response.data),
|
||||
status: response.status,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Singleton Export ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Pre-configured advanced booking data services instance.
|
||||
*
|
||||
* Use this when you need both standard CRUD operations and
|
||||
* custom methods like `getAvailabilityChart()`.
|
||||
*
|
||||
* For standard CRUD-only usage, prefer `bookingServices` from
|
||||
* `booking.data-services.ts` instead.
|
||||
*/
|
||||
export const advancedBookingServices = new AdvancedBookingDataServices();
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
import { BookingTransformer } from './booking.transformer';
|
||||
import type { BookingDTO } from './booking.transformer';
|
||||
import type { BookingEntity } from './booking.data-services';
|
||||
|
||||
// ─── Advanced Types ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Raw availability chart data as returned by the API.
|
||||
*
|
||||
* The backend returns a flat structure with snake_case keys
|
||||
* and ISO date strings. This needs to be transformed into
|
||||
* a more UI-friendly shape for chart rendering.
|
||||
*/
|
||||
export interface AvailabilityChartRawData {
|
||||
dates: Array<{
|
||||
date_iso: string;
|
||||
available_rooms: number;
|
||||
total_rooms: number;
|
||||
occupancy_rate: number;
|
||||
revenue_per_room: number;
|
||||
}>;
|
||||
summary: {
|
||||
avg_occupancy_rate: number;
|
||||
total_revenue: number;
|
||||
period_start: string;
|
||||
period_end: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* UI-friendly availability chart data.
|
||||
*
|
||||
* Pre-computed for direct rendering in chart components
|
||||
* with camelCase fields, formatted labels, and derived metrics.
|
||||
*/
|
||||
export interface AvailabilityChartData {
|
||||
/** Data points ready for chart rendering. */
|
||||
dataPoints: Array<{
|
||||
/** Formatted date label (e.g., 'Mon, Jul 1'). */
|
||||
label: string;
|
||||
/** ISO date string for programmatic use. */
|
||||
dateISO: string;
|
||||
/** Number of rooms available. */
|
||||
availableRooms: number;
|
||||
/** Total room capacity. */
|
||||
totalRooms: number;
|
||||
/** Occupancy rate as a percentage (0-100). */
|
||||
occupancyRate: number;
|
||||
/** Revenue per available room. */
|
||||
revenuePerRoom: number;
|
||||
/** Whether the day is a high-demand day (>80% occupancy). */
|
||||
isHighDemand: boolean;
|
||||
}>;
|
||||
/** Aggregated summary metrics for the period. */
|
||||
summary: {
|
||||
averageOccupancy: number;
|
||||
totalRevenue: number;
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
/** Number of high-demand days in the period. */
|
||||
highDemandDays: number;
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Advanced Booking Transformer ───────────────────────────────
|
||||
|
||||
/**
|
||||
* Extended booking transformer with additional custom methods
|
||||
* for non-CRUD data transformations.
|
||||
*
|
||||
* Inherits all standard DTO ↔ Entity mapping from
|
||||
* {@link BookingTransformer} and adds domain-specific
|
||||
* transformations for advanced features like availability charts.
|
||||
*
|
||||
* **When to extend vs. create new:**
|
||||
* - Extend when the new transformer shares the same entity/DTO pair
|
||||
* and you need additional transformation methods
|
||||
* - Create a new transformer when the entity/DTO types are different
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const transformer = new AdvancedBookingTransformer();
|
||||
*
|
||||
* // Standard CRUD mapping (inherited)
|
||||
* const entity = transformer.transformToEntity(bookingDTO);
|
||||
*
|
||||
* // Custom chart transformation (new)
|
||||
* const chartData = transformer.transformAvailabilityChart(rawChartData);
|
||||
* ```
|
||||
*/
|
||||
export class AdvancedBookingTransformer extends BookingTransformer {
|
||||
/**
|
||||
* Transform raw availability chart data from the API into a
|
||||
* UI-friendly format for chart rendering.
|
||||
*
|
||||
* Performs the following transformations:
|
||||
* 1. Maps snake_case fields to camelCase
|
||||
* 2. Formats date strings into human-readable labels
|
||||
* 3. Computes derived `isHighDemand` flag (>80% occupancy)
|
||||
* 4. Aggregates `highDemandDays` count in the summary
|
||||
*
|
||||
* @param rawData - Raw chart data from the `/bookings/availability-chart` endpoint
|
||||
* @returns Transformed chart data ready for UI rendering
|
||||
*/
|
||||
transformAvailabilityChart(rawData: AvailabilityChartRawData): AvailabilityChartData {
|
||||
const HIGH_DEMAND_THRESHOLD = 80;
|
||||
|
||||
const dataPoints = rawData.dates.map((item) => {
|
||||
const date = new Date(item.date_iso);
|
||||
const isHighDemand = item.occupancy_rate > HIGH_DEMAND_THRESHOLD;
|
||||
|
||||
return {
|
||||
label: date.toLocaleDateString('en-US', {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}),
|
||||
dateISO: item.date_iso,
|
||||
availableRooms: item.available_rooms,
|
||||
totalRooms: item.total_rooms,
|
||||
occupancyRate: item.occupancy_rate,
|
||||
revenuePerRoom: item.revenue_per_room,
|
||||
isHighDemand,
|
||||
};
|
||||
});
|
||||
|
||||
const highDemandDays = dataPoints.filter((dp) => dp.isHighDemand).length;
|
||||
|
||||
return {
|
||||
dataPoints,
|
||||
summary: {
|
||||
averageOccupancy: rawData.summary.avg_occupancy_rate,
|
||||
totalRevenue: rawData.summary.total_revenue,
|
||||
periodStart: rawData.summary.period_start,
|
||||
periodEnd: rawData.summary.period_end,
|
||||
highDemandDays,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced getMany response that also normalizes status values.
|
||||
*
|
||||
* Demonstrates overriding an inherited hook to add
|
||||
* additional processing on top of the base transformation.
|
||||
*
|
||||
* @param dtos - Array of booking DTOs from the API
|
||||
* @returns Transformed entities with normalized status
|
||||
*/
|
||||
override transformGetManyResponse(dtos: BookingDTO[]): BookingEntity[] {
|
||||
return super.transformGetManyResponse(dtos).map((entity) => ({
|
||||
...entity,
|
||||
// Normalize 'cancelled' vs 'canceled' from different API versions
|
||||
status: entity.status === ('canceled' as BookingEntity['status'])
|
||||
? 'cancelled'
|
||||
: entity.status,
|
||||
}));
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
import { CommonRemoteDataServices } from '@repo/core-api/data-services';
|
||||
import type { BaseEntity } from '@repo/core-api/data-services';
|
||||
import { apiClient } from '../../../../../../../core/lib/api-client';
|
||||
import { BookingTransformer } from './booking.transformer';
|
||||
import type { BookingDTO } from './booking.transformer';
|
||||
|
||||
// ─── Domain Entity ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Booking domain entity.
|
||||
*
|
||||
* In a real module, this would be defined in the domain layer
|
||||
* (e.g., `features/booking/domain/entities.ts`) and imported here.
|
||||
*/
|
||||
export interface BookingEntity extends BaseEntity {
|
||||
bookingCode: string;
|
||||
customerName: string;
|
||||
checkInDate: string;
|
||||
checkOutDate: string;
|
||||
status: 'pending' | 'confirmed' | 'cancelled';
|
||||
totalAmount: number;
|
||||
}
|
||||
|
||||
// ─── Data Services Instance ─────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Booking data services — wired to the enterprise `apiClient`
|
||||
* with automatic DTO ↔ Entity transformation.
|
||||
*
|
||||
* All requests flow through the full interceptor chain:
|
||||
* Faro tracing → Bearer token injection → ApiError normalization.
|
||||
*
|
||||
* The injected {@link BookingTransformer} automatically:
|
||||
* - Maps snake_case API responses to camelCase entities on `getOne`/`getMany`
|
||||
* - Maps camelCase entity payloads to snake_case DTOs on `create`/`edit`
|
||||
* - Strips `id` from create payloads
|
||||
* - Computes `durationNights` on `getOne` responses
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const { data } = await bookingServices.getMany({ params: { page: 1 } });
|
||||
* // data is BookingEntity[] with camelCase fields
|
||||
*
|
||||
* const { data: booking } = await bookingServices.getOne('42');
|
||||
* // booking is BookingEntity with computed durationNights
|
||||
*
|
||||
* await bookingServices.create({ bookingCode: 'BK001', customerName: 'Alice', ... });
|
||||
* // Payload is automatically transformed to { booking_code: 'BK001', customer_name: 'Alice', ... }
|
||||
*
|
||||
* await bookingServices.confirmProcessTransaction('42');
|
||||
* ```
|
||||
*/
|
||||
export const bookingServices = new CommonRemoteDataServices<BookingEntity, BookingDTO>(apiClient, {
|
||||
apiUrl: '/bookings',
|
||||
moduleKey: 'BOOKING',
|
||||
transformer: new BookingTransformer(),
|
||||
});
|
||||
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
import { BaseDataTransformer } from '@repo/core-api/data-services';
|
||||
import type { BookingEntity } from './booking.data-services';
|
||||
|
||||
// ─── Booking DTO (API Response Shape) ───────────────────────────
|
||||
|
||||
/**
|
||||
* Raw booking data as returned by the API.
|
||||
*
|
||||
* Uses snake_case field names matching the backend's JSON serialization.
|
||||
* This DTO is never used directly in UI components — it is transformed
|
||||
* into a {@link BookingEntity} by the {@link BookingTransformer}.
|
||||
*/
|
||||
export interface BookingDTO {
|
||||
id?: string;
|
||||
booking_code: string;
|
||||
customer_name: string;
|
||||
check_in_date: string;
|
||||
check_out_date: string;
|
||||
status: 'pending' | 'confirmed' | 'cancelled';
|
||||
total_amount: number;
|
||||
}
|
||||
|
||||
// ─── Booking Transformer ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Transforms between the API's `BookingDTO` (snake_case) and
|
||||
* the frontend's `BookingEntity` (camelCase).
|
||||
*
|
||||
* Handles:
|
||||
* - Field name mapping (snake_case ↔ camelCase)
|
||||
* - Computed field derivation (e.g., `durationNights` on `getOne`)
|
||||
* - Payload sanitization (e.g., stripping `id` on create)
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const transformer = new BookingTransformer();
|
||||
*
|
||||
* // API response → Domain entity
|
||||
* const entity = transformer.transformToEntity({
|
||||
* id: '42',
|
||||
* booking_code: 'BK042',
|
||||
* customer_name: 'Alice',
|
||||
* check_in_date: '2026-07-01',
|
||||
* check_out_date: '2026-07-03',
|
||||
* status: 'confirmed',
|
||||
* total_amount: 500000,
|
||||
* });
|
||||
* // → { id: '42', bookingCode: 'BK042', customerName: 'Alice', ... }
|
||||
* ```
|
||||
*/
|
||||
export class BookingTransformer extends BaseDataTransformer<BookingEntity, BookingDTO> {
|
||||
/**
|
||||
* Map an API booking DTO to a frontend booking entity.
|
||||
*
|
||||
* @param dto - Raw booking data from the API
|
||||
* @returns Mapped booking entity with camelCase fields
|
||||
*/
|
||||
override transformToEntity(dto: BookingDTO): BookingEntity {
|
||||
return {
|
||||
id: dto.id,
|
||||
bookingCode: dto.booking_code,
|
||||
customerName: dto.customer_name,
|
||||
checkInDate: dto.check_in_date,
|
||||
checkOutDate: dto.check_out_date,
|
||||
status: dto.status,
|
||||
totalAmount: dto.total_amount,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a frontend booking entity to an API booking DTO.
|
||||
*
|
||||
* @param entity - Booking entity from the frontend
|
||||
* @returns Mapped booking DTO with snake_case fields
|
||||
*/
|
||||
override transformToDTO(entity: BookingEntity): BookingDTO {
|
||||
return {
|
||||
id: entity.id as string,
|
||||
booking_code: entity.bookingCode,
|
||||
customer_name: entity.customerName,
|
||||
check_in_date: entity.checkInDate,
|
||||
check_out_date: entity.checkOutDate,
|
||||
status: entity.status,
|
||||
total_amount: entity.totalAmount,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform a single booking response with computed fields.
|
||||
*
|
||||
* Adds `durationNights` as a derived convenience field
|
||||
* that is only relevant when viewing a single booking detail.
|
||||
*
|
||||
* @param dto - Raw booking DTO from the API
|
||||
* @returns Booking entity with computed fields
|
||||
*/
|
||||
override transformGetOneResponse(dto: BookingDTO): BookingEntity {
|
||||
const entity = this.transformToEntity(dto);
|
||||
const checkIn = new Date(dto.check_in_date);
|
||||
const checkOut = new Date(dto.check_out_date);
|
||||
const durationMs = checkOut.getTime() - checkIn.getTime();
|
||||
const durationNights = Math.max(0, Math.ceil(durationMs / (1000 * 60 * 60 * 24)));
|
||||
|
||||
return {
|
||||
...entity,
|
||||
// Attach computed field via type assertion since
|
||||
// durationNights is a view-layer convenience
|
||||
...(durationNights > 0 ? { durationNights } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip `id` from create payloads since the backend generates IDs.
|
||||
*
|
||||
* @param entity - Partial booking entity from the create form
|
||||
* @returns Sanitized DTO payload without `id`
|
||||
*/
|
||||
override transformCreatePayload(entity: Partial<BookingEntity>): Partial<BookingDTO> {
|
||||
const dto = this.transformToDTO(entity as BookingEntity);
|
||||
const { id: _, ...rest } = dto;
|
||||
return rest;
|
||||
}
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
import { useState } from 'react';
|
||||
import { bookingServices } from '../data/booking.data-services';
|
||||
import type { BookingEntity } from '../data/booking.data-services';
|
||||
import type { ApiResponse } from '@repo/core-api/http-client';
|
||||
import { ApiError } from '@repo/core-api/errors';
|
||||
|
||||
/**
|
||||
* Sample component demonstrating `@repo/core-api` integration
|
||||
* with the advanced TelemetryContext escape hatch.
|
||||
*
|
||||
* Pipeline: Faro auto-instrumentation → Bearer token → GET /bookings
|
||||
* + Custom span "booking.list.fetch" with enriched tags
|
||||
*/
|
||||
export default function BookingSample() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [result, setResult] = useState<ApiResponse<BookingEntity[]> | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleFetch = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
|
||||
try {
|
||||
const response = await bookingServices.getMany<BookingEntity[]>({
|
||||
params: { page: 1, limit: 20 },
|
||||
// ── Telemetry Escape Hatch ──────────────────────────────
|
||||
// This creates a custom OTel span named "booking.list.fetch",
|
||||
// attaches business tags, and pushes a Faro event on success.
|
||||
telemetryContext: {
|
||||
customSpanName: 'booking.list.fetch',
|
||||
tags: {
|
||||
'feature': 'booking',
|
||||
'ui.component': 'BookingSample',
|
||||
'ui.action': 'list_fetch',
|
||||
'page': 1,
|
||||
},
|
||||
pushEventOnSuccess: 'booking_list_loaded',
|
||||
},
|
||||
});
|
||||
setResult(response);
|
||||
console.log('[BookingSample] Response:', response);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(`[${err.code}] ${err.message} (HTTP ${err.status})`);
|
||||
console.error('[BookingSample] ApiError:', err.toJSON());
|
||||
} else {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24, fontFamily: 'monospace' }}>
|
||||
<h2>🧪 Booking Data Services — Integration Test</h2>
|
||||
<p style={{ color: '#888', fontSize: 14 }}>
|
||||
Pipeline: Faro + Custom Span "booking.list.fetch" → Bearer Token → GET /bookings
|
||||
</p>
|
||||
|
||||
<button
|
||||
onClick={handleFetch}
|
||||
disabled={loading}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
fontSize: 16,
|
||||
cursor: loading ? 'wait' : 'pointer',
|
||||
background: loading ? '#555' : '#4f46e5',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: 6,
|
||||
marginTop: 12,
|
||||
}}
|
||||
>
|
||||
{loading ? 'Fetching…' : 'Test Fetch Bookings'}
|
||||
</button>
|
||||
|
||||
{error && (
|
||||
<pre style={{ color: '#ef4444', marginTop: 16, whiteSpace: 'pre-wrap' }}>
|
||||
❌ {error}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<pre style={{ marginTop: 16, background: '#1e1e2e', color: '#a6e3a1', padding: 16, borderRadius: 8, overflow: 'auto' }}>
|
||||
{JSON.stringify(result, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"module_name": "Purchasing",
|
||||
"select_date": "Select Date",
|
||||
"header": {
|
||||
"title": "Transaction List",
|
||||
"subtitle": "Manage all your transactions here"
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"module_name": "Pembelanjaan",
|
||||
"select_date": "Pilih Tanggal",
|
||||
"header": {
|
||||
"title": "Daftar Transaksi",
|
||||
"subtitle": "Kelola semua transaksi Anda di sini"
|
||||
}
|
||||
}
|
||||
+347
@@ -0,0 +1,347 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslation, changeLanguage, applyTenantOverrides, i18n } from '@repo/core-i18n';
|
||||
import { secureIndexedDB, AppStorageKey } from '../../../../../../../core/storage/local';
|
||||
|
||||
// Decentralized languages imports
|
||||
import bookingId from '../languages/id/booking.json';
|
||||
import bookingEn from '../languages/en/booking.json';
|
||||
|
||||
// ─── Shared Styles ──────────────────────────────────────────────
|
||||
|
||||
const sectionStyle = {
|
||||
marginTop: 24,
|
||||
padding: 24,
|
||||
border: '1px solid #334155',
|
||||
borderRadius: 8,
|
||||
background: '#0f172a',
|
||||
};
|
||||
|
||||
const btnStyle = (color: string, isActive: boolean = false) => ({
|
||||
padding: '8px 16px',
|
||||
fontSize: 14,
|
||||
fontWeight: isActive ? 700 : 600,
|
||||
cursor: 'pointer' as const,
|
||||
background: color,
|
||||
color: '#fff',
|
||||
border: isActive ? '2px solid #fff' : '2px solid transparent',
|
||||
borderRadius: 6,
|
||||
marginRight: 8,
|
||||
});
|
||||
|
||||
// ─── Module-Level Flag ──────────────────────────────────────────
|
||||
// Bendera penanda statis agar kamus hanya dimuat satu kali
|
||||
let isBookingDictLoaded = false;
|
||||
|
||||
// ─── Component ──────────────────────────────────────────────────
|
||||
|
||||
export default function I18nSample() {
|
||||
// 1. Eksekusi SINKRONUS tepat sebelum render pertama
|
||||
if (!isBookingDictLoaded) {
|
||||
i18n.addResourceBundle('id', 'booking', bookingId, true, false);
|
||||
i18n.addResourceBundle('en', 'booking', bookingEn, true, false);
|
||||
isBookingDictLoaded = true;
|
||||
}
|
||||
|
||||
// 2. Sekarang useTranslation dijamin mendapat kamus yang sudah terisi penuh
|
||||
const { t } = useTranslation(['common', 'booking']);
|
||||
|
||||
// State untuk melacak bahasa aktif secara real-time
|
||||
const [activeLang, setActiveLang] = useState(i18n.language);
|
||||
const [syncStatus, setSyncStatus] = useState<string>('');
|
||||
const [activeTenant, setActiveTenant] = useState<string>('default');
|
||||
const [isFetchingConfig, setIsFetchingConfig] = useState(false);
|
||||
|
||||
// Dengarkan perubahan bahasa dari engine
|
||||
useEffect(() => {
|
||||
const handleLangChange = (lng: string) => setActiveLang(lng);
|
||||
i18n.on('languageChanged', handleLangChange);
|
||||
return () => {
|
||||
i18n.off('languageChanged', handleLangChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ─── Admin Panel State ──────────────────────────────────────────
|
||||
const [adminModuleName, setAdminModuleName] = useState('PENGELUARAN');
|
||||
const [adminHeaderTitle, setAdminHeaderTitle] = useState('Daftar Pengeluaran');
|
||||
const [dbPayloadStr, setDbPayloadStr] = useState<string>('No data in DB');
|
||||
|
||||
const MOCK_DB_KEY = AppStorageKey.MOCK_DB_COMPANY_A;
|
||||
|
||||
const loadDbPayload = useCallback(async () => {
|
||||
try {
|
||||
const data = await secureIndexedDB.getItem<any>(MOCK_DB_KEY);
|
||||
setDbPayloadStr(data ? JSON.stringify(data, null, 2) : 'No data in DB');
|
||||
setAdminHeaderTitle(data?.overrides?.header?.title || 'Daftar Pengeluaran');
|
||||
setAdminModuleName(data?.overrides?.module_name || 'PENGELUARAN');
|
||||
} catch (e) {
|
||||
setDbPayloadStr('Error reading DB');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadDbPayload();
|
||||
}, [loadDbPayload]);
|
||||
|
||||
const handleAdminSave = async () => {
|
||||
const payload = {
|
||||
namespace: 'booking',
|
||||
overrides: {
|
||||
module_name: adminModuleName,
|
||||
header: { title: adminHeaderTitle },
|
||||
},
|
||||
};
|
||||
await secureIndexedDB.setItem(MOCK_DB_KEY, payload);
|
||||
setSyncStatus('✅ Saved tenant config to IndexedDB!');
|
||||
await loadDbPayload();
|
||||
};
|
||||
|
||||
// ─── Mock API ───────────────────────────────────────────────────
|
||||
const mockFetchTenantConfig = async (companyId: string): Promise<any> => {
|
||||
if (companyId === 'company-a') {
|
||||
const data = await secureIndexedDB.getItem<any>(MOCK_DB_KEY);
|
||||
if (!data) {
|
||||
throw new Error('Company A config not found in DB. Please save via Admin Panel first.');
|
||||
}
|
||||
return data;
|
||||
} else if (companyId === 'company-b') {
|
||||
return {
|
||||
namespace: 'booking',
|
||||
overrides: {
|
||||
module_name: 'PROCUREMENT (B)',
|
||||
header: { title: 'Procurement List (B)' },
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error('Unknown company');
|
||||
};
|
||||
|
||||
// ─── Section A: Language Switcher ──────────────────────────────
|
||||
|
||||
const handleLanguageChange = async (newLng: string, shouldFail: boolean = false) => {
|
||||
setSyncStatus('Syncing with backend...');
|
||||
|
||||
try {
|
||||
await changeLanguage(newLng, async (lng, _prevLng) => {
|
||||
await new Promise((resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
if (shouldFail) {
|
||||
reject(new Error('Mock API 500: Failed to save preference'));
|
||||
} else {
|
||||
resolve(true);
|
||||
}
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
setSyncStatus(`✅ Successfully synced language '${lng}' to backend.`);
|
||||
});
|
||||
} catch (error) {
|
||||
setSyncStatus(`❌ Rollback triggered: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Section B: Tenant Overrides (Real-World Flow) ─────────────
|
||||
|
||||
const handleSimulateLogin = async (companyId: string) => {
|
||||
setIsFetchingConfig(true);
|
||||
setActiveTenant(companyId);
|
||||
|
||||
try {
|
||||
const config = await mockFetchTenantConfig(companyId);
|
||||
applyTenantOverrides(config.namespace, config.overrides, 'id');
|
||||
applyTenantOverrides(config.namespace, config.overrides, 'en');
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch config', err);
|
||||
} finally {
|
||||
setIsFetchingConfig(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetTenant = () => {
|
||||
i18n.addResourceBundle('id', 'booking', bookingId, true, true);
|
||||
i18n.addResourceBundle('en', 'booking', bookingEn, true, true);
|
||||
setActiveTenant('default');
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ fontFamily: 'sans-serif', color: '#f8fafc' }}>
|
||||
<h2 style={{ fontSize: 24, fontWeight: 'bold' }}>🌐 Enterprise i18n Demo</h2>
|
||||
<p style={{ color: '#94a3b8' }}>
|
||||
Current Active Language: <strong style={{ color: '#38bdf8' }}>{activeLang}</strong>
|
||||
</p>
|
||||
|
||||
{/* ─── Admin Panel ──────────────────────────────────────────── */}
|
||||
<div style={sectionStyle}>
|
||||
<h3 style={{ fontSize: 18, marginBottom: 16, color: '#fbbf24' }}>Admin Panel (Company A Config)</h3>
|
||||
<p style={{ fontSize: 14, color: '#94a3b8', marginBottom: 16 }}>
|
||||
Simulate a backend CMS. Save the vocabulary overrides to IndexedDB.
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginBottom: 16 }}>
|
||||
<label style={{ fontSize: 14 }}>
|
||||
<span style={{ display: 'inline-block', width: 120 }}>Module Name:</span>
|
||||
<input
|
||||
type="text"
|
||||
value={adminModuleName}
|
||||
onChange={(e) => setAdminModuleName(e.target.value)}
|
||||
style={{
|
||||
padding: 6,
|
||||
borderRadius: 4,
|
||||
background: '#1e293b',
|
||||
border: '1px solid #475569',
|
||||
color: '#fff',
|
||||
width: 250,
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ fontSize: 14 }}>
|
||||
<span style={{ display: 'inline-block', width: 120 }}>Header Title:</span>
|
||||
<input
|
||||
type="text"
|
||||
value={adminHeaderTitle}
|
||||
onChange={(e) => setAdminHeaderTitle(e.target.value)}
|
||||
style={{
|
||||
padding: 6,
|
||||
borderRadius: 4,
|
||||
background: '#1e293b',
|
||||
border: '1px solid #475569',
|
||||
color: '#fff',
|
||||
width: 250,
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button onClick={handleAdminSave} style={btnStyle('#d97706')}>
|
||||
Save to Database (IndexedDB)
|
||||
</button>
|
||||
|
||||
<div style={{ marginTop: 16, padding: 12, background: '#1e293b', borderRadius: 6 }}>
|
||||
<div style={{ fontSize: 12, color: '#94a3b8', marginBottom: 4 }}>Raw JSON in DB:</div>
|
||||
<pre style={{ margin: 0, fontSize: 12, color: '#a7f3d0' }}>
|
||||
<code>{dbPayloadStr}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── Section A ────────────────────────────────────────────── */}
|
||||
<div style={sectionStyle}>
|
||||
<h3 style={{ fontSize: 18, marginBottom: 16 }}>A. Language Switcher & Backend Sync</h3>
|
||||
<p style={{ fontSize: 14, color: '#94a3b8', marginBottom: 16 }}>
|
||||
Change the language. The callback simulates a 1-second backend API request.
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<button
|
||||
onClick={() => handleLanguageChange('id')}
|
||||
style={btnStyle(activeLang === 'id' ? '#1d4ed8' : '#0ea5e9', activeLang === 'id')}
|
||||
>
|
||||
ID (Lokal & Sync)
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleLanguageChange('en')}
|
||||
style={btnStyle(activeLang === 'en' ? '#1d4ed8' : '#0ea5e9', activeLang === 'en')}
|
||||
>
|
||||
EN (Lokal & Sync)
|
||||
</button>
|
||||
<button onClick={() => handleLanguageChange('en', true)} style={btnStyle('#dc2626')}>
|
||||
Force Error (Test Rollback)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{syncStatus && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 16,
|
||||
padding: 12,
|
||||
background: '#1e293b',
|
||||
borderRadius: 6,
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
{syncStatus}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* UI Result untuk Section A */}
|
||||
<div style={{ background: '#1e293b', padding: 16, borderRadius: 8, marginTop: 16 }}>
|
||||
<h4 style={{ color: '#cbd5e1', marginBottom: 12 }}>UI Result (Live Dictionary):</h4>
|
||||
<p style={{ margin: '4px 0', display: 'flex', alignItems: 'center' }}>
|
||||
<code style={{ color: '#94a3b8', width: 180, display: 'inline-block' }}>common:save</code>
|
||||
<strong style={{ fontSize: 16, color: '#10b981' }}>{t('common:save')}</strong>
|
||||
</p>
|
||||
<p style={{ margin: '4px 0', display: 'flex', alignItems: 'center' }}>
|
||||
<code style={{ color: '#94a3b8', width: 180, display: 'inline-block' }}>booking:select_date</code>
|
||||
<strong style={{ fontSize: 16, color: '#10b981' }}>{t('booking:select_date')}</strong>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── Section B ────────────────────────────────────────────── */}
|
||||
<div style={sectionStyle}>
|
||||
<h3 style={{ fontSize: 18, marginBottom: 16 }}>B. Dynamic Tenant Overrides (End-to-End)</h3>
|
||||
<p style={{ fontSize: 14, color: '#94a3b8', marginBottom: 16 }}>
|
||||
Simulates a user logging in. It fetches the config directly from IndexedDB (mock database) and applies the
|
||||
deep-merge override.
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 24 }}>
|
||||
<button
|
||||
onClick={resetTenant}
|
||||
style={btnStyle(activeTenant === 'default' ? '#16a34a' : '#475569', activeTenant === 'default')}
|
||||
>
|
||||
Default Company
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSimulateLogin('company-a')}
|
||||
style={btnStyle(activeTenant === 'company-a' ? '#16a34a' : '#475569', activeTenant === 'company-a')}
|
||||
disabled={isFetchingConfig}
|
||||
>
|
||||
Simulate Login as Company A
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSimulateLogin('company-b')}
|
||||
style={btnStyle(activeTenant === 'company-b' ? '#16a34a' : '#475569', activeTenant === 'company-b')}
|
||||
disabled={isFetchingConfig}
|
||||
>
|
||||
Simulate Login as Company B
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isFetchingConfig && (
|
||||
<div style={{ marginBottom: 16, color: '#fbbf24', fontSize: 14 }}>⏳ Fetching tenant config...</div>
|
||||
)}
|
||||
|
||||
{/* Display the localized strings */}
|
||||
<div style={{ background: '#1e293b', padding: 16, borderRadius: 8 }}>
|
||||
<h4 style={{ color: '#cbd5e1', marginBottom: 12 }}>UI Result (Tenant Overlay):</h4>
|
||||
<table style={{ width: '100%', textAlign: 'left', borderCollapse: 'collapse' }}>
|
||||
<tbody>
|
||||
<tr style={{ borderBottom: '1px solid #334155' }}>
|
||||
<th style={{ padding: 8, color: '#94a3b8' }}>Key</th>
|
||||
<th style={{ padding: 8, color: '#94a3b8' }}>Value</th>
|
||||
</tr>
|
||||
<tr style={{ borderBottom: '1px solid #334155' }}>
|
||||
<td style={{ padding: 8 }}>
|
||||
<code>booking:module_name</code>
|
||||
</td>
|
||||
<td style={{ padding: 8, fontWeight: 'bold' }}>{t('booking:module_name')}</td>
|
||||
</tr>
|
||||
<tr style={{ borderBottom: '1px solid #334155' }}>
|
||||
<td style={{ padding: 8 }}>
|
||||
<code>booking:header.title</code>
|
||||
</td>
|
||||
<td style={{ padding: 8, fontWeight: 'bold' }}>{t('booking:header.title')}</td>
|
||||
</tr>
|
||||
<tr style={{ borderBottom: '1px solid #334155' }}>
|
||||
<td style={{ padding: 8 }}>
|
||||
<code>booking:header.subtitle</code>
|
||||
</td>
|
||||
<td style={{ padding: 8, fontWeight: 'bold' }}>{t('booking:header.subtitle')}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+280
@@ -0,0 +1,280 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { secureStorage, secureIndexedDB, AppStorageKey } from '../../../../../../../core/storage/local';
|
||||
|
||||
// ─── Demo Data ──────────────────────────────────────────────────
|
||||
|
||||
interface DemoUser {
|
||||
id: string;
|
||||
name: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
interface DemoDraft {
|
||||
id: number;
|
||||
type: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
const DEMO_USER: DemoUser = {
|
||||
id: 'u-123',
|
||||
name: 'Firman Ramdhani',
|
||||
role: 'admin',
|
||||
};
|
||||
|
||||
const DEMO_DRAFT: DemoDraft = { id: 101, type: 'offline_draft', content: 'Draft data saved offline' };
|
||||
|
||||
const LS_KEY = AppStorageKey.USER_PROFILE; // Encrypted at rest (in ENCRYPTED_KEYS)
|
||||
const IDB_KEY = AppStorageKey.OFFLINE_DRAFT; // Plain key for IndexedDB demo
|
||||
|
||||
// ─── Shared Styles ──────────────────────────────────────────────
|
||||
|
||||
const btnStyle = (color: string) => ({
|
||||
padding: '8px 16px',
|
||||
fontSize: 14,
|
||||
fontWeight: 600 as const,
|
||||
cursor: 'pointer' as const,
|
||||
background: color,
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: 6,
|
||||
});
|
||||
|
||||
const preStyle = {
|
||||
marginTop: 16,
|
||||
background: '#1e1e2e',
|
||||
color: '#a6e3a1',
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
minHeight: 60,
|
||||
overflow: 'auto' as const,
|
||||
fontSize: 13,
|
||||
};
|
||||
|
||||
const logContainerStyle = {
|
||||
background: '#0f0f17',
|
||||
color: '#94a3b8',
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
maxHeight: 200,
|
||||
overflow: 'auto' as const,
|
||||
fontSize: 12,
|
||||
};
|
||||
|
||||
// ─── Reusable CRUD Button Row ───────────────────────────────────
|
||||
|
||||
interface CRUDAction {
|
||||
label: string;
|
||||
handler: () => void;
|
||||
color: string;
|
||||
}
|
||||
|
||||
function CRUDButtons({ actions }: { actions: CRUDAction[] }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
{actions.map(({ label, handler, color }) => (
|
||||
<button key={label} onClick={handler} style={btnStyle(color)}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Component ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Interactive demo for `@repo/core-storage`.
|
||||
*
|
||||
* Demonstrates the full CRUD lifecycle for BOTH storage backends:
|
||||
* - **localStorage** (encrypted via AES for sensitive keys)
|
||||
* - **IndexedDB** (Promise-wrapped, suitable for large payloads)
|
||||
*
|
||||
* Open the browser's DevTools:
|
||||
* - **Application → Local Storage** to see AES-encrypted payloads
|
||||
* - **Application → IndexedDB → app_db → kv_store** to see IDB entries
|
||||
*/
|
||||
export default function StorageSample() {
|
||||
const [lsResult, setLsResult] = useState<string>('(no data read yet)');
|
||||
const [idbResult, setIdbResult] = useState<string>('(no data read yet)');
|
||||
const [log, setLog] = useState<string[]>([]);
|
||||
|
||||
const pushLog = useCallback((msg: string) => {
|
||||
setLog((prev) => [...prev, `[${new Date().toLocaleTimeString()}] ${msg}`]);
|
||||
}, []);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// ── localStorage CRUD ─────────────────────────────────────────
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
const lsCreate = useCallback(async () => {
|
||||
await secureStorage.setItem(LS_KEY, DEMO_USER);
|
||||
pushLog(`[LS] CREATE → Stored encrypted: ${JSON.stringify(DEMO_USER)}`);
|
||||
}, [pushLog]);
|
||||
|
||||
const lsRead = useCallback(async () => {
|
||||
const result = await secureStorage.getItem<DemoUser>(LS_KEY);
|
||||
if (result) {
|
||||
setLsResult(JSON.stringify(result, null, 2));
|
||||
pushLog(`[LS] READ → Decrypted: ${JSON.stringify(result)}`);
|
||||
} else {
|
||||
setLsResult('(null — no data found)');
|
||||
pushLog('[LS] READ → null (key does not exist)');
|
||||
}
|
||||
}, [pushLog]);
|
||||
|
||||
const lsUpdate = useCallback(async () => {
|
||||
const existing = await secureStorage.getItem<DemoUser>(LS_KEY);
|
||||
if (!existing) {
|
||||
pushLog('[LS] UPDATE → Failed: key does not exist. Create first.');
|
||||
return;
|
||||
}
|
||||
const updated: DemoUser = { ...existing, role: 'superadmin', id: existing.id + 1 };
|
||||
await secureStorage.setItem(LS_KEY, updated);
|
||||
pushLog(`[LS] UPDATE → Re-encrypted: ${JSON.stringify(updated)}`);
|
||||
}, [pushLog]);
|
||||
|
||||
const lsDelete = useCallback(async () => {
|
||||
await secureStorage.removeItem(LS_KEY);
|
||||
setLsResult('(deleted)');
|
||||
pushLog(`[LS] DELETE → Removed key "${LS_KEY}"`);
|
||||
}, [pushLog]);
|
||||
|
||||
const lsClear = useCallback(async () => {
|
||||
await secureStorage.clear();
|
||||
setLsResult('(cleared)');
|
||||
pushLog('[LS] CLEAR → All localStorage keys removed');
|
||||
}, [pushLog]);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// ── IndexedDB CRUD ────────────────────────────────────────────
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
const idbCreate = useCallback(async () => {
|
||||
try {
|
||||
await secureIndexedDB.setItem(IDB_KEY, DEMO_DRAFT);
|
||||
pushLog(`[IDB] CREATE → Stored: ${JSON.stringify(DEMO_DRAFT)}`);
|
||||
} catch (err) {
|
||||
pushLog(`[IDB] CREATE → ERROR: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}, [pushLog]);
|
||||
|
||||
const idbRead = useCallback(async () => {
|
||||
try {
|
||||
const result = await secureIndexedDB.getItem<DemoDraft>(IDB_KEY);
|
||||
if (result) {
|
||||
setIdbResult(JSON.stringify(result, null, 2));
|
||||
pushLog(`[IDB] READ → Retrieved: ${JSON.stringify(result)}`);
|
||||
} else {
|
||||
setIdbResult('(null — no data found)');
|
||||
pushLog('[IDB] READ → null (key does not exist)');
|
||||
}
|
||||
} catch (err) {
|
||||
pushLog(`[IDB] READ → ERROR: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}, [pushLog]);
|
||||
|
||||
const idbUpdate = useCallback(async () => {
|
||||
try {
|
||||
const existing = await secureIndexedDB.getItem<DemoDraft>(IDB_KEY);
|
||||
if (!existing) {
|
||||
pushLog('[IDB] UPDATE → Failed: key does not exist. Create first.');
|
||||
return;
|
||||
}
|
||||
const updated: DemoDraft = {
|
||||
...existing,
|
||||
id: existing.id + 1,
|
||||
content: `Updated at ${new Date().toLocaleTimeString()}`,
|
||||
};
|
||||
await secureIndexedDB.setItem(IDB_KEY, updated);
|
||||
pushLog(`[IDB] UPDATE → Persisted: ${JSON.stringify(updated)}`);
|
||||
} catch (err) {
|
||||
pushLog(`[IDB] UPDATE → ERROR: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}, [pushLog]);
|
||||
|
||||
const idbDelete = useCallback(async () => {
|
||||
try {
|
||||
await secureIndexedDB.removeItem(IDB_KEY);
|
||||
setIdbResult('(deleted)');
|
||||
pushLog(`[IDB] DELETE → Removed key "${IDB_KEY}"`);
|
||||
} catch (err) {
|
||||
pushLog(`[IDB] DELETE → ERROR: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}, [pushLog]);
|
||||
|
||||
const idbClear = useCallback(async () => {
|
||||
try {
|
||||
await secureIndexedDB.clear();
|
||||
setIdbResult('(cleared)');
|
||||
pushLog('[IDB] CLEAR → All IndexedDB entries removed');
|
||||
} catch (err) {
|
||||
pushLog(`[IDB] CLEAR → ERROR: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}, [pushLog]);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// ── Render ────────────────────────────────────────────────────
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24, fontFamily: 'monospace' }}>
|
||||
<h2>🔐 @repo/core-storage — Dual Backend CRUD Demo</h2>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, marginTop: 16 }}>
|
||||
{/* ── Left: localStorage ─────────────────────────────────── */}
|
||||
<div>
|
||||
<h3 style={{ color: '#22c55e' }}>📦 localStorage (AES Encrypted)</h3>
|
||||
<p style={{ color: '#888', fontSize: 12, marginBottom: 12 }}>
|
||||
Key: <code>{LS_KEY}</code> — stored encrypted at rest
|
||||
<br />
|
||||
Verify: <strong>DevTools → Application → Local Storage</strong>
|
||||
</p>
|
||||
|
||||
<CRUDButtons
|
||||
actions={[
|
||||
{ label: '➕ Create', handler: lsCreate, color: '#22c55e' },
|
||||
{ label: '📖 Read', handler: lsRead, color: '#3b82f6' },
|
||||
{ label: '✏️ Update', handler: lsUpdate, color: '#f59e0b' },
|
||||
{ label: '🗑️ Delete', handler: lsDelete, color: '#ef4444' },
|
||||
{ label: '💣 Clear', handler: lsClear, color: '#6b7280' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<pre style={preStyle}>{lsResult}</pre>
|
||||
</div>
|
||||
|
||||
{/* ── Right: IndexedDB ───────────────────────────────────── */}
|
||||
<div>
|
||||
<h3 style={{ color: '#8b5cf6' }}>🗃️ IndexedDB (app_db / kv_store)</h3>
|
||||
<p style={{ color: '#888', fontSize: 12, marginBottom: 12 }}>
|
||||
Key: <code>{IDB_KEY}</code> — plain JSON (not in ENCRYPTED_KEYS)
|
||||
<br />
|
||||
Verify: <strong>DevTools → Application → IndexedDB → app_db</strong>
|
||||
</p>
|
||||
|
||||
<CRUDButtons
|
||||
actions={[
|
||||
{ label: '➕ Create', handler: idbCreate, color: '#8b5cf6' },
|
||||
{ label: '📖 Read', handler: idbRead, color: '#06b6d4' },
|
||||
{ label: '✏️ Update', handler: idbUpdate, color: '#f59e0b' },
|
||||
{ label: '🗑️ Delete', handler: idbDelete, color: '#ef4444' },
|
||||
{ label: '💣 Clear', handler: idbClear, color: '#6b7280' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<pre style={preStyle}>{idbResult}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Shared Action Log ────────────────────────────────────── */}
|
||||
<h3 style={{ marginTop: 24 }}>📋 Action Log</h3>
|
||||
<div style={logContainerStyle}>
|
||||
{log.length === 0 ? (
|
||||
<span style={{ color: '#475569' }}>(no actions yet)</span>
|
||||
) : (
|
||||
log.map((entry, i) => <div key={i}>{entry}</div>)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Stack, Container, Card, Title, Text } from '@repo/ui/components';
|
||||
import ExamplePage from './components/example/example.page';
|
||||
import EventsDemoPage from './components/events-demo';
|
||||
|
||||
export default function EventsPage() {
|
||||
return (
|
||||
<Container size="xl" m={0} p={0}>
|
||||
<Stack gap="xl">
|
||||
<Card withBorder shadow="sm" radius="md" p="md">
|
||||
<Title order={4} mb="md">Nested Showcase Example</Title>
|
||||
<Text mb="md">This is an example of a nested showcase component.</Text>
|
||||
<ExamplePage />
|
||||
</Card>
|
||||
<Card withBorder shadow="sm" radius="md" p="md">
|
||||
<EventsDemoPage />
|
||||
</Card>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user