diff --git a/docs/features/admin-ui-modernization.md b/docs/features/admin-ui-modernization.md new file mode 100644 index 0000000..9855411 --- /dev/null +++ b/docs/features/admin-ui-modernization.md @@ -0,0 +1,957 @@ +--- +title: Admin UI Modernization +created: 2026-01-22 +updated: 2026-01-22 +tags: + - feature + - vue + - admin + - ui + - refactor +status: planning +priority: high +--- + +# Admin UI Modernization + +> [!abstract] Summary +> Migrate the admin dashboard from **React 18 + MUI** to **Vue 3** for consistency with the machine UI, using PrimeVue for components and tRPC for type-safe API communication. + +## Quick Links + +- [[#Current State]] +- [[#Why Unify on Vue]] +- [[#Architecture]] +- [[#Migration Strategy]] +- [[#Implementation]] + +--- + +## Current State + +### Existing Tech Stack + +``` +packages/admin-ui/ +├── src/ +│ ├── pages/ # React page components +│ ├── components/ # Reusable React components +│ ├── hooks/ # Custom React hooks +│ └── ... +├── package.json # React 18, MUI, Apollo Client +└── vite.config.js +``` + +| Component | Current | Notes | +|-----------|---------|-------| +| Framework | React 18.3 | Functional components + hooks | +| UI Library | MUI v7.1 | Heavy bundle (~300kb) | +| State | Zustand | Lightweight | +| API Client | Apollo Client | GraphQL | +| Styling | Tailwind CSS v4 | Already modern | +| Build | Vite + SWC | Already modern | +| Charts | D3 | Keep | +| Tables | Material React Table | Replace | + +### Problems + +> [!warning] Issues with Current Setup + +1. **Two frameworks** - React (admin) + Vanilla JS (machine) = different mental models +2. **Heavy bundle** - MUI adds ~300kb to bundle +3. **GraphQL complexity** - Apollo Client is powerful but heavy +4. **Inconsistent patterns** - Different state management approaches +5. **Developer context switching** - Between React and future Vue machine UI + +#currentstate + +--- + +## Why Unify on Vue + +> [!decision] Single Framework Strategy + +### Benefits of Vue Everywhere + +| Aspect | Two Frameworks | Single Framework (Vue) | +|--------|---------------|------------------------| +| Learning curve | High | Lower | +| Code sharing | None | Components, composables | +| Hiring | React + Vue devs | Vue devs only | +| Maintenance | 2x patterns | 1x patterns | +| Bundle optimization | Separate | Shared chunks possible | + +### Vue 3 vs React 19 for Admin + +| Feature | Vue 3 | React 19 | +|---------|-------|----------| +| Bundle size | ~33kb | ~42kb | +| DevTools | Excellent | Excellent | +| TypeScript | First-class | First-class | +| Learning curve | Lower | Medium | +| Component syntax | SFC (cleaner) | JSX | +| State management | Pinia (simpler) | Context/Zustand | + +> [!tip] Strategic Alignment +> With machine UI moving to Vue 3, unifying admin UI reduces cognitive load and enables shared utilities. + +#vue #consistency + +--- + +## Architecture + +### Target Stack + +``` +┌─────────────────────────────────────────────────────────┐ +│ Admin Dashboard │ +├─────────────────────────────────────────────────────────┤ +│ Vue 3 + TypeScript │ +│ ┌─────────────────────────────────────────────────────┐│ +│ │ Pages (Vue Router) ││ +│ │ ├── DashboardPage.vue ││ +│ │ ├── TransactionsPage.vue ││ +│ │ ├── MachinesPage.vue ││ +│ │ ├── CustomersPage.vue ││ +│ │ ├── MembershipsPage.vue (new) ││ +│ │ └── SettingsPage.vue ││ +│ └─────────────────────────────────────────────────────┘│ +│ ┌─────────────────────────────────────────────────────┐│ +│ │ UI Components (PrimeVue) ││ +│ │ ├── DataTable, Charts, Forms ││ +│ │ └── Custom components ││ +│ └─────────────────────────────────────────────────────┘│ +│ ┌─────────────────────────────────────────────────────┐│ +│ │ State (Pinia) ││ +│ │ ├── useAuthStore ││ +│ │ ├── useMachinesStore ││ +│ │ └── useSettingsStore ││ +│ └─────────────────────────────────────────────────────┘│ +│ ┌─────────────────────────────────────────────────────┐│ +│ │ API Layer (tRPC Client) ││ +│ │ └── Type-safe server communication ││ +│ └─────────────────────────────────────────────────────┘│ +├─────────────────────────────────────────────────────────┤ +│ Tailwind CSS v4 (keep) │ +└─────────────────────────────────────────────────────────┘ +``` + +### Project Structure + +``` +packages/admin-ui/ +├── src/ +│ ├── main.ts # Entry point +│ ├── App.vue # Root component +│ ├── router/ +│ │ └── index.ts # Vue Router config +│ │ +│ ├── pages/ # Route-level components +│ │ ├── DashboardPage.vue +│ │ ├── transactions/ +│ │ │ ├── TransactionsPage.vue +│ │ │ └── TransactionDetailPage.vue +│ │ ├── machines/ +│ │ │ ├── MachinesPage.vue +│ │ │ └── MachineDetailPage.vue +│ │ ├── customers/ +│ │ │ ├── CustomersPage.vue +│ │ │ └── CustomerDetailPage.vue +│ │ ├── memberships/ +│ │ │ ├── MembershipsPage.vue +│ │ │ ├── MembershipDetailPage.vue +│ │ │ └── TiersConfigPage.vue +│ │ ├── settings/ +│ │ │ ├── SettingsPage.vue +│ │ │ ├── WalletSettingsPage.vue +│ │ │ └── LightningSettingsPage.vue +│ │ └── auth/ +│ │ ├── LoginPage.vue +│ │ └── SetupPasskeyPage.vue +│ │ +│ ├── components/ # Reusable components +│ │ ├── layout/ +│ │ │ ├── AppSidebar.vue +│ │ │ ├── AppHeader.vue +│ │ │ └── AppBreadcrumb.vue +│ │ ├── common/ +│ │ │ ├── DataTable.vue # Wrapper around PrimeVue +│ │ │ ├── StatusBadge.vue +│ │ │ ├── CryptoAmount.vue +│ │ │ ├── FiatAmount.vue +│ │ │ └── DateTimeDisplay.vue +│ │ ├── charts/ +│ │ │ ├── TransactionChart.vue +│ │ │ ├── VolumeChart.vue +│ │ │ └── MachineStatusChart.vue +│ │ ├── machines/ +│ │ │ ├── MachineCard.vue +│ │ │ ├── MachineStatusIndicator.vue +│ │ │ └── CassetteStatus.vue +│ │ └── memberships/ +│ │ ├── MembershipCard.vue +│ │ ├── TierBadge.vue +│ │ └── QRCodeGenerator.vue +│ │ +│ ├── stores/ # Pinia stores +│ │ ├── auth.ts +│ │ ├── machines.ts +│ │ ├── transactions.ts +│ │ ├── customers.ts +│ │ ├── memberships.ts +│ │ └── settings.ts +│ │ +│ ├── composables/ # Reusable logic +│ │ ├── useAuth.ts +│ │ ├── usePagination.ts +│ │ ├── useFilters.ts +│ │ ├── useExport.ts +│ │ └── useNotifications.ts +│ │ +│ ├── api/ # tRPC client +│ │ ├── client.ts +│ │ └── types.ts # Shared types from server +│ │ +│ ├── types/ # TypeScript types +│ │ ├── machine.ts +│ │ ├── transaction.ts +│ │ ├── customer.ts +│ │ └── membership.ts +│ │ +│ ├── utils/ # Utility functions +│ │ ├── formatters.ts +│ │ ├── validators.ts +│ │ └── constants.ts +│ │ +│ └── styles/ # Global styles +│ ├── main.css # Tailwind imports +│ └── primevue-theme.css # PrimeVue customization +│ +├── index.html +├── vite.config.ts +├── tsconfig.json +├── tailwind.config.ts +└── package.json +``` + +#architecture #structure + +--- + +## UI Component Library + +### PrimeVue vs Alternatives + +| Library | Bundle | Components | Vue 3 | Tailwind | +|---------|--------|------------|-------|----------| +| **PrimeVue** | Tree-shakeable | 90+ | Yes | Yes | +| Naive UI | ~80kb | 80+ | Yes | Manual | +| Vuetify 3 | ~300kb | 80+ | Yes | No | +| Element Plus | ~200kb | 70+ | Yes | No | +| Headless UI | ~10kb | 10 | Yes | Yes | + +> [!decision] PrimeVue +> - **Tree-shakeable** - Only import what you use +> - **Tailwind integration** - PrimeVue + Tailwind preset +> - **Rich data table** - Critical for admin dashboards +> - **Chart components** - Built-in Chart.js integration +> - **Accessibility** - WCAG compliant +> - **Unstyled mode** - Full control with Tailwind + +### PrimeVue Setup + +```typescript +// src/main.ts +import { createApp } from 'vue' +import PrimeVue from 'primevue/config' +import Aura from '@primeuix/themes/aura' +import ToastService from 'primevue/toastservice' +import ConfirmationService from 'primevue/confirmationservice' + +import App from './App.vue' +import router from './router' +import { pinia } from './stores' + +const app = createApp(App) + +app.use(PrimeVue, { + theme: { + preset: Aura, + options: { + darkModeSelector: '.dark', + cssLayer: { + name: 'primevue', + order: 'tailwind-base, primevue, tailwind-utilities', + }, + }, + }, +}) + +app.use(ToastService) +app.use(ConfirmationService) +app.use(router) +app.use(pinia) + +app.mount('#app') +``` + +### Component Examples + +#### Data Table Wrapper + +```vue + + + + +``` + +#### Transaction List Page + +```vue + + + + +``` + +#primevue #components + +--- + +## API Layer: tRPC + +### Why tRPC over GraphQL + +| Aspect | GraphQL (Apollo) | tRPC | +|--------|------------------|------| +| Bundle size | ~50kb | ~5kb | +| Type safety | Codegen required | Automatic | +| Learning curve | Higher | Lower | +| Caching | Built-in | TanStack Query | +| Boilerplate | Schema + resolvers | Just functions | + +> [!decision] tRPC for Admin API +> Since both server and admin-ui are TypeScript, tRPC provides end-to-end type safety without code generation. + +### tRPC Client Setup + +```typescript +// src/api/client.ts +import { createTRPCClient, httpBatchLink } from '@trpc/client' +import type { AppRouter } from '@lamassu/server/trpc' +import { useAuthStore } from '@/stores/auth' + +export const trpc = createTRPCClient({ + links: [ + httpBatchLink({ + url: `${import.meta.env.VITE_API_URL}/trpc`, + headers() { + const auth = useAuthStore() + return { + Authorization: auth.token ? `Bearer ${auth.token}` : '', + } + }, + }), + ], +}) +``` + +### Using tRPC in Stores + +```typescript +// src/stores/memberships.ts +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { trpc } from '@/api/client' +import type { Membership, CreateMembershipInput } from '@/types/membership' + +export const useMembershipsStore = defineStore('memberships', () => { + const memberships = ref([]) + const loading = ref(false) + const error = ref(null) + + async function fetchMemberships(filters?: { tierId?: string; search?: string }) { + loading.value = true + error.value = null + try { + // Fully typed! IDE knows exact return type + memberships.value = await trpc.membership.list.query(filters) + } catch (e) { + error.value = e instanceof Error ? e.message : 'Unknown error' + } finally { + loading.value = false + } + } + + async function createMembership(input: CreateMembershipInput) { + // TypeScript ensures input matches server expectations + const newMembership = await trpc.membership.create.mutate(input) + memberships.value.push(newMembership) + return newMembership + } + + async function deleteMembership(id: string) { + await trpc.membership.delete.mutate({ id }) + memberships.value = memberships.value.filter(m => m.id !== id) + } + + return { + memberships, + loading, + error, + fetchMemberships, + createMembership, + deleteMembership, + } +}) +``` + +### TanStack Query Integration (Optional) + +```typescript +// For more advanced caching/refetching +import { useQuery, useMutation, useQueryClient } from '@tanstack/vue-query' +import { trpc } from '@/api/client' + +export function useMemberships(filters?: { tierId?: string }) { + return useQuery({ + queryKey: ['memberships', filters], + queryFn: () => trpc.membership.list.query(filters), + }) +} + +export function useCreateMembership() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (input: CreateMembershipInput) => + trpc.membership.create.mutate(input), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['memberships'] }) + }, + }) +} +``` + +#trpc #api + +--- + +## Authentication + +### Passkey-First Auth Flow + +```vue + + + + +``` + +#auth #passkey + +--- + +## Shared Code with Machine UI + +### Shared Package Structure + +``` +packages/ +├── admin-ui/ # Vue 3 admin dashboard +├── machine-ui/ # Vue 3 kiosk UI (moved from lamassu-machine) +└── ui-shared/ # Shared Vue components & utilities + ├── src/ + │ ├── components/ + │ │ ├── QRCode.vue + │ │ ├── CryptoIcon.vue + │ │ └── LoadingSpinner.vue + │ ├── composables/ + │ │ ├── useCrypto.ts + │ │ ├── useFormatters.ts + │ │ └── useValidators.ts + │ ├── types/ + │ │ ├── coin.ts + │ │ ├── transaction.ts + │ │ └── membership.ts + │ └── index.ts + └── package.json +``` + +### Shared Composables + +```typescript +// packages/ui-shared/src/composables/useFormatters.ts +import { computed } from 'vue' + +export function useFormatters(locale = 'en-US') { + const formatFiat = (amount: number, currency: string) => { + return new Intl.NumberFormat(locale, { + style: 'currency', + currency, + }).format(amount) + } + + const formatCrypto = (sats: number, coin: string) => { + if (coin === 'BTC') { + return `${(sats / 100_000_000).toFixed(8)} BTC` + } + return `${sats} sats` + } + + const formatDate = (date: Date | string) => { + return new Intl.DateTimeFormat(locale, { + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(date)) + } + + return { + formatFiat, + formatCrypto, + formatDate, + } +} +``` + +```typescript +// Usage in both admin-ui and machine-ui +import { useFormatters } from '@lamassu/ui-shared' + +const { formatFiat, formatCrypto } = useFormatters() +``` + +#shared #monorepo + +--- + +## Migration Strategy + +### Phase 1: Setup & Infrastructure + +> [!todo] Phase 1 Tasks + +- [ ] Create new Vue 3 project in `packages/admin-ui-v2/` +- [ ] Set up Vite + Vue + TypeScript +- [ ] Configure PrimeVue with Tailwind +- [ ] Set up Vue Router with auth guards +- [ ] Set up Pinia stores +- [ ] Configure tRPC client +- [ ] Create layout components (Sidebar, Header) + +### Phase 2: Core Pages Migration + +> [!todo] Phase 2 Tasks + +Migrate pages in order of complexity: + +1. [ ] Login / Auth pages +2. [ ] Dashboard (overview) +3. [ ] Machines list & detail +4. [ ] Transactions list & detail +5. [ ] Customers list & detail +6. [ ] Settings pages + +### Phase 3: New Features + +> [!todo] Phase 3 Tasks + +- [ ] Memberships management (new) +- [ ] Discount tiers configuration (new) +- [ ] Lightning/LNbits settings (new) +- [ ] Enhanced analytics dashboard + +### Phase 4: Polish & Cutover + +> [!todo] Phase 4 Tasks + +- [ ] Dark mode support +- [ ] Responsive design review +- [ ] Accessibility audit +- [ ] Performance optimization +- [ ] E2E tests with Playwright +- [ ] Remove old React admin-ui +- [ ] Update deployment configs + +#migration #phases + +--- + +## Build & Deployment + +### Vite Config + +```typescript +// packages/admin-ui/vite.config.ts +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import { resolve } from 'path' +import Components from 'unplugin-vue-components/vite' +import { PrimeVueResolver } from '@primevue/auto-import-resolver' + +export default defineConfig({ + plugins: [ + vue(), + Components({ + resolvers: [PrimeVueResolver()], + }), + ], + + resolve: { + alias: { + '@': resolve(__dirname, 'src'), + }, + }, + + build: { + target: 'es2022', + outDir: 'dist', + sourcemap: true, + rollupOptions: { + output: { + manualChunks: { + vue: ['vue', 'vue-router', 'pinia'], + primevue: ['primevue'], + charts: ['chart.js', 'vue-chartjs'], + }, + }, + }, + }, + + server: { + port: 3001, + proxy: { + '/api': { + target: 'http://localhost:3000', + changeOrigin: true, + }, + '/trpc': { + target: 'http://localhost:3000', + changeOrigin: true, + }, + }, + }, +}) +``` + +### Package.json + +```json +{ + "name": "@lamassu/admin-ui", + "version": "2.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc --noEmit && vite build", + "preview": "vite preview", + "test": "vitest", + "test:e2e": "playwright test", + "lint": "eslint src --ext .vue,.ts --fix", + "typecheck": "vue-tsc --noEmit" + }, + "dependencies": { + "vue": "^3.5.0", + "vue-router": "^4.4.0", + "pinia": "^2.2.0", + "@primevue/themes": "^4.2.0", + "primevue": "^4.2.0", + "primeicons": "^7.0.0", + "@trpc/client": "^11.0.0", + "@tanstack/vue-query": "^5.60.0", + "@vueuse/core": "^11.0.0", + "@simplewebauthn/browser": "^10.0.0", + "chart.js": "^4.4.0", + "vue-chartjs": "^5.3.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.1.0", + "vite": "^6.0.0", + "typescript": "^5.6.0", + "vue-tsc": "^2.1.0", + "vitest": "^2.1.0", + "@vue/test-utils": "^2.4.0", + "@playwright/test": "^1.48.0", + "tailwindcss": "^4.0.0", + "autoprefixer": "^10.4.0", + "eslint": "^9.14.0", + "eslint-plugin-vue": "^9.30.0", + "unplugin-vue-components": "^0.27.0", + "@primevue/auto-import-resolver": "^4.2.0" + } +} +``` + +#build #deployment + +--- + +## Comparison Summary + +| Aspect | Before (React) | After (Vue 3) | +|--------|---------------|---------------| +| Framework | React 18 | Vue 3 | +| UI Library | MUI (~300kb) | PrimeVue (tree-shake) | +| State | Zustand | Pinia | +| API | Apollo GraphQL | tRPC | +| Styling | Tailwind | Tailwind (keep) | +| Build | Vite | Vite (keep) | +| Types | TypeScript | TypeScript (keep) | +| Machine UI | Different (Vanilla) | Same (Vue 3) | + +### Bundle Size Targets + +| Chunk | Before | After | +|-------|--------|-------| +| Framework | ~42kb | ~33kb | +| UI Library | ~300kb | ~80kb* | +| API Client | ~50kb | ~5kb | +| **Total** | **~400kb** | **~120kb** | + +*PrimeVue with tree-shaking, only used components + +#comparison #summary + +--- + +## Related Documents + +- [[machine-ui-modernization]] - Kiosk UI Vue migration +- [[modernization-plan]] - Overall modernization roadmap +- [[membership-lightning-integration]] - New membership feature +- [[lnbits-integration]] - Lightning backend