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