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