Add Vue 3 migration plan for admin UI
Migrate from React 18 + MUI to Vue 3 for consistency with machine UI: - PrimeVue for components (tree-shakeable vs MUI's 300kb) - tRPC replacing Apollo GraphQL (~5kb vs ~50kb) - Pinia for state (same as machine UI) - Shared ui-shared package for common components Benefits: - Single framework across all UIs - ~70% bundle size reduction (400kb → 120kb) - End-to-end type safety with tRPC - Shared composables between admin and machine Includes: - Complete project structure - PrimeVue integration with Tailwind - tRPC client setup - Passkey authentication flow - 4-phase migration strategy Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
775c847c23
commit
efddb0db5f
1 changed files with 957 additions and 0 deletions
957
docs/features/admin-ui-modernization.md
Normal file
957
docs/features/admin-ui-modernization.md
Normal file
|
|
@ -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
|
||||
<!-- src/components/common/DataTable.vue -->
|
||||
<script setup lang="ts" generic="T">
|
||||
import DataTable from 'primevue/datatable'
|
||||
import Column from 'primevue/column'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
interface Props {
|
||||
data: T[]
|
||||
columns: ColumnDef<T>[]
|
||||
loading?: boolean
|
||||
paginator?: boolean
|
||||
rows?: number
|
||||
globalFilter?: boolean
|
||||
}
|
||||
|
||||
interface ColumnDef<T> {
|
||||
field: keyof T
|
||||
header: string
|
||||
sortable?: boolean
|
||||
filterable?: boolean
|
||||
body?: (row: T) => VNode
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
loading: false,
|
||||
paginator: true,
|
||||
rows: 20,
|
||||
globalFilter: true,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
rowClick: [row: T]
|
||||
rowSelect: [rows: T[]]
|
||||
}>()
|
||||
|
||||
const filters = ref({
|
||||
global: { value: null, matchMode: 'contains' },
|
||||
})
|
||||
|
||||
const selectedRows = ref<T[]>([])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card">
|
||||
<div v-if="globalFilter" class="flex justify-end mb-4">
|
||||
<span class="p-input-icon-left">
|
||||
<i class="pi pi-search" />
|
||||
<InputText
|
||||
v-model="filters.global.value"
|
||||
placeholder="Search..."
|
||||
class="w-64"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
:value="data"
|
||||
:loading="loading"
|
||||
:paginator="paginator"
|
||||
:rows="rows"
|
||||
:filters="filters"
|
||||
v-model:selection="selectedRows"
|
||||
dataKey="id"
|
||||
stripedRows
|
||||
showGridlines
|
||||
class="p-datatable-sm"
|
||||
@row-click="(e) => emit('rowClick', e.data)"
|
||||
>
|
||||
<Column
|
||||
v-for="col in columns"
|
||||
:key="String(col.field)"
|
||||
:field="String(col.field)"
|
||||
:header="col.header"
|
||||
:sortable="col.sortable"
|
||||
>
|
||||
<template v-if="col.body" #body="{ data: row }">
|
||||
<component :is="col.body(row)" />
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
#### Transaction List Page
|
||||
|
||||
```vue
|
||||
<!-- src/pages/transactions/TransactionsPage.vue -->
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useTransactionsStore } from '@/stores/transactions'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import DataTable from '@/components/common/DataTable.vue'
|
||||
import StatusBadge from '@/components/common/StatusBadge.vue'
|
||||
import CryptoAmount from '@/components/common/CryptoAmount.vue'
|
||||
import FiatAmount from '@/components/common/FiatAmount.vue'
|
||||
import DateTimeDisplay from '@/components/common/DateTimeDisplay.vue'
|
||||
|
||||
const store = useTransactionsStore()
|
||||
const { transactions, loading } = storeToRefs(store)
|
||||
|
||||
const columns = [
|
||||
{ field: 'id', header: 'ID', sortable: true },
|
||||
{
|
||||
field: 'status',
|
||||
header: 'Status',
|
||||
sortable: true,
|
||||
body: (row) => h(StatusBadge, { status: row.status }),
|
||||
},
|
||||
{ field: 'direction', header: 'Type', sortable: true },
|
||||
{
|
||||
field: 'fiatAmount',
|
||||
header: 'Fiat',
|
||||
sortable: true,
|
||||
body: (row) => h(FiatAmount, { amount: row.fiatAmount, currency: row.fiatCode }),
|
||||
},
|
||||
{
|
||||
field: 'cryptoAmount',
|
||||
header: 'Crypto',
|
||||
sortable: true,
|
||||
body: (row) => h(CryptoAmount, { amount: row.cryptoAmount, coin: row.cryptoCode }),
|
||||
},
|
||||
{
|
||||
field: 'created',
|
||||
header: 'Date',
|
||||
sortable: true,
|
||||
body: (row) => h(DateTimeDisplay, { date: row.created }),
|
||||
},
|
||||
]
|
||||
|
||||
function handleRowClick(transaction: Transaction) {
|
||||
router.push(`/transactions/${transaction.id}`)
|
||||
}
|
||||
|
||||
// Load on mount
|
||||
onMounted(() => store.fetchTransactions())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold">Transactions</h1>
|
||||
<div class="flex gap-2">
|
||||
<Button label="Export CSV" icon="pi pi-download" outlined />
|
||||
<Button label="Refresh" icon="pi pi-refresh" @click="store.fetchTransactions" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
:data="transactions"
|
||||
:columns="columns"
|
||||
:loading="loading"
|
||||
@row-click="handleRowClick"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
#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<AppRouter>({
|
||||
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<Membership[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(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
|
||||
<!-- src/pages/auth/LoginPage.vue -->
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { startAuthentication } from '@simplewebauthn/browser'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const email = ref('')
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function handlePasskeyLogin() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
// Get challenge from server
|
||||
const options = await trpc.auth.getPasskeyOptions.query({ email: email.value })
|
||||
|
||||
// Trigger browser passkey UI
|
||||
const credential = await startAuthentication(options)
|
||||
|
||||
// Verify with server
|
||||
const result = await trpc.auth.verifyPasskey.mutate({
|
||||
email: email.value,
|
||||
credential,
|
||||
})
|
||||
|
||||
auth.setToken(result.token)
|
||||
auth.setUser(result.user)
|
||||
|
||||
router.push('/dashboard')
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Authentication failed'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
||||
<Card class="w-full max-w-md">
|
||||
<template #title>
|
||||
<div class="text-center">
|
||||
<img src="/logo.svg" alt="Lamassu" class="h-12 mx-auto mb-4" />
|
||||
<h1 class="text-2xl font-bold">Admin Login</h1>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium mb-1">Email</label>
|
||||
<InputText
|
||||
id="email"
|
||||
v-model="email"
|
||||
type="email"
|
||||
class="w-full"
|
||||
placeholder="admin@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Message v-if="error" severity="error" :closable="false">
|
||||
{{ error }}
|
||||
</Message>
|
||||
|
||||
<Button
|
||||
label="Sign in with Passkey"
|
||||
icon="pi pi-key"
|
||||
class="w-full"
|
||||
:loading="loading"
|
||||
@click="handlePasskeyLogin"
|
||||
/>
|
||||
|
||||
<Divider align="center">or</Divider>
|
||||
|
||||
<Button
|
||||
label="Sign in with Password"
|
||||
icon="pi pi-lock"
|
||||
class="w-full"
|
||||
severity="secondary"
|
||||
outlined
|
||||
@click="router.push('/auth/password')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
#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
|
||||
Loading…
Add table
Add a link
Reference in a new issue