Add Vue 3 UI modernization plan for lamassu-machine
Replaces legacy vanilla JS + jQuery with modern stack: - Vue 3 with Composition API - TypeScript throughout - Pinia for state management - Vite for building - Vue I18n for localization Includes: - Complete project structure - Core component examples (App.vue, stores, composables) - WebSocket integration pattern - i18n strategy preserving existing translations - 4-phase migration plan - Testing patterns with Vitest - Performance targets (<120kb total bundle) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
a5e6b32c24
commit
775c847c23
1 changed files with 966 additions and 0 deletions
966
docs/features/machine-ui-modernization.md
Normal file
966
docs/features/machine-ui-modernization.md
Normal file
|
|
@ -0,0 +1,966 @@
|
|||
---
|
||||
title: Machine UI Modernization
|
||||
created: 2026-01-22
|
||||
updated: 2026-01-22
|
||||
tags:
|
||||
- feature
|
||||
- vue
|
||||
- ui
|
||||
- lamassu-machine
|
||||
- refactor
|
||||
status: planning
|
||||
priority: high
|
||||
---
|
||||
|
||||
# Machine UI Modernization
|
||||
|
||||
> [!abstract] Summary
|
||||
> Replace the legacy vanilla JavaScript + jQuery UI in `lamassu-machine` with a modern **Vue 3** application using TypeScript, Pinia for state management, and Vite for building.
|
||||
|
||||
## Quick Links
|
||||
|
||||
- [[#Current State]]
|
||||
- [[#Why Vue]]
|
||||
- [[#Architecture]]
|
||||
- [[#Migration Strategy]]
|
||||
- [[#Implementation]]
|
||||
|
||||
---
|
||||
|
||||
## Current State
|
||||
|
||||
### Problems with Existing UI
|
||||
|
||||
```javascript
|
||||
// Current: lamassu-machine/ui/src/app.js (80KB single file)
|
||||
/* globals $, URLSearchParams, WebSocket, Keyboard, BigNumber, ... */
|
||||
'use strict'
|
||||
|
||||
var fiatCode = null
|
||||
var locale = null
|
||||
var currentState
|
||||
var websocket = null
|
||||
// ... 50+ global variables
|
||||
```
|
||||
|
||||
> [!warning] Technical Debt
|
||||
> - **Single 80KB file** with all logic
|
||||
> - **50+ global variables** for state
|
||||
> - **jQuery dependency** for DOM manipulation
|
||||
> - **No type safety** - runtime errors only
|
||||
> - **No component structure** - hard to test/maintain
|
||||
> - **Babel 6** (2016) for transpilation
|
||||
> - **Manual DOM updates** - error-prone
|
||||
|
||||
### Current Tech Stack
|
||||
|
||||
| Component | Current | Issues |
|
||||
|-----------|---------|--------|
|
||||
| Framework | Vanilla JS | No structure |
|
||||
| DOM | jQuery | Dated, heavy |
|
||||
| State | Global vars | Unmaintainable |
|
||||
| Build | Babel 6 | Outdated |
|
||||
| Styles | SCSS | OK, keep |
|
||||
| i18n | Jed (gettext) | Works, but heavy |
|
||||
|
||||
#currentstate #technicaldebt
|
||||
|
||||
---
|
||||
|
||||
## Why Vue
|
||||
|
||||
> [!decision] Vue 3 over React/Svelte/Solid
|
||||
|
||||
| Framework | Bundle Size | Learning Curve | Kiosk Fit |
|
||||
|-----------|-------------|----------------|-----------|
|
||||
| **Vue 3** | ~33kb | Low | Excellent |
|
||||
| React 19 | ~42kb | Medium | Good |
|
||||
| Svelte 5 | ~2kb | Low | Excellent |
|
||||
| Solid | ~7kb | Medium | Good |
|
||||
|
||||
### Vue Advantages for Kiosk
|
||||
|
||||
1. **Single-File Components (SFC)**
|
||||
- HTML, CSS, JS in one file
|
||||
- Natural for UI-focused development
|
||||
- Easy to understand screen-by-screen
|
||||
|
||||
2. **Composition API**
|
||||
- TypeScript-first design
|
||||
- Reusable composables for hardware
|
||||
- Better than Options API for complex state
|
||||
|
||||
3. **Progressive Adoption**
|
||||
- Can migrate screen-by-screen
|
||||
- Works alongside existing code during migration
|
||||
|
||||
4. **Smaller Bundle**
|
||||
- Critical for kiosk boot time
|
||||
- Tree-shakeable
|
||||
|
||||
5. **Vue Ecosystem**
|
||||
- **Pinia** - Type-safe state management
|
||||
- **VueUse** - Composables for common tasks
|
||||
- **Vue I18n** - Internationalization
|
||||
|
||||
> [!note] Why Not Svelte?
|
||||
> Svelte has the smallest bundle, but Vue has:
|
||||
> - Larger ecosystem for i18n, forms, etc.
|
||||
> - More developers familiar with it
|
||||
> - Better tooling maturity
|
||||
|
||||
#vue #framework
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Target Stack
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Tauri 2.x Shell │
|
||||
│ (Rust core, WebView for UI) │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ Vue 3 Application │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ Screens (Vue Components) │ │
|
||||
│ │ ├── IdleScreen.vue │ │
|
||||
│ │ ├── ChooseCoinScreen.vue │ │
|
||||
│ │ ├── InsertBillsScreen.vue │ │
|
||||
│ │ └── ... │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ State (Pinia Stores) │ │
|
||||
│ │ ├── useTransactionStore │ │
|
||||
│ │ ├── useMachineStore │ │
|
||||
│ │ └── useUIStore │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ Composables │ │
|
||||
│ │ ├── useWebSocket │ │
|
||||
│ │ ├── useKeyboard │ │
|
||||
│ │ └── useQRScanner │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ Hardware Bridge │
|
||||
│ (WebSocket ↔ brain.js state machine) │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
lamassu-machine/
|
||||
├── ui/
|
||||
│ ├── src/
|
||||
│ │ ├── main.ts # Entry point
|
||||
│ │ ├── App.vue # Root component
|
||||
│ │ ├── router.ts # Screen routing
|
||||
│ │ │
|
||||
│ │ ├── screens/ # Full-screen views
|
||||
│ │ │ ├── IdleScreen.vue
|
||||
│ │ │ ├── ChooseCoinScreen.vue
|
||||
│ │ │ ├── ChooseLanguageScreen.vue
|
||||
│ │ │ ├── ScanAddressScreen.vue
|
||||
│ │ │ ├── InsertBillsScreen.vue
|
||||
│ │ │ ├── SendingCoinsScreen.vue
|
||||
│ │ │ ├── MembershipPromptScreen.vue
|
||||
│ │ │ ├── MembershipScanScreen.vue
|
||||
│ │ │ └── ...
|
||||
│ │ │
|
||||
│ │ ├── components/ # Reusable components
|
||||
│ │ │ ├── common/
|
||||
│ │ │ │ ├── BaseButton.vue
|
||||
│ │ │ │ ├── QRCode.vue
|
||||
│ │ │ │ ├── LoadingSpinner.vue
|
||||
│ │ │ │ └── LanguageSelector.vue
|
||||
│ │ │ ├── keyboard/
|
||||
│ │ │ │ ├── VirtualKeyboard.vue
|
||||
│ │ │ │ └── Keypad.vue
|
||||
│ │ │ └── transaction/
|
||||
│ │ │ ├── CoinSelector.vue
|
||||
│ │ │ ├── BillAcceptor.vue
|
||||
│ │ │ └── AmountDisplay.vue
|
||||
│ │ │
|
||||
│ │ ├── stores/ # Pinia stores
|
||||
│ │ │ ├── transaction.ts
|
||||
│ │ │ ├── machine.ts
|
||||
│ │ │ ├── ui.ts
|
||||
│ │ │ └── i18n.ts
|
||||
│ │ │
|
||||
│ │ ├── composables/ # Reusable logic
|
||||
│ │ │ ├── useWebSocket.ts
|
||||
│ │ │ ├── useKeyboard.ts
|
||||
│ │ │ ├── useQRScanner.ts
|
||||
│ │ │ ├── useIdleTimeout.ts
|
||||
│ │ │ └── useSounds.ts
|
||||
│ │ │
|
||||
│ │ ├── types/ # TypeScript types
|
||||
│ │ │ ├── transaction.ts
|
||||
│ │ │ ├── machine.ts
|
||||
│ │ │ └── events.ts
|
||||
│ │ │
|
||||
│ │ ├── i18n/ # Internationalization
|
||||
│ │ │ ├── index.ts
|
||||
│ │ │ └── locales/
|
||||
│ │ │ ├── en.json
|
||||
│ │ │ ├── es.json
|
||||
│ │ │ └── ...
|
||||
│ │ │
|
||||
│ │ └── styles/ # Global styles
|
||||
│ │ ├── main.scss
|
||||
│ │ ├── variables.scss
|
||||
│ │ └── themes/
|
||||
│ │
|
||||
│ ├── index.html
|
||||
│ ├── vite.config.ts
|
||||
│ ├── tsconfig.json
|
||||
│ └── package.json
|
||||
│
|
||||
├── lib/
|
||||
│ └── brain.js # State machine (unchanged)
|
||||
│
|
||||
└── package.json
|
||||
```
|
||||
|
||||
#architecture #structure
|
||||
|
||||
---
|
||||
|
||||
## Core Components
|
||||
|
||||
### App.vue - Root Component
|
||||
|
||||
```vue
|
||||
<!-- ui/src/App.vue -->
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useUIStore } from './stores/ui'
|
||||
import { useWebSocket } from './composables/useWebSocket'
|
||||
|
||||
// Screens
|
||||
import IdleScreen from './screens/IdleScreen.vue'
|
||||
import ChooseCoinScreen from './screens/ChooseCoinScreen.vue'
|
||||
import InsertBillsScreen from './screens/InsertBillsScreen.vue'
|
||||
// ... other screens
|
||||
|
||||
const ui = useUIStore()
|
||||
const { connected } = useWebSocket()
|
||||
|
||||
const screenComponent = computed(() => {
|
||||
const screens: Record<string, Component> = {
|
||||
idle: IdleScreen,
|
||||
chooseCoin: ChooseCoinScreen,
|
||||
insertBills: InsertBillsScreen,
|
||||
// ... map all states to screens
|
||||
}
|
||||
return screens[ui.currentScreen] ?? IdleScreen
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="app"
|
||||
:class="[ui.theme, { rtl: ui.isRTL }]"
|
||||
:dir="ui.isRTL ? 'rtl' : 'ltr'"
|
||||
>
|
||||
<Transition name="screen" mode="out-in">
|
||||
<component :is="screenComponent" :key="ui.currentScreen" />
|
||||
</Transition>
|
||||
|
||||
<!-- Global overlays -->
|
||||
<LoadingOverlay v-if="ui.isLoading" />
|
||||
<ErrorOverlay v-if="ui.error" :message="ui.error" />
|
||||
<ConnectionLost v-if="!connected" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
@import './styles/main.scss';
|
||||
|
||||
.app {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
|
||||
&.rtl {
|
||||
direction: rtl;
|
||||
}
|
||||
}
|
||||
|
||||
.screen-enter-active,
|
||||
.screen-leave-active {
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
}
|
||||
|
||||
.screen-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
.screen-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
### Transaction Store (Pinia)
|
||||
|
||||
```typescript
|
||||
// ui/src/stores/transaction.ts
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { Coin, Membership, Transaction } from '../types'
|
||||
|
||||
export const useTransactionStore = defineStore('transaction', () => {
|
||||
// State
|
||||
const direction = ref<'cashIn' | 'cashOut' | null>(null)
|
||||
const selectedCoin = ref<Coin | null>(null)
|
||||
const fiatAmount = ref(0)
|
||||
const cryptoAmount = ref(0)
|
||||
const walletAddress = ref<string | null>(null)
|
||||
const membership = ref<Membership | null>(null)
|
||||
const bills = ref<number[]>([])
|
||||
|
||||
// Computed
|
||||
const hasMembership = computed(() => membership.value !== null)
|
||||
const discountPercent = computed(() => membership.value?.tier.discountPercentage ?? 0)
|
||||
const totalFiat = computed(() => bills.value.reduce((sum, bill) => sum + bill, 0))
|
||||
|
||||
const effectiveRate = computed(() => {
|
||||
if (!selectedCoin.value) return 0
|
||||
const baseRate = selectedCoin.value.rate
|
||||
return baseRate * (1 - discountPercent.value / 100)
|
||||
})
|
||||
|
||||
// Actions
|
||||
function startCashIn(coin: Coin) {
|
||||
direction.value = 'cashIn'
|
||||
selectedCoin.value = coin
|
||||
bills.value = []
|
||||
}
|
||||
|
||||
function startCashOut(coin: Coin) {
|
||||
direction.value = 'cashOut'
|
||||
selectedCoin.value = coin
|
||||
}
|
||||
|
||||
function addBill(denomination: number) {
|
||||
bills.value.push(denomination)
|
||||
fiatAmount.value = totalFiat.value
|
||||
}
|
||||
|
||||
function setMembership(m: Membership) {
|
||||
membership.value = m
|
||||
if (m.lightningAddress) {
|
||||
walletAddress.value = m.lightningAddress
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
direction.value = null
|
||||
selectedCoin.value = null
|
||||
fiatAmount.value = 0
|
||||
cryptoAmount.value = 0
|
||||
walletAddress.value = null
|
||||
membership.value = null
|
||||
bills.value = []
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
direction,
|
||||
selectedCoin,
|
||||
fiatAmount,
|
||||
cryptoAmount,
|
||||
walletAddress,
|
||||
membership,
|
||||
bills,
|
||||
|
||||
// Computed
|
||||
hasMembership,
|
||||
discountPercent,
|
||||
totalFiat,
|
||||
effectiveRate,
|
||||
|
||||
// Actions
|
||||
startCashIn,
|
||||
startCashOut,
|
||||
addBill,
|
||||
setMembership,
|
||||
reset,
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### WebSocket Composable
|
||||
|
||||
```typescript
|
||||
// ui/src/composables/useWebSocket.ts
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useUIStore } from '../stores/ui'
|
||||
import { useTransactionStore } from '../stores/transaction'
|
||||
|
||||
interface BrainMessage {
|
||||
action: string
|
||||
state?: string
|
||||
data?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export function useWebSocket() {
|
||||
const ws = ref<WebSocket | null>(null)
|
||||
const connected = ref(false)
|
||||
const reconnectAttempts = ref(0)
|
||||
|
||||
const ui = useUIStore()
|
||||
const transaction = useTransactionStore()
|
||||
|
||||
function connect() {
|
||||
const host = import.meta.env.VITE_WS_HOST ?? 'localhost'
|
||||
const port = import.meta.env.VITE_WS_PORT ?? '8080'
|
||||
|
||||
ws.value = new WebSocket(`ws://${host}:${port}`)
|
||||
|
||||
ws.value.onopen = () => {
|
||||
connected.value = true
|
||||
reconnectAttempts.value = 0
|
||||
console.log('WebSocket connected')
|
||||
}
|
||||
|
||||
ws.value.onclose = () => {
|
||||
connected.value = false
|
||||
scheduleReconnect()
|
||||
}
|
||||
|
||||
ws.value.onerror = (error) => {
|
||||
console.error('WebSocket error:', error)
|
||||
}
|
||||
|
||||
ws.value.onmessage = (event) => {
|
||||
const message: BrainMessage = JSON.parse(event.data)
|
||||
handleMessage(message)
|
||||
}
|
||||
}
|
||||
|
||||
function handleMessage(message: BrainMessage) {
|
||||
switch (message.action) {
|
||||
case 'stateChange':
|
||||
ui.setScreen(message.state!)
|
||||
break
|
||||
|
||||
case 'billInserted':
|
||||
transaction.addBill(message.data!.denomination as number)
|
||||
break
|
||||
|
||||
case 'membershipValidated':
|
||||
transaction.setMembership(message.data!.membership as Membership)
|
||||
break
|
||||
|
||||
case 'transactionComplete':
|
||||
transaction.reset()
|
||||
break
|
||||
|
||||
case 'error':
|
||||
ui.setError(message.data!.message as string)
|
||||
break
|
||||
|
||||
default:
|
||||
console.log('Unknown message:', message)
|
||||
}
|
||||
}
|
||||
|
||||
function send(action: string, data?: Record<string, unknown>) {
|
||||
if (ws.value?.readyState === WebSocket.OPEN) {
|
||||
ws.value.send(JSON.stringify({ action, data }))
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (reconnectAttempts.value < 10) {
|
||||
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts.value), 30000)
|
||||
setTimeout(() => {
|
||||
reconnectAttempts.value++
|
||||
connect()
|
||||
}, delay)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => connect())
|
||||
onUnmounted(() => ws.value?.close())
|
||||
|
||||
return {
|
||||
connected,
|
||||
send,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Example Screen Component
|
||||
|
||||
```vue
|
||||
<!-- ui/src/screens/MembershipPromptScreen.vue -->
|
||||
<script setup lang="ts">
|
||||
import { useWebSocket } from '../composables/useWebSocket'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import BaseButton from '../components/common/BaseButton.vue'
|
||||
|
||||
const { send } = useWebSocket()
|
||||
const { t } = useI18n()
|
||||
|
||||
function handleYes() {
|
||||
send('membershipYes')
|
||||
}
|
||||
|
||||
function handleNo() {
|
||||
send('membershipNo')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="screen membership-prompt">
|
||||
<div class="content">
|
||||
<div class="icon">
|
||||
<img src="/images/membership-card.svg" alt="" />
|
||||
</div>
|
||||
|
||||
<h1 class="title">{{ t('membership.prompt.title') }}</h1>
|
||||
<p class="subtitle">{{ t('membership.prompt.subtitle') }}</p>
|
||||
|
||||
<div class="actions">
|
||||
<BaseButton
|
||||
variant="primary"
|
||||
size="large"
|
||||
@click="handleYes"
|
||||
>
|
||||
{{ t('common.yes') }}
|
||||
</BaseButton>
|
||||
|
||||
<BaseButton
|
||||
variant="secondary"
|
||||
size="large"
|
||||
@click="handleNo"
|
||||
>
|
||||
{{ t('common.no') }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.membership-prompt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
|
||||
.content {
|
||||
text-align: center;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: 2rem;
|
||||
|
||||
img {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 1.25rem;
|
||||
opacity: 0.8;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
#components #vue
|
||||
|
||||
---
|
||||
|
||||
## i18n Strategy
|
||||
|
||||
### Vue I18n Setup
|
||||
|
||||
```typescript
|
||||
// ui/src/i18n/index.ts
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
// Lazy load locales
|
||||
const messages = Object.fromEntries(
|
||||
Object.entries(
|
||||
import.meta.glob('./locales/*.json', { eager: true })
|
||||
).map(([path, module]) => {
|
||||
const locale = path.match(/\/(\w+)\.json$/)?.[1] ?? 'en'
|
||||
return [locale, (module as { default: Record<string, string> }).default]
|
||||
})
|
||||
)
|
||||
|
||||
export const i18n = createI18n({
|
||||
legacy: false, // Composition API
|
||||
locale: 'en',
|
||||
fallbackLocale: 'en',
|
||||
messages,
|
||||
})
|
||||
|
||||
export function setLocale(locale: string) {
|
||||
i18n.global.locale.value = locale
|
||||
document.documentElement.lang = locale
|
||||
document.documentElement.dir = isRTL(locale) ? 'rtl' : 'ltr'
|
||||
}
|
||||
|
||||
function isRTL(locale: string): boolean {
|
||||
return ['ar', 'he', 'fa', 'ur'].includes(locale)
|
||||
}
|
||||
```
|
||||
|
||||
### Locale Files
|
||||
|
||||
```json
|
||||
// ui/src/i18n/locales/en.json
|
||||
{
|
||||
"common": {
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"continue": "Continue",
|
||||
"cancel": "Cancel",
|
||||
"back": "Back"
|
||||
},
|
||||
"idle": {
|
||||
"tapToStart": "Tap to Start",
|
||||
"buyBitcoin": "Buy Bitcoin",
|
||||
"sellBitcoin": "Sell Bitcoin"
|
||||
},
|
||||
"membership": {
|
||||
"prompt": {
|
||||
"title": "Do you have a membership card?",
|
||||
"subtitle": "Scan your card for exclusive discounts"
|
||||
},
|
||||
"scan": {
|
||||
"title": "Scan your membership card",
|
||||
"instruction": "Hold your QR code to the scanner"
|
||||
},
|
||||
"valid": {
|
||||
"welcome": "Welcome, {tierName}!",
|
||||
"discount": "{percent}% discount applied",
|
||||
"autoSend": "Bitcoin will be sent to your wallet automatically"
|
||||
},
|
||||
"invalid": {
|
||||
"title": "Membership not recognized",
|
||||
"tryAgain": "Try Again",
|
||||
"skip": "Continue without membership"
|
||||
}
|
||||
},
|
||||
"transaction": {
|
||||
"insertBills": "Insert bills",
|
||||
"currentAmount": "Current amount: {amount}",
|
||||
"sendingCoins": "Sending {coin}...",
|
||||
"complete": "Transaction complete!"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#i18n #localization
|
||||
|
||||
---
|
||||
|
||||
## Build Configuration
|
||||
|
||||
### Vite Config
|
||||
|
||||
```typescript
|
||||
// ui/vite.config.ts
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { resolve } from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
|
||||
build: {
|
||||
target: 'chrome90', // Kiosk browser target
|
||||
outDir: 'dist',
|
||||
assetsDir: 'assets',
|
||||
sourcemap: false,
|
||||
minify: 'esbuild',
|
||||
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
vue: ['vue', 'vue-router', 'pinia'],
|
||||
i18n: ['vue-i18n'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
server: {
|
||||
port: 3000,
|
||||
host: true,
|
||||
},
|
||||
|
||||
// For kiosk: inline assets to reduce HTTP requests
|
||||
assetsInclude: ['**/*.svg', '**/*.png'],
|
||||
})
|
||||
```
|
||||
|
||||
### Package.json
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "lamassu-machine-ui",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"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",
|
||||
"vue-i18n": "^10.0.0",
|
||||
"@vueuse/core": "^11.0.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",
|
||||
"sass": "^1.80.0",
|
||||
"eslint": "^9.14.0",
|
||||
"eslint-plugin-vue": "^9.30.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#build #vite
|
||||
|
||||
---
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
### Phase 1: Setup & Parallel Development
|
||||
|
||||
> [!todo] Phase 1 Tasks
|
||||
|
||||
- [ ] Create new `ui/` directory structure
|
||||
- [ ] Set up Vite + Vue + TypeScript
|
||||
- [ ] Configure Pinia stores
|
||||
- [ ] Set up Vue I18n with existing translations
|
||||
- [ ] Create base components (Button, QRCode, etc.)
|
||||
- [ ] Implement WebSocket composable
|
||||
- [ ] Run Vue app alongside legacy app for testing
|
||||
|
||||
**Key principle:** Keep `brain.js` state machine unchanged. Only replace the UI layer.
|
||||
|
||||
### Phase 2: Screen Migration
|
||||
|
||||
> [!todo] Phase 2 Tasks
|
||||
|
||||
Migrate screens one-by-one, starting with simplest:
|
||||
|
||||
1. [ ] IdleScreen
|
||||
2. [ ] ChooseLanguageScreen
|
||||
3. [ ] ChooseCoinScreen
|
||||
4. [ ] MembershipPromptScreen (new)
|
||||
5. [ ] MembershipScanScreen (new)
|
||||
6. [ ] ScanAddressScreen
|
||||
7. [ ] InsertBillsScreen
|
||||
8. [ ] SendingCoinsScreen
|
||||
9. [ ] CompleteScreen
|
||||
10. [ ] ErrorScreen
|
||||
|
||||
### Phase 3: Component Polish
|
||||
|
||||
> [!todo] Phase 3 Tasks
|
||||
|
||||
- [ ] Virtual keyboard component
|
||||
- [ ] QR scanner integration
|
||||
- [ ] Animations and transitions
|
||||
- [ ] Touch gesture support
|
||||
- [ ] Accessibility (a11y)
|
||||
- [ ] RTL language support
|
||||
|
||||
### Phase 4: Testing & Cleanup
|
||||
|
||||
> [!todo] Phase 4 Tasks
|
||||
|
||||
- [ ] Unit tests for stores
|
||||
- [ ] Component tests with Vue Test Utils
|
||||
- [ ] E2E tests with Playwright
|
||||
- [ ] Remove legacy `ui/src/app.js`
|
||||
- [ ] Remove jQuery dependency
|
||||
- [ ] Update documentation
|
||||
|
||||
#migration #phases
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
### Component Tests
|
||||
|
||||
```typescript
|
||||
// ui/src/screens/__tests__/MembershipPromptScreen.test.ts
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import MembershipPromptScreen from '../MembershipPromptScreen.vue'
|
||||
|
||||
describe('MembershipPromptScreen', () => {
|
||||
it('renders prompt text', () => {
|
||||
const wrapper = mount(MembershipPromptScreen, {
|
||||
global: {
|
||||
plugins: [createTestingPinia()],
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('membership card')
|
||||
})
|
||||
|
||||
it('emits membershipYes when Yes clicked', async () => {
|
||||
const send = vi.fn()
|
||||
vi.mock('../composables/useWebSocket', () => ({
|
||||
useWebSocket: () => ({ send, connected: ref(true) }),
|
||||
}))
|
||||
|
||||
const wrapper = mount(MembershipPromptScreen)
|
||||
await wrapper.find('[data-test="yes-btn"]').trigger('click')
|
||||
|
||||
expect(send).toHaveBeenCalledWith('membershipYes')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### Store Tests
|
||||
|
||||
```typescript
|
||||
// ui/src/stores/__tests__/transaction.test.ts
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useTransactionStore } from '../transaction'
|
||||
|
||||
describe('Transaction Store', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it('calculates total fiat from bills', () => {
|
||||
const store = useTransactionStore()
|
||||
|
||||
store.addBill(20)
|
||||
store.addBill(20)
|
||||
store.addBill(10)
|
||||
|
||||
expect(store.totalFiat).toBe(50)
|
||||
})
|
||||
|
||||
it('applies membership discount to rate', () => {
|
||||
const store = useTransactionStore()
|
||||
|
||||
store.selectedCoin = { code: 'BTC', rate: 100 }
|
||||
store.setMembership({
|
||||
tier: { discountPercentage: 15 },
|
||||
})
|
||||
|
||||
expect(store.effectiveRate).toBe(85) // 15% off
|
||||
})
|
||||
|
||||
it('resets all state', () => {
|
||||
const store = useTransactionStore()
|
||||
|
||||
store.startCashIn({ code: 'BTC', rate: 100 })
|
||||
store.addBill(20)
|
||||
store.reset()
|
||||
|
||||
expect(store.direction).toBeNull()
|
||||
expect(store.bills).toEqual([])
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
#testing #vitest
|
||||
|
||||
---
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Bundle Size Targets
|
||||
|
||||
| Chunk | Target | Reason |
|
||||
|-------|--------|--------|
|
||||
| Vue core | < 40kb | Framework |
|
||||
| App code | < 50kb | Screens + components |
|
||||
| i18n | < 30kb | Lazy load locales |
|
||||
| **Total** | **< 120kb** | Fast kiosk boot |
|
||||
|
||||
### Optimization Strategies
|
||||
|
||||
1. **Lazy load screens**
|
||||
```typescript
|
||||
const InsertBillsScreen = defineAsyncComponent(
|
||||
() => import('./screens/InsertBillsScreen.vue')
|
||||
)
|
||||
```
|
||||
|
||||
2. **Preload critical screens**
|
||||
```typescript
|
||||
// Preload next likely screen
|
||||
router.beforeEach((to, from) => {
|
||||
if (to.name === 'chooseCoin') {
|
||||
import('./screens/ScanAddressScreen.vue')
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
3. **Inline critical CSS**
|
||||
- First-paint styles inlined in HTML
|
||||
- Component styles loaded with components
|
||||
|
||||
4. **Image optimization**
|
||||
- SVG for icons (scalable, small)
|
||||
- WebP for photos
|
||||
- Lazy load non-critical images
|
||||
|
||||
#performance #optimization
|
||||
|
||||
---
|
||||
|
||||
## Related Documents
|
||||
|
||||
- [[modernization-plan]] - Overall modernization roadmap
|
||||
- [[membership-lightning-integration]] - Membership feature
|
||||
- [[Machine State Management]] - XState migration (brain.js)
|
||||
Loading…
Add table
Add a link
Reference in a new issue