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:
Firman Ramdhani
2026-07-23 17:26:41 +07:00
parent e99aeb6def
commit 980952252d
65 changed files with 5749 additions and 9 deletions
@@ -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>
);
}
@@ -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();
@@ -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,
}));
}
}
@@ -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(),
});
@@ -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;
}
}
@@ -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 &quot;booking.list.fetch&quot; β†’ 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>
);
}
@@ -0,0 +1,8 @@
{
"module_name": "Purchasing",
"select_date": "Select Date",
"header": {
"title": "Transaction List",
"subtitle": "Manage all your transactions here"
}
}
@@ -0,0 +1,8 @@
{
"module_name": "Pembelanjaan",
"select_date": "Pilih Tanggal",
"header": {
"title": "Daftar Transaksi",
"subtitle": "Kelola semua transaksi Anda di sini"
}
}
@@ -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>
);
}
@@ -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>
);
}