feat(docker): add dev.sh with auto-funding and ATM app setup
- Add dev.sh script for managing regtest development environment - Implement cmd_fund to fund ATM app owner via Lightning.Pub API - Add --fund flag to cmd_up for automatic funding on startup - Update setup_atm_app to write VITE_APP_ID to machine .env - Fix Electron IPC to pass appId and extensionApiUrl to renderer - Restructure repo from nested lamassu-next/ to root The dev.sh script now supports: - ./dev.sh up --fund # Start regtest and auto-fund ATM - ./dev.sh fund # Fund existing ATM app - ./dev.sh status # Show environment status - ./dev.sh reset # Clean restart Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
30a2eb2199
commit
c98f126ba7
180 changed files with 2695 additions and 9587 deletions
49
apps/machine/.env.example
Normal file
49
apps/machine/.env.example
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
# Lamassu ATM Configuration
|
||||
# Copy this file to .env and fill in your values
|
||||
|
||||
# =============================================================================
|
||||
# Hardware Configuration
|
||||
# =============================================================================
|
||||
|
||||
# Machine model preset (sintra, gaia, or custom)
|
||||
VITE_LAMASSU_MACHINE_MODEL=sintra
|
||||
|
||||
# Fiat currency code (ISO 4217)
|
||||
VITE_LAMASSU_FIAT_CODE=USD
|
||||
|
||||
# Custom device paths (optional - uses preset defaults if not set)
|
||||
# VITE_LAMASSU_VALIDATOR_DEVICE=/dev/ttyJ5
|
||||
# VITE_LAMASSU_DISPENSER_DEVICE=/dev/ttyJ7
|
||||
|
||||
# Cassette configuration (optional - JSON array)
|
||||
# VITE_LAMASSU_CASSETTES='[{"denomination":20,"count":100}]'
|
||||
|
||||
# =============================================================================
|
||||
# Lightning.Pub Connection (Required)
|
||||
# =============================================================================
|
||||
|
||||
# Nostr relay WebSocket URL
|
||||
VITE_RELAY_URL=ws://localhost:7777
|
||||
|
||||
# Lightning.Pub's Nostr public key (required!)
|
||||
# Get from: docker logs lamassu-lightning-pub | grep pubkey
|
||||
VITE_LIGHTNING_PUB_PUBKEY=
|
||||
|
||||
# Lightning.Pub HTTP API URL (optional)
|
||||
VITE_LIGHTNING_PUB_API_URL=http://localhost:1776
|
||||
|
||||
# =============================================================================
|
||||
# ATM Identity
|
||||
# =============================================================================
|
||||
|
||||
# ATM's Nostr private key (hex format, 64 characters)
|
||||
# Generate with: npx @lamassu/nostr-client generate-keypair
|
||||
# If not set, generates ephemeral identity on each restart
|
||||
VITE_ATM_PRIVATE_KEY=
|
||||
|
||||
# =============================================================================
|
||||
# Development Only
|
||||
# =============================================================================
|
||||
|
||||
# Lightning.Pub admin token (dev/testing only)
|
||||
VITE_ADMIN_TOKEN=lamassu-dev-admin-token
|
||||
21
apps/machine/components.json
Normal file
21
apps/machine/components.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"$schema": "https://shadcn-vue.com/schema.json",
|
||||
"style": "new-york",
|
||||
"typescript": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/style.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"composables": "@/composables"
|
||||
},
|
||||
"registries": {}
|
||||
}
|
||||
132
apps/machine/electron/main.ts
Normal file
132
apps/machine/electron/main.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
/**
|
||||
* Electron Main Process
|
||||
*
|
||||
* Creates the ATM kiosk window and handles IPC with the renderer.
|
||||
* In production, runs in kiosk mode (fullscreen, no decorations).
|
||||
* HAL hardware drivers run here (Node.js environment).
|
||||
*/
|
||||
|
||||
import { app, BrowserWindow, ipcMain } from 'electron'
|
||||
import path from 'node:path'
|
||||
import fs from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
// ESM equivalent of __dirname
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
|
||||
// Load .env file manually (Electron main process doesn't have Vite's env loading)
|
||||
function loadEnvFile() {
|
||||
const envPath = path.join(__dirname, '..', '.env')
|
||||
try {
|
||||
if (fs.existsSync(envPath)) {
|
||||
const envContent = fs.readFileSync(envPath, 'utf-8')
|
||||
for (const line of envContent.split('\n')) {
|
||||
const trimmed = line.trim()
|
||||
if (trimmed && !trimmed.startsWith('#')) {
|
||||
const [key, ...valueParts] = trimmed.split('=')
|
||||
if (key && valueParts.length > 0) {
|
||||
process.env[key] = valueParts.join('=')
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log('[Electron] Loaded .env file from:', envPath)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Electron] Failed to load .env file:', e)
|
||||
}
|
||||
}
|
||||
|
||||
loadEnvFile()
|
||||
|
||||
// Determine if we're in development
|
||||
const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged
|
||||
|
||||
// Window dimensions (vertical ATM display)
|
||||
const WINDOW_WIDTH = 1080
|
||||
const WINDOW_HEIGHT = 1920
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
|
||||
function createWindow() {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: WINDOW_WIDTH,
|
||||
height: WINDOW_HEIGHT,
|
||||
resizable: isDev, // Allow resize in dev for debugging
|
||||
frame: isDev, // Show window frame in dev
|
||||
kiosk: !isDev, // Kiosk mode in production
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
})
|
||||
|
||||
// Load the app
|
||||
if (isDev) {
|
||||
// Development: load from Vite dev server
|
||||
const devServerUrl = process.env.VITE_DEV_SERVER_URL || 'http://localhost:1420'
|
||||
mainWindow.loadURL(devServerUrl)
|
||||
mainWindow.webContents.openDevTools()
|
||||
} else {
|
||||
// Production: load from built files
|
||||
mainWindow.loadFile(path.join(__dirname, '../dist/index.html'))
|
||||
}
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
mainWindow = null
|
||||
})
|
||||
}
|
||||
|
||||
// IPC Handlers
|
||||
ipcMain.handle('get-version', () => {
|
||||
return app.getVersion()
|
||||
})
|
||||
|
||||
/**
|
||||
* Get runtime configuration from environment variables
|
||||
* This allows configuration to be set at runtime (not baked in at build time)
|
||||
*/
|
||||
ipcMain.handle('get-config', () => {
|
||||
return {
|
||||
// Lightning.Pub connection
|
||||
relayUrl: process.env.VITE_RELAY_URL || 'ws://localhost:7777',
|
||||
lightningPubPubkey: process.env.VITE_LIGHTNING_PUB_PUBKEY || '',
|
||||
lightningPubApiUrl: process.env.VITE_LIGHTNING_PUB_API_URL || 'http://localhost:1776',
|
||||
extensionApiUrl: process.env.VITE_EXTENSION_API_URL || 'http://localhost:1777',
|
||||
atmPrivateKey: process.env.VITE_ATM_PRIVATE_KEY || '',
|
||||
adminToken: process.env.VITE_ADMIN_TOKEN || '',
|
||||
appId: process.env.VITE_APP_ID || '',
|
||||
|
||||
// Hardware configuration
|
||||
machineModel: process.env.VITE_LAMASSU_MACHINE_MODEL || 'sintra',
|
||||
fiatCode: process.env.VITE_LAMASSU_FIAT_CODE || 'USD',
|
||||
validatorDevice: process.env.VITE_LAMASSU_VALIDATOR_DEVICE,
|
||||
dispenserDevice: process.env.VITE_LAMASSU_DISPENSER_DEVICE,
|
||||
cassettes: process.env.VITE_LAMASSU_CASSETTES,
|
||||
}
|
||||
})
|
||||
|
||||
// App lifecycle
|
||||
app.whenReady().then(() => {
|
||||
createWindow()
|
||||
|
||||
app.on('activate', () => {
|
||||
// macOS: re-create window when dock icon clicked
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
// Quit on all platforms (ATM doesn't need macOS dock behavior)
|
||||
app.quit()
|
||||
})
|
||||
|
||||
// Security: prevent new window creation
|
||||
app.on('web-contents-created', (_, contents) => {
|
||||
contents.setWindowOpenHandler(() => {
|
||||
return { action: 'deny' }
|
||||
})
|
||||
})
|
||||
50
apps/machine/electron/preload.ts
Normal file
50
apps/machine/electron/preload.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
/**
|
||||
* Electron Preload Script
|
||||
*
|
||||
* Exposes a secure API to the renderer process via contextBridge.
|
||||
* This is the only way for the Vue app to communicate with the main process.
|
||||
*/
|
||||
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
|
||||
/**
|
||||
* Runtime configuration interface
|
||||
* These values are read from environment variables at runtime (not build time)
|
||||
*/
|
||||
export interface RuntimeConfig {
|
||||
relayUrl: string
|
||||
lightningPubPubkey: string
|
||||
lightningPubApiUrl: string
|
||||
extensionApiUrl: string
|
||||
atmPrivateKey: string
|
||||
adminToken: string
|
||||
appId: string
|
||||
machineModel: string
|
||||
fiatCode: string
|
||||
validatorDevice?: string
|
||||
dispenserDevice?: string
|
||||
cassettes?: string
|
||||
}
|
||||
|
||||
// Expose protected methods to renderer
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
// Get app version
|
||||
getVersion: (): Promise<string> => ipcRenderer.invoke('get-version'),
|
||||
|
||||
// Get runtime configuration (read from environment at runtime)
|
||||
getConfig: (): Promise<RuntimeConfig> => ipcRenderer.invoke('get-config'),
|
||||
|
||||
// Platform info
|
||||
platform: process.platform,
|
||||
})
|
||||
|
||||
// Type declaration for the exposed API
|
||||
declare global {
|
||||
interface Window {
|
||||
electronAPI: {
|
||||
getVersion: () => Promise<string>
|
||||
getConfig: () => Promise<RuntimeConfig>
|
||||
platform: NodeJS.Platform
|
||||
}
|
||||
}
|
||||
}
|
||||
14
apps/machine/electron/tsconfig.json
Normal file
14
apps/machine/electron/tsconfig.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "../dist-electron",
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["main.ts"],
|
||||
"exclude": ["preload.ts"]
|
||||
}
|
||||
13
apps/machine/electron/tsconfig.preload.json
Normal file
13
apps/machine/electron/tsconfig.preload.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "node",
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "../dist-electron",
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["preload.ts"]
|
||||
}
|
||||
27
apps/machine/index.html
Normal file
27
apps/machine/index.html
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<!doctype html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Lamassu ATM</title>
|
||||
<style>
|
||||
/* Prevent text selection and context menu on kiosk */
|
||||
* {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
73
apps/machine/package.json
Normal file
73
apps/machine/package.json
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
{
|
||||
"name": "@lamassu/machine",
|
||||
"version": "0.1.0",
|
||||
"description": "Electron ATM kiosk application with Vue 3 UI",
|
||||
"author": {
|
||||
"name": "AIO Labs",
|
||||
"email": "dev@aiolabs.dev"
|
||||
},
|
||||
"homepage": "https://aiolabs.dev",
|
||||
"license": "AGPL-3.0",
|
||||
"type": "module",
|
||||
"main": "dist-electron/main.js",
|
||||
"scripts": {
|
||||
"dev": "concurrently -n vite,electron \"vite\" \"pnpm run electron:dev\"",
|
||||
"dev:vite": "vite",
|
||||
"electron:dev": "tsc -p electron/tsconfig.json && tsc -p electron/tsconfig.preload.json && electron dist-electron/main.js",
|
||||
"build": "vue-tsc --noEmit && vite build && tsc -p electron/tsconfig.json && tsc -p electron/tsconfig.preload.json",
|
||||
"build:electron": "pnpm build && electron-builder",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"lint": "eslint src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lamassu/clink": "workspace:*",
|
||||
"@lamassu/hal": "workspace:*",
|
||||
"@lamassu/lightning": "workspace:*",
|
||||
"@lamassu/nostr-client": "workspace:*",
|
||||
"@lamassu/state-machine": "workspace:*",
|
||||
"@scure/base": "^1.2.0",
|
||||
"@vueuse/core": "^14.1.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"express": "^5.2.1",
|
||||
"lucide-vue-next": "^0.563.0",
|
||||
"pinia": "^2.2.0",
|
||||
"qrcode.vue": "^3.6.0",
|
||||
"reka-ui": "^2.7.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"vue": "^3.5.0",
|
||||
"vue-router": "^4.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^22.0.0",
|
||||
"@vitejs/plugin-vue": "^5.2.0",
|
||||
"concurrently": "^9.0.0",
|
||||
"electron": "^33.0.0",
|
||||
"electron-builder": "^25.0.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^6.0.0",
|
||||
"vue-tsc": "^2.1.0"
|
||||
},
|
||||
"build": {
|
||||
"appId": "dev.lamassu.atm",
|
||||
"productName": "Lamassu ATM",
|
||||
"directories": {
|
||||
"output": "release"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
"dist-electron/**/*"
|
||||
],
|
||||
"linux": {
|
||||
"target": [
|
||||
"AppImage"
|
||||
],
|
||||
"category": "Finance"
|
||||
}
|
||||
}
|
||||
}
|
||||
65
apps/machine/src/App.vue
Normal file
65
apps/machine/src/App.vue
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { useAtmStore } from '@/stores/atm'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
const atmStore = useAtmStore()
|
||||
|
||||
onMounted(async () => {
|
||||
// Initialize the ATM state machine with Lightning.Pub
|
||||
await atmStore.initializeWithLightning()
|
||||
})
|
||||
|
||||
function toggleLiveServices() {
|
||||
if (atmStore.useLiveServices) {
|
||||
// Switch to mock
|
||||
atmStore.initialize()
|
||||
} else {
|
||||
// Switch to live
|
||||
atmStore.initializeWithLightning()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex h-screen w-screen flex-col overflow-hidden bg-background font-sans text-foreground"
|
||||
>
|
||||
<router-view />
|
||||
|
||||
<!-- Connection status (top right) -->
|
||||
<div class="fixed right-4 top-4 z-50 flex items-center gap-2">
|
||||
<Badge
|
||||
v-if="atmStore.connectionStatus === 'connected'"
|
||||
class="bg-success text-success-foreground"
|
||||
>
|
||||
Live
|
||||
</Badge>
|
||||
<Badge
|
||||
v-else-if="atmStore.connectionStatus === 'connecting'"
|
||||
class="bg-warning text-warning-foreground"
|
||||
>
|
||||
Connecting...
|
||||
</Badge>
|
||||
<Badge v-else-if="atmStore.connectionStatus === 'error'" class="bg-destructive">
|
||||
Offline
|
||||
</Badge>
|
||||
<Badge v-else class="bg-muted text-muted-foreground">Mock</Badge>
|
||||
</div>
|
||||
|
||||
<!-- Debug overlay (dev only) -->
|
||||
<div
|
||||
v-if="atmStore.debugMode"
|
||||
class="fixed inset-x-0 bottom-0 z-50 max-h-52 overflow-auto bg-popover p-4 font-mono text-xs text-popover-foreground"
|
||||
>
|
||||
<div class="mb-2 flex items-center gap-4">
|
||||
<span class="text-success">State: {{ atmStore.currentState }}</span>
|
||||
<Button variant="outline" size="sm" class="h-6 text-xs" @click="toggleLiveServices">
|
||||
{{ atmStore.useLiveServices ? 'Switch to Mock' : 'Connect Live' }}
|
||||
</Button>
|
||||
</div>
|
||||
<pre class="text-muted-foreground">{{ JSON.stringify(atmStore.context, null, 2) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
37
apps/machine/src/components/QRCode.vue
Normal file
37
apps/machine/src/components/QRCode.vue
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted } from 'vue'
|
||||
import QrcodeVue from 'qrcode.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
value: string
|
||||
size?: number
|
||||
}>()
|
||||
|
||||
const qrSize = computed(() => props.size ?? 200)
|
||||
|
||||
// Read CSS variables for theme-aware colors
|
||||
const qrBackground = ref('#f5f0e6')
|
||||
const qrForeground = ref('#3c3836')
|
||||
|
||||
onMounted(() => {
|
||||
const styles = getComputedStyle(document.documentElement)
|
||||
qrBackground.value = styles.getPropertyValue('--qr').trim() || '#f5f0e6'
|
||||
qrForeground.value = styles.getPropertyValue('--qr-foreground').trim() || '#3c3836'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col items-center justify-center rounded-xl bg-qr p-5">
|
||||
<QrcodeVue
|
||||
:value="value"
|
||||
:size="qrSize"
|
||||
level="M"
|
||||
render-as="svg"
|
||||
:background="qrBackground"
|
||||
:foreground="qrForeground"
|
||||
/>
|
||||
<code class="mt-3 block max-w-full break-all px-2 text-center text-xs text-qr-foreground">
|
||||
{{ value.slice(0, 32) }}...
|
||||
</code>
|
||||
</div>
|
||||
</template>
|
||||
17
apps/machine/src/components/ui/alert/Alert.vue
Normal file
17
apps/machine/src/components/ui/alert/Alert.vue
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import type { AlertVariants } from '.'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { alertVariants } from '.'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
variant?: AlertVariants['variant']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div data-slot="alert" :class="cn(alertVariants({ variant }), props.class)" role="alert">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
22
apps/machine/src/components/ui/alert/AlertDescription.vue
Normal file
22
apps/machine/src/components/ui/alert/AlertDescription.vue
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
:class="
|
||||
cn(
|
||||
'text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed',
|
||||
props.class
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
17
apps/machine/src/components/ui/alert/AlertTitle.vue
Normal file
17
apps/machine/src/components/ui/alert/AlertTitle.vue
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
:class="cn('col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
24
apps/machine/src/components/ui/alert/index.ts
Normal file
24
apps/machine/src/components/ui/alert/index.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import type { VariantProps } from 'class-variance-authority'
|
||||
import { cva } from 'class-variance-authority'
|
||||
|
||||
export { default as Alert } from './Alert.vue'
|
||||
export { default as AlertDescription } from './AlertDescription.vue'
|
||||
export { default as AlertTitle } from './AlertTitle.vue'
|
||||
|
||||
export const alertVariants = cva(
|
||||
'relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-card text-card-foreground',
|
||||
destructive:
|
||||
'text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export type AlertVariants = VariantProps<typeof alertVariants>
|
||||
28
apps/machine/src/components/ui/badge/Badge.vue
Normal file
28
apps/machine/src/components/ui/badge/Badge.vue
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<script setup lang="ts">
|
||||
import type { PrimitiveProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import type { BadgeVariants } from '.'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { Primitive } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { badgeVariants } from '.'
|
||||
|
||||
const props = defineProps<
|
||||
PrimitiveProps & {
|
||||
variant?: BadgeVariants['variant']
|
||||
class?: HTMLAttributes['class']
|
||||
}
|
||||
>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
data-slot="badge"
|
||||
:class="cn(badgeVariants({ variant }), props.class)"
|
||||
v-bind="delegatedProps"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
24
apps/machine/src/components/ui/badge/index.ts
Normal file
24
apps/machine/src/components/ui/badge/index.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import type { VariantProps } from 'class-variance-authority'
|
||||
import { cva } from 'class-variance-authority'
|
||||
|
||||
export { default as Badge } from './Badge.vue'
|
||||
|
||||
export const badgeVariants = cva(
|
||||
'inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
|
||||
secondary:
|
||||
'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
|
||||
destructive:
|
||||
'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
|
||||
outline: 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
)
|
||||
export type BadgeVariants = VariantProps<typeof badgeVariants>
|
||||
29
apps/machine/src/components/ui/button/Button.vue
Normal file
29
apps/machine/src/components/ui/button/Button.vue
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<script setup lang="ts">
|
||||
import type { PrimitiveProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import type { ButtonVariants } from '.'
|
||||
import { Primitive } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { buttonVariants } from '.'
|
||||
|
||||
interface Props extends PrimitiveProps {
|
||||
variant?: ButtonVariants['variant']
|
||||
size?: ButtonVariants['size']
|
||||
class?: HTMLAttributes['class']
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
as: 'button',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
data-slot="button"
|
||||
:as="as"
|
||||
:as-child="asChild"
|
||||
:class="cn(buttonVariants({ variant, size }), props.class)"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
35
apps/machine/src/components/ui/button/index.ts
Normal file
35
apps/machine/src/components/ui/button/index.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import type { VariantProps } from 'class-variance-authority'
|
||||
import { cva } from 'class-variance-authority'
|
||||
|
||||
export { default as Button } from './Button.vue'
|
||||
|
||||
export const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive:
|
||||
'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
|
||||
outline:
|
||||
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
||||
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
|
||||
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
||||
icon: 'size-9',
|
||||
'icon-sm': 'size-8',
|
||||
'icon-lg': 'size-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
)
|
||||
export type ButtonVariants = VariantProps<typeof buttonVariants>
|
||||
22
apps/machine/src/components/ui/card/Card.vue
Normal file
22
apps/machine/src/components/ui/card/Card.vue
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="card"
|
||||
:class="
|
||||
cn(
|
||||
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
|
||||
props.class
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
17
apps/machine/src/components/ui/card/CardAction.vue
Normal file
17
apps/machine/src/components/ui/card/CardAction.vue
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="card-action"
|
||||
:class="cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
14
apps/machine/src/components/ui/card/CardContent.vue
Normal file
14
apps/machine/src/components/ui/card/CardContent.vue
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div data-slot="card-content" :class="cn('px-6', props.class)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
14
apps/machine/src/components/ui/card/CardDescription.vue
Normal file
14
apps/machine/src/components/ui/card/CardDescription.vue
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p data-slot="card-description" :class="cn('text-muted-foreground text-sm', props.class)">
|
||||
<slot />
|
||||
</p>
|
||||
</template>
|
||||
14
apps/machine/src/components/ui/card/CardFooter.vue
Normal file
14
apps/machine/src/components/ui/card/CardFooter.vue
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div data-slot="card-footer" :class="cn('flex items-center px-6 [.border-t]:pt-6', props.class)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
22
apps/machine/src/components/ui/card/CardHeader.vue
Normal file
22
apps/machine/src/components/ui/card/CardHeader.vue
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="card-header"
|
||||
:class="
|
||||
cn(
|
||||
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
|
||||
props.class
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
14
apps/machine/src/components/ui/card/CardTitle.vue
Normal file
14
apps/machine/src/components/ui/card/CardTitle.vue
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h3 data-slot="card-title" :class="cn('leading-none font-semibold', props.class)">
|
||||
<slot />
|
||||
</h3>
|
||||
</template>
|
||||
7
apps/machine/src/components/ui/card/index.ts
Normal file
7
apps/machine/src/components/ui/card/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export { default as Card } from './Card.vue'
|
||||
export { default as CardAction } from './CardAction.vue'
|
||||
export { default as CardContent } from './CardContent.vue'
|
||||
export { default as CardDescription } from './CardDescription.vue'
|
||||
export { default as CardFooter } from './CardFooter.vue'
|
||||
export { default as CardHeader } from './CardHeader.vue'
|
||||
export { default as CardTitle } from './CardTitle.vue'
|
||||
35
apps/machine/src/components/ui/input/Input.vue
Normal file
35
apps/machine/src/components/ui/input/Input.vue
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { useVModel } from '@vueuse/core'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
defaultValue?: string | number
|
||||
modelValue?: string | number
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const emits = defineEmits<{
|
||||
(e: 'update:modelValue', payload: string | number): void
|
||||
}>()
|
||||
|
||||
const modelValue = useVModel(props, 'modelValue', emits, {
|
||||
passive: true,
|
||||
defaultValue: props.defaultValue,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input
|
||||
v-model="modelValue"
|
||||
data-slot="input"
|
||||
:class="
|
||||
cn(
|
||||
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
|
||||
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
|
||||
props.class
|
||||
)
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
1
apps/machine/src/components/ui/input/index.ts
Normal file
1
apps/machine/src/components/ui/input/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default as Input } from './Input.vue'
|
||||
14
apps/machine/src/components/ui/skeleton/Skeleton.vue
Normal file
14
apps/machine/src/components/ui/skeleton/Skeleton.vue
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface SkeletonProps {
|
||||
class?: HTMLAttributes['class']
|
||||
}
|
||||
|
||||
const props = defineProps<SkeletonProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div data-slot="skeleton" :class="cn('animate-pulse rounded-md bg-primary/10', props.class)" />
|
||||
</template>
|
||||
1
apps/machine/src/components/ui/skeleton/index.ts
Normal file
1
apps/machine/src/components/ui/skeleton/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default as Skeleton } from './Skeleton.vue'
|
||||
217
apps/machine/src/config/device.ts
Normal file
217
apps/machine/src/config/device.ts
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
/**
|
||||
* Device Configuration
|
||||
*
|
||||
* Defines hardware configuration for ATM devices (bill validator, dispenser).
|
||||
* Supports multiple machine models with sensible defaults.
|
||||
*
|
||||
* Configuration priority (highest to lowest):
|
||||
* 1. Runtime overrides (passed directly to functions)
|
||||
* 2. Environment variables (LAMASSU_*)
|
||||
* 3. Machine preset defaults
|
||||
*/
|
||||
|
||||
import type { HalConfig, CassetteConfig } from '@/services/hal'
|
||||
|
||||
/**
|
||||
* Supported machine models
|
||||
*/
|
||||
export type MachineModel = 'sintra' | 'gaia' | 'custom'
|
||||
|
||||
/**
|
||||
* Full device configuration
|
||||
*/
|
||||
export interface DeviceConfig {
|
||||
/** Machine model name */
|
||||
model: MachineModel
|
||||
/** Fiat currency code (ISO 4217) */
|
||||
fiatCode: string
|
||||
/** Bill validator configuration */
|
||||
validator: {
|
||||
/** Validator protocol type */
|
||||
type: 'id003'
|
||||
/** Serial device path(s) */
|
||||
device: string | string[]
|
||||
}
|
||||
/** Bill dispenser configuration */
|
||||
dispenser: {
|
||||
/** Dispenser model */
|
||||
type: 'f56'
|
||||
/** Serial device path */
|
||||
device: string
|
||||
/** Cassette configuration */
|
||||
cassettes: CassetteConfig[]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Preset configurations for known machine models
|
||||
*/
|
||||
export const MACHINE_PRESETS: Record<MachineModel, Omit<DeviceConfig, 'fiatCode'>> = {
|
||||
/**
|
||||
* Lamassu Sintra (Gen 2)
|
||||
* - Platform: Aaeon UP Board (Intel Atom x5-Z8350)
|
||||
* - Validator: JCM iVIZION (ID003 protocol)
|
||||
* - Dispenser: Fujitsu F56
|
||||
*/
|
||||
sintra: {
|
||||
model: 'sintra',
|
||||
validator: {
|
||||
type: 'id003',
|
||||
device: '/dev/ttyJ5',
|
||||
},
|
||||
dispenser: {
|
||||
type: 'f56',
|
||||
device: '/dev/ttyJ7',
|
||||
cassettes: [{ denomination: 20, count: 50 }],
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* Lamassu Gaia
|
||||
* - Validator: JCM iVIZION (ID003 protocol)
|
||||
* - Dispenser: Fujitsu F56
|
||||
* Note: Device paths may vary - verify on hardware
|
||||
*/
|
||||
gaia: {
|
||||
model: 'gaia',
|
||||
validator: {
|
||||
type: 'id003',
|
||||
device: '/dev/ttyUSB0',
|
||||
},
|
||||
dispenser: {
|
||||
type: 'f56',
|
||||
device: '/dev/ttyUSB1',
|
||||
cassettes: [{ denomination: 20, count: 50 }],
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* Custom configuration - all values must be provided via env/runtime
|
||||
*/
|
||||
custom: {
|
||||
model: 'custom',
|
||||
validator: {
|
||||
type: 'id003',
|
||||
device: '/dev/ttyUSB0',
|
||||
},
|
||||
dispenser: {
|
||||
type: 'f56',
|
||||
device: '/dev/ttyUSB1',
|
||||
cassettes: [],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Get device configuration for a machine model
|
||||
*
|
||||
* @param model - Machine model or 'custom' for manual config
|
||||
* @param fiatCode - Fiat currency code (default: 'USD')
|
||||
* @param overrides - Optional overrides for specific values
|
||||
*/
|
||||
export function getDeviceConfig(
|
||||
model: MachineModel = 'sintra',
|
||||
fiatCode: string = 'USD',
|
||||
overrides?: Partial<DeviceConfig>
|
||||
): DeviceConfig {
|
||||
const preset = MACHINE_PRESETS[model]
|
||||
|
||||
const config: DeviceConfig = {
|
||||
...preset,
|
||||
fiatCode,
|
||||
validator: { ...preset.validator },
|
||||
dispenser: { ...preset.dispenser, cassettes: [...preset.dispenser.cassettes] },
|
||||
}
|
||||
|
||||
// Apply overrides
|
||||
if (overrides) {
|
||||
if (overrides.fiatCode) config.fiatCode = overrides.fiatCode
|
||||
if (overrides.validator?.device) config.validator.device = overrides.validator.device
|
||||
if (overrides.dispenser?.device) config.dispenser.device = overrides.dispenser.device
|
||||
if (overrides.dispenser?.cassettes) config.dispenser.cassettes = overrides.dispenser.cassettes
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
/**
|
||||
* Load device configuration from environment variables
|
||||
*
|
||||
* Supported variables:
|
||||
* - LAMASSU_MACHINE_MODEL: Machine model preset (sintra, gaia, custom)
|
||||
* - LAMASSU_FIAT_CODE: Fiat currency code (USD, EUR, etc.)
|
||||
* - LAMASSU_VALIDATOR_DEVICE: Bill validator serial device path
|
||||
* - LAMASSU_DISPENSER_DEVICE: Bill dispenser serial device path
|
||||
* - LAMASSU_CASSETTES: JSON array of cassette configs
|
||||
*
|
||||
* @example
|
||||
* LAMASSU_MACHINE_MODEL=sintra
|
||||
* LAMASSU_FIAT_CODE=USD
|
||||
* LAMASSU_CASSETTES='[{"denomination":20,"count":100},{"denomination":50,"count":50}]'
|
||||
*/
|
||||
export function loadDeviceConfigFromEnv(): DeviceConfig {
|
||||
const model = (import.meta.env.VITE_LAMASSU_MACHINE_MODEL as MachineModel) || 'sintra'
|
||||
const fiatCode = import.meta.env.VITE_LAMASSU_FIAT_CODE || 'USD'
|
||||
|
||||
const overrides: Partial<DeviceConfig> = {}
|
||||
|
||||
// Validator device override
|
||||
const validatorDevice = import.meta.env.VITE_LAMASSU_VALIDATOR_DEVICE
|
||||
if (validatorDevice) {
|
||||
overrides.validator = { type: 'id003', device: validatorDevice }
|
||||
}
|
||||
|
||||
// Dispenser device override
|
||||
const dispenserDevice = import.meta.env.VITE_LAMASSU_DISPENSER_DEVICE
|
||||
if (dispenserDevice) {
|
||||
overrides.dispenser = {
|
||||
type: 'f56',
|
||||
device: dispenserDevice,
|
||||
cassettes: [],
|
||||
}
|
||||
}
|
||||
|
||||
// Cassettes override (JSON)
|
||||
const cassettesJson = import.meta.env.VITE_LAMASSU_CASSETTES
|
||||
if (cassettesJson) {
|
||||
try {
|
||||
const cassettes = JSON.parse(cassettesJson) as CassetteConfig[]
|
||||
if (overrides.dispenser) {
|
||||
overrides.dispenser.cassettes = cassettes
|
||||
} else {
|
||||
overrides.dispenser = {
|
||||
type: 'f56',
|
||||
device: MACHINE_PRESETS[model].dispenser.device,
|
||||
cassettes,
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Config] Failed to parse LAMASSU_CASSETTES:', e)
|
||||
}
|
||||
}
|
||||
|
||||
return getDeviceConfig(model, fiatCode, overrides)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert DeviceConfig to HalConfig format
|
||||
*/
|
||||
export function toHalConfig(config: DeviceConfig): HalConfig {
|
||||
return {
|
||||
validator: {
|
||||
type: config.validator.type,
|
||||
device: config.validator.device,
|
||||
fiatCode: config.fiatCode,
|
||||
},
|
||||
dispenser: {
|
||||
type: config.dispenser.type,
|
||||
device: config.dispenser.device,
|
||||
cassettes: config.dispenser.cassettes,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default export: Load config from environment with Sintra defaults
|
||||
*/
|
||||
export const deviceConfig = loadDeviceConfigFromEnv()
|
||||
15
apps/machine/src/config/index.ts
Normal file
15
apps/machine/src/config/index.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
/**
|
||||
* Configuration module
|
||||
*
|
||||
* Exports device configuration and utilities.
|
||||
*/
|
||||
|
||||
export {
|
||||
deviceConfig,
|
||||
getDeviceConfig,
|
||||
loadDeviceConfigFromEnv,
|
||||
toHalConfig,
|
||||
MACHINE_PRESETS,
|
||||
type DeviceConfig,
|
||||
type MachineModel,
|
||||
} from './device'
|
||||
7
apps/machine/src/lib/utils.ts
Normal file
7
apps/machine/src/lib/utils.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import type { ClassValue } from 'clsx'
|
||||
import { clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
29
apps/machine/src/main.ts
Normal file
29
apps/machine/src/main.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import App from './App.vue'
|
||||
import './style.css'
|
||||
|
||||
// Import views
|
||||
import IdleView from './views/IdleView.vue'
|
||||
import CashOutView from './views/CashOutView.vue'
|
||||
import CashInView from './views/CashInView.vue'
|
||||
|
||||
// Create router
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/', name: 'idle', component: IdleView },
|
||||
{ path: '/cash-out', name: 'cash-out', component: CashOutView },
|
||||
{ path: '/cash-in', name: 'cash-in', component: CashInView },
|
||||
],
|
||||
})
|
||||
|
||||
// Create Pinia store
|
||||
const pinia = createPinia()
|
||||
|
||||
// Create and mount app
|
||||
const app = createApp(App)
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
211
apps/machine/src/services/hal.ts
Normal file
211
apps/machine/src/services/hal.ts
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
/**
|
||||
* HAL Service Wrapper
|
||||
*
|
||||
* Bridges @lamassu/hal hardware drivers (EventEmitter-based) to the
|
||||
* ATMServices interface expected by the XState state machine.
|
||||
*
|
||||
* Validator events are pushed into the state machine via callbacks.
|
||||
* Dispenser operations are exposed as Promise-based ATMServices methods.
|
||||
*
|
||||
* NOTE: This module uses dynamic imports for @lamassu/hal because it
|
||||
* requires Node.js APIs (serialport, node:events) that are unavailable
|
||||
* in the browser. It will only work in Electron's main process or
|
||||
* Node.js environments. In the browser, initializeHalServices() will
|
||||
* throw with a clear error message.
|
||||
*/
|
||||
|
||||
import type { ATMServices } from '@lamassu/state-machine'
|
||||
|
||||
export interface CassetteConfig {
|
||||
denomination: number
|
||||
count?: number
|
||||
}
|
||||
|
||||
export interface HalConfig {
|
||||
validator: {
|
||||
type: 'id003'
|
||||
device: string | string[]
|
||||
fiatCode: string
|
||||
}
|
||||
dispenser: {
|
||||
type: 'f56'
|
||||
device: string
|
||||
cassettes: CassetteConfig[]
|
||||
}
|
||||
}
|
||||
|
||||
export interface ValidatorCallbacks {
|
||||
onBillInserted: (denomination: number) => void
|
||||
onBillRejected: (reason: string) => void
|
||||
onError: (error: string) => void
|
||||
}
|
||||
|
||||
export interface HalServices {
|
||||
/** Partial ATMServices for hardware operations (dispenseCash, getInventory) */
|
||||
atmServices: Pick<ATMServices, 'dispenseCash' | 'getInventory'>
|
||||
/** Wire validator events to state machine. Call after actor is started. */
|
||||
connectValidator: (callbacks: ValidatorCallbacks) => void
|
||||
/** Enable bill acceptance */
|
||||
enableValidator: () => void
|
||||
/** Disable bill acceptance */
|
||||
disableValidator: () => void
|
||||
/** Stack the bill currently in escrow (accept it) */
|
||||
stackBill: () => void
|
||||
/** Reject the bill currently in escrow (return it) */
|
||||
rejectBill: () => void
|
||||
/** Clean up all hardware connections */
|
||||
cleanup: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize HAL hardware and return services for state machine integration.
|
||||
*
|
||||
* The validator runs as an EventEmitter - call `connectValidator()` to wire
|
||||
* its events to the state machine after the actor is started.
|
||||
*
|
||||
* The dispenser is Promise-based and exposed via `atmServices.dispenseCash`.
|
||||
*
|
||||
* Requires Node.js environment (Electron main process).
|
||||
*/
|
||||
export async function initializeHalServices(config: HalConfig): Promise<HalServices> {
|
||||
// Dynamic import - only works in Node.js (Electron main process)
|
||||
const hal = await import('@lamassu/hal').catch(() => {
|
||||
throw new Error(
|
||||
'[HAL] @lamassu/hal requires Node.js (serialport, node:events). ' +
|
||||
'Run in Electron main process, not the browser.'
|
||||
)
|
||||
})
|
||||
|
||||
const { validator: valConfig, dispenser: dispConfig } = config
|
||||
|
||||
// Create hardware instances
|
||||
const validator = hal.createValidator(valConfig.type, {
|
||||
rs232: { device: valConfig.device, fiatCode: valConfig.fiatCode },
|
||||
fiatCode: valConfig.fiatCode,
|
||||
})
|
||||
|
||||
const dispenser = hal.createDispenser(dispConfig.type, {
|
||||
device: dispConfig.device,
|
||||
})
|
||||
|
||||
// Initialize dispenser
|
||||
await dispenser.init({
|
||||
fiatCode: valConfig.fiatCode,
|
||||
cassettes: dispConfig.cassettes,
|
||||
})
|
||||
console.log('[HAL] Dispenser initialized')
|
||||
|
||||
// Start validator
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
validator.run((err?: Error) => {
|
||||
if (err) return reject(err)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
console.log('[HAL] Validator started')
|
||||
|
||||
// Track inventory (decremented on dispense)
|
||||
const inventory: Record<number, number> = {}
|
||||
for (const cassette of dispConfig.cassettes) {
|
||||
inventory[cassette.denomination] = cassette.count ?? 0
|
||||
}
|
||||
|
||||
// Map cassette index to denomination for dispense translation
|
||||
const cassetteDenominations = dispConfig.cassettes.map((c) => c.denomination)
|
||||
|
||||
const atmServices: Pick<ATMServices, 'dispenseCash' | 'getInventory'> = {
|
||||
dispenseCash: async (amounts) => {
|
||||
console.log('[HAL] Dispensing:', amounts)
|
||||
|
||||
// Translate { denomination, count }[] to number[] by cassette position
|
||||
const notes: number[] = new Array(cassetteDenominations.length).fill(0)
|
||||
for (const { denomination, count } of amounts) {
|
||||
const idx = cassetteDenominations.indexOf(denomination)
|
||||
if (idx === -1) {
|
||||
throw new Error(`No cassette loaded with denomination: ${denomination}`)
|
||||
}
|
||||
notes[idx] = count
|
||||
}
|
||||
|
||||
const result = await dispenser.dispense(notes)
|
||||
|
||||
// Update inventory
|
||||
for (let i = 0; i < result.value.length; i++) {
|
||||
const denom = cassetteDenominations[i]
|
||||
if (denom !== undefined && inventory[denom] !== undefined) {
|
||||
inventory[denom] -= result.value[i]?.dispensed ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
if (result.error) {
|
||||
throw result.error
|
||||
}
|
||||
|
||||
// Wait for customer to take the bills
|
||||
await dispenser.waitForBillsRemoved()
|
||||
console.log('[HAL] Bills removed by customer')
|
||||
},
|
||||
|
||||
getInventory: async () => {
|
||||
return { ...inventory }
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
atmServices,
|
||||
|
||||
connectValidator: (callbacks: ValidatorCallbacks) => {
|
||||
validator.on('billsRead', (data: { denomination: number | null; code: number }) => {
|
||||
if (data.denomination !== null) {
|
||||
// Auto-stack the bill (accept it into the cash box)
|
||||
validator.stack()
|
||||
callbacks.onBillInserted(data.denomination)
|
||||
} else {
|
||||
console.log('[HAL] Unknown denomination, rejecting. Code: 0x' + data.code.toString(16))
|
||||
validator.reject()
|
||||
}
|
||||
})
|
||||
|
||||
validator.on('billsRejected', (data?: { reason: string; code: number | null }) => {
|
||||
callbacks.onBillRejected(data?.reason ?? 'unknown')
|
||||
})
|
||||
|
||||
validator.on('stackerOpen', () => {
|
||||
callbacks.onError('Stacker open')
|
||||
})
|
||||
|
||||
validator.on('error', (err: Error) => {
|
||||
callbacks.onError(err.message)
|
||||
})
|
||||
|
||||
validator.on('disconnected', () => {
|
||||
callbacks.onError('Validator disconnected')
|
||||
})
|
||||
},
|
||||
|
||||
enableValidator: () => {
|
||||
validator.enable()
|
||||
validator.lightOn()
|
||||
},
|
||||
|
||||
disableValidator: () => {
|
||||
validator.disable()
|
||||
validator.lightOff()
|
||||
},
|
||||
|
||||
stackBill: () => validator.stack(),
|
||||
rejectBill: () => validator.reject(),
|
||||
|
||||
cleanup: async () => {
|
||||
return new Promise<void>((resolve) => {
|
||||
validator.disable()
|
||||
validator.lightOff()
|
||||
dispenser.close()
|
||||
validator.close((err?: Error) => {
|
||||
if (err) console.error('[HAL] Validator close error:', err)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
1167
apps/machine/src/services/lightning.ts
Normal file
1167
apps/machine/src/services/lightning.ts
Normal file
File diff suppressed because it is too large
Load diff
580
apps/machine/src/stores/atm.ts
Normal file
580
apps/machine/src/stores/atm.ts
Normal file
|
|
@ -0,0 +1,580 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import {
|
||||
createATMMachine,
|
||||
createActor,
|
||||
type ATMContext,
|
||||
type ATMServices,
|
||||
type ActorRefFrom,
|
||||
type SnapshotFrom,
|
||||
type ATMMachine,
|
||||
} from '@lamassu/state-machine'
|
||||
import { initializeLightningServices } from '@/services/lightning'
|
||||
import type { HalConfig, HalServices } from '@/services/hal'
|
||||
import { deviceConfig, toHalConfig } from '@/config'
|
||||
import type { LightningPubClient } from '@lamassu/lightning'
|
||||
import type { CLINKClient } from '@lamassu/clink'
|
||||
|
||||
// Mock services for development/fallback
|
||||
const mockServices: ATMServices = {
|
||||
generateClinkOffer: async (context) => {
|
||||
console.log('[Mock] Generating CLINK offer for', context.satsAmount, 'sats')
|
||||
return `noffer1mock${Date.now().toString(36)}`
|
||||
},
|
||||
|
||||
generateNdebit: async (context) => {
|
||||
console.log('[Mock] Generating ndebit for', context.satsAmount, 'sats')
|
||||
// Mock ndebit URI with amount
|
||||
return `clink:ndebit1mock${Date.now().toString(36)}?amount=${context.satsAmount}`
|
||||
},
|
||||
|
||||
generateLnurlWithdraw: async (context) => {
|
||||
console.log('[Mock] Generating LNURL-withdraw for', context.satsAmount, 'sats')
|
||||
// This is a mock LNURL - in reality it would be a bech32 encoded URL
|
||||
return `LNURL1MOCK${Date.now().toString(36).toUpperCase()}`
|
||||
},
|
||||
|
||||
generateInvoice: async (amountMsat) => {
|
||||
console.log('[Mock] Generating invoice for', amountMsat, 'msats')
|
||||
return `lnbc${amountMsat}n1mock${Date.now().toString(36)}`
|
||||
},
|
||||
|
||||
getExchangeRate: async (currency) => {
|
||||
console.log('[Mock] Fetching exchange rate for', currency)
|
||||
return 1000 // 1000 sats per USD (~$100k BTC)
|
||||
},
|
||||
|
||||
sendNostrReceipt: async (context) => {
|
||||
console.log('[Mock] Sending Nostr receipt to', context.userNpub)
|
||||
},
|
||||
|
||||
dispenseCash: async (amounts) => {
|
||||
console.log('[Mock] Dispensing cash:', amounts)
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000))
|
||||
},
|
||||
|
||||
// noffer cash-out services
|
||||
generateNoffer: async () => {
|
||||
console.log('[Mock] Generating noffer for cash-out')
|
||||
return `noffer1mock${Date.now().toString(36)}`
|
||||
},
|
||||
|
||||
sendOfferResponse: async (request, invoice) => {
|
||||
console.log('[Mock] Sending offer response to', request.payerPubkey.slice(0, 16) + '...')
|
||||
console.log('[Mock] Invoice:', invoice.slice(0, 32) + '...')
|
||||
},
|
||||
|
||||
validateDispenseAmount: async (amountSats, exchangeRate) => {
|
||||
console.log('[Mock] Validating dispense amount:', amountSats, 'sats at rate', exchangeRate)
|
||||
const fiatDollars = amountSats / exchangeRate
|
||||
const fiatCents = Math.round(fiatDollars * 100)
|
||||
if (fiatCents < 100) {
|
||||
throw new Error(`Amount too small: $${(fiatCents / 100).toFixed(2)} (minimum $1.00)`)
|
||||
}
|
||||
return fiatCents
|
||||
},
|
||||
|
||||
watchInvoice: (paymentHash, _callback) => {
|
||||
console.log('[Mock] Watching invoice for payment:', paymentHash.slice(0, 20) + '...')
|
||||
// Note: Auto-pay disabled for testing real invoice monitoring
|
||||
// Use the "Simulate Payment Now" button in the UI to manually trigger payment
|
||||
return () => {}
|
||||
},
|
||||
|
||||
getInventory: async () => {
|
||||
console.log('[Mock] Getting inventory')
|
||||
// Mock inventory: only $20 bills for Phase 1
|
||||
return { 20: 50 }
|
||||
},
|
||||
}
|
||||
|
||||
export const useAtmStore = defineStore('atm', () => {
|
||||
// State
|
||||
const actor = ref<ActorRefFrom<ATMMachine> | null>(null)
|
||||
const snapshot = ref<SnapshotFrom<ATMMachine> | null>(null)
|
||||
const debugMode = ref(import.meta.env.DEV)
|
||||
const useLiveServices = ref(false)
|
||||
const connectionStatus = ref<'disconnected' | 'connecting' | 'connected' | 'error'>(
|
||||
'disconnected'
|
||||
)
|
||||
const lightningPub = ref<LightningPubClient | null>(null)
|
||||
const clinkClient = ref<CLINKClient | null>(null)
|
||||
const halServices = ref<HalServices | null>(null)
|
||||
const isPayingInvoice = ref(false)
|
||||
const isRequestingDebit = ref(false)
|
||||
const paymentError = ref<string | null>(null)
|
||||
const lnurlWithdrawUri = ref<string | null>(null)
|
||||
|
||||
// Store reference to ATM services for direct calls
|
||||
let atmServicesRef: ATMServices | null = null
|
||||
|
||||
// Computed
|
||||
const currentState = computed(() => {
|
||||
if (!snapshot.value) return 'uninitialized'
|
||||
const value = snapshot.value.value
|
||||
if (typeof value === 'string') return value
|
||||
// Handle nested states
|
||||
return JSON.stringify(value)
|
||||
})
|
||||
|
||||
const context = computed((): ATMContext | null => {
|
||||
return snapshot.value?.context ?? null
|
||||
})
|
||||
|
||||
const isIdle = computed(() => currentState.value === 'idle')
|
||||
|
||||
const isCashIn = computed(() => {
|
||||
const state = snapshot.value?.value
|
||||
return typeof state === 'object' && 'cashIn' in state
|
||||
})
|
||||
|
||||
const isCashOut = computed(() => {
|
||||
const state = snapshot.value?.value
|
||||
return typeof state === 'object' && 'cashOut' in state
|
||||
})
|
||||
|
||||
// Get the nested state for cashIn or cashOut
|
||||
const nestedState = computed(() => {
|
||||
const state = snapshot.value?.value
|
||||
if (typeof state === 'object') {
|
||||
if ('cashIn' in state) return state.cashIn as string
|
||||
if ('cashOut' in state) return state.cashOut as string
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
// Actions
|
||||
function initialize(services: ATMServices = mockServices) {
|
||||
atmServicesRef = services
|
||||
const machine = createATMMachine(services)
|
||||
actor.value = createActor(machine)
|
||||
|
||||
// Subscribe to state changes
|
||||
actor.value.subscribe((newSnapshot: SnapshotFrom<ATMMachine>) => {
|
||||
snapshot.value = newSnapshot
|
||||
console.log('[ATM] State:', newSnapshot.value)
|
||||
})
|
||||
|
||||
// Start the machine
|
||||
actor.value.start()
|
||||
console.log('[ATM] State machine initialized')
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize with real Lightning.Pub services
|
||||
*/
|
||||
async function initializeWithLightning() {
|
||||
connectionStatus.value = 'connecting'
|
||||
console.log('[ATM] Connecting to Lightning.Pub...')
|
||||
|
||||
try {
|
||||
const services = await initializeLightningServices()
|
||||
useLiveServices.value = true
|
||||
connectionStatus.value = 'connected'
|
||||
console.log('[ATM] Connected to Lightning.Pub!')
|
||||
|
||||
// Store references to clients for direct operations
|
||||
lightningPub.value = services.lightningPub
|
||||
clinkClient.value = services.clink
|
||||
|
||||
// Wire up payment received callback
|
||||
services.onPaymentReceived((preimage) => {
|
||||
console.log('[ATM] Payment received via CLINK, preimage:', preimage.slice(0, 16) + '...')
|
||||
paymentReceived(preimage)
|
||||
})
|
||||
|
||||
// Wire up offer request callback - send to state machine for cash-out flow
|
||||
services.onOfferRequest((request, sender) => {
|
||||
console.log('[ATM] Offer request from:', sender.slice(0, 16) + '...')
|
||||
console.log('[ATM] Amount requested:', request.amount_sats, 'sats')
|
||||
|
||||
// Send to state machine if we're in the displayingNoffer state
|
||||
if (nestedState.value === 'displayingNoffer') {
|
||||
send({
|
||||
type: 'OFFER_REQUEST_RECEIVED',
|
||||
request: {
|
||||
eventId: request.offer || 'unknown',
|
||||
payerPubkey: sender,
|
||||
amountSats: request.amount_sats,
|
||||
description: request.description,
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Initialize state machine with real services
|
||||
initialize(services.atmServices)
|
||||
} catch (error) {
|
||||
console.error('[ATM] Failed to connect to Lightning.Pub:', error)
|
||||
connectionStatus.value = 'error'
|
||||
|
||||
// Fall back to mock services
|
||||
console.log('[ATM] Falling back to mock services')
|
||||
useLiveServices.value = false
|
||||
initialize(mockServices)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pay a Lightning invoice (for cash-in flow)
|
||||
* Customer provides their invoice, we pay it with the calculated sats amount
|
||||
*/
|
||||
async function payInvoice(invoice: string): Promise<boolean> {
|
||||
if (!lightningPub.value) {
|
||||
paymentError.value = 'Lightning services not initialized'
|
||||
console.error('[ATM] Cannot pay invoice: Lightning.Pub not initialized')
|
||||
return false
|
||||
}
|
||||
|
||||
// Get the calculated sats amount from context (based on inserted cash)
|
||||
const amountSats = context.value?.satsAmount
|
||||
if (!amountSats || amountSats <= 0) {
|
||||
paymentError.value = 'No amount calculated - insert cash first'
|
||||
console.error('[ATM] Cannot pay invoice: no sats amount in context')
|
||||
return false
|
||||
}
|
||||
|
||||
isPayingInvoice.value = true
|
||||
paymentError.value = null
|
||||
|
||||
try {
|
||||
console.log(
|
||||
'[ATM] Paying invoice:',
|
||||
invoice.slice(0, 40) + '...',
|
||||
'amount:',
|
||||
amountSats,
|
||||
'sats'
|
||||
)
|
||||
const result = await lightningPub.value.payInvoice(invoice, amountSats)
|
||||
|
||||
if (result.success && result.preimage) {
|
||||
console.log('[ATM] Payment successful! Preimage:', result.preimage.slice(0, 16) + '...')
|
||||
// Trigger the state machine transition
|
||||
paymentReceived(result.preimage)
|
||||
return true
|
||||
} else {
|
||||
paymentError.value = result.error || 'Payment failed'
|
||||
console.error('[ATM] Payment failed:', result.error)
|
||||
return false
|
||||
}
|
||||
} catch (error) {
|
||||
paymentError.value = error instanceof Error ? error.message : 'Payment failed'
|
||||
console.error('[ATM] Payment error:', error)
|
||||
return false
|
||||
} finally {
|
||||
isPayingInvoice.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request a CLINK Debit from customer's wallet (for cash-in flow)
|
||||
* Customer provides their Nostr pubkey, we request payment authorization
|
||||
*
|
||||
* Flow:
|
||||
* 1. ATM generates an invoice for the amount
|
||||
* 2. ATM sends DebitPaymentRequest with that invoice to customer's wallet
|
||||
* 3. Customer's wallet pays the invoice and returns preimage
|
||||
*/
|
||||
async function requestDebit(customerPubkey: string, amountSats?: number): Promise<boolean> {
|
||||
if (!clinkClient.value || !lightningPub.value) {
|
||||
paymentError.value = 'Lightning services not initialized'
|
||||
console.error('[ATM] Cannot request debit: Lightning services not initialized')
|
||||
return false
|
||||
}
|
||||
|
||||
// Use the amount from context if not specified
|
||||
const amount = amountSats ?? context.value?.satsAmount
|
||||
if (!amount) {
|
||||
paymentError.value = 'No amount specified'
|
||||
return false
|
||||
}
|
||||
|
||||
isRequestingDebit.value = true
|
||||
paymentError.value = null
|
||||
|
||||
try {
|
||||
console.log('[ATM] Requesting CLINK Debit from', customerPubkey.slice(0, 16) + '...')
|
||||
console.log('[ATM] Amount:', amount, 'sats')
|
||||
|
||||
// Step 1: Generate an invoice for the amount
|
||||
console.log('[ATM] Generating invoice for debit request...')
|
||||
const invoice = await lightningPub.value.createInvoice({
|
||||
amountSats: amount,
|
||||
description: `Lamassu ATM - Buy ${amount} sats`,
|
||||
})
|
||||
|
||||
if (!invoice.paymentRequest) {
|
||||
throw new Error('Failed to generate invoice')
|
||||
}
|
||||
|
||||
// Step 2: Send debit payment request with the invoice
|
||||
const response = await clinkClient.value.requestDebitPayment(
|
||||
customerPubkey,
|
||||
invoice.paymentRequest,
|
||||
{
|
||||
amountSats: amount,
|
||||
description: `Lamassu ATM - Buy ${amount} sats`,
|
||||
}
|
||||
)
|
||||
|
||||
if (response.res === 'ok' && response.preimage) {
|
||||
console.log(
|
||||
'[ATM] CLINK Debit successful! Preimage:',
|
||||
response.preimage.slice(0, 16) + '...'
|
||||
)
|
||||
// Trigger the state machine transition
|
||||
paymentReceived(response.preimage)
|
||||
return true
|
||||
} else {
|
||||
const errorResponse = response as { res: 'GFY'; error: string }
|
||||
paymentError.value = errorResponse.error || 'Debit request denied'
|
||||
console.error('[ATM] CLINK Debit failed:', errorResponse.error)
|
||||
return false
|
||||
}
|
||||
} catch (error) {
|
||||
paymentError.value = error instanceof Error ? error.message : 'Debit request failed'
|
||||
console.error('[ATM] CLINK Debit error:', error)
|
||||
return false
|
||||
} finally {
|
||||
isRequestingDebit.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an LNURL-withdraw link for the current cash-in amount
|
||||
* This allows any Lightning wallet to claim the sats
|
||||
*/
|
||||
async function generateLnurlWithdraw(): Promise<string> {
|
||||
if (!atmServicesRef) {
|
||||
throw new Error('ATM services not initialized')
|
||||
}
|
||||
|
||||
const ctx = context.value
|
||||
if (!ctx?.satsAmount) {
|
||||
throw new Error('No amount available - insert cash first')
|
||||
}
|
||||
|
||||
console.log('[ATM] Generating LNURL-withdraw for', ctx.satsAmount, 'sats')
|
||||
|
||||
const lnurl = await atmServicesRef.generateLnurlWithdraw(ctx)
|
||||
lnurlWithdrawUri.value = lnurl
|
||||
|
||||
console.log('[ATM] LNURL-withdraw generated:', lnurl.slice(0, 40) + '...')
|
||||
return lnurl
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize with real HAL hardware + Lightning services
|
||||
*
|
||||
* Connects to physical bill validator and dispenser, then merges
|
||||
* hardware services with Lightning payment services.
|
||||
*/
|
||||
async function initializeWithHal(halConfig: HalConfig) {
|
||||
connectionStatus.value = 'connecting'
|
||||
console.log('[ATM] Initializing HAL hardware...')
|
||||
|
||||
try {
|
||||
// Dynamic import - hal.ts uses Node.js APIs only available in Electron
|
||||
const { initializeHalServices } = await import('@/services/hal')
|
||||
const hal = await initializeHalServices(halConfig)
|
||||
halServices.value = hal
|
||||
console.log('[ATM] HAL hardware initialized')
|
||||
|
||||
// Initialize Lightning services
|
||||
const lightning = await initializeLightningServices()
|
||||
useLiveServices.value = true
|
||||
connectionStatus.value = 'connected'
|
||||
lightningPub.value = lightning.lightningPub
|
||||
clinkClient.value = lightning.clink
|
||||
|
||||
// Wire payment callbacks
|
||||
lightning.onPaymentReceived((preimage) => {
|
||||
paymentReceived(preimage)
|
||||
})
|
||||
|
||||
lightning.onOfferRequest((request, sender) => {
|
||||
if (nestedState.value === 'displayingNoffer') {
|
||||
send({
|
||||
type: 'OFFER_REQUEST_RECEIVED',
|
||||
request: {
|
||||
eventId: request.offer || 'unknown',
|
||||
payerPubkey: sender,
|
||||
amountSats: request.amount_sats,
|
||||
description: request.description,
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Merge HAL hardware services with Lightning payment services
|
||||
const mergedServices: ATMServices = {
|
||||
...lightning.atmServices,
|
||||
...hal.atmServices, // Override dispenseCash and getInventory with real hardware
|
||||
}
|
||||
|
||||
// Initialize state machine with merged services
|
||||
initialize(mergedServices)
|
||||
|
||||
// Wire validator events to state machine
|
||||
hal.connectValidator({
|
||||
onBillInserted: (denomination) => {
|
||||
send({ type: 'BILL_INSERTED', denomination })
|
||||
},
|
||||
onBillRejected: (reason) => {
|
||||
send({ type: 'BILL_REJECTED', reason })
|
||||
},
|
||||
onError: (error) => {
|
||||
send({ type: 'ERROR', error })
|
||||
},
|
||||
})
|
||||
|
||||
console.log('[ATM] Fully initialized with HAL + Lightning')
|
||||
} catch (error) {
|
||||
console.error('[ATM] HAL initialization failed:', error)
|
||||
connectionStatus.value = 'error'
|
||||
|
||||
// Fall back to mock services
|
||||
console.log('[ATM] Falling back to mock services')
|
||||
useLiveServices.value = false
|
||||
initialize(mockServices)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize for production using device configuration
|
||||
*
|
||||
* Loads device config from environment variables (with Sintra defaults)
|
||||
* and initializes HAL hardware + Lightning services.
|
||||
*
|
||||
* Environment variables (optional, have sensible defaults):
|
||||
* - VITE_LAMASSU_MACHINE_MODEL: sintra | gaia | custom
|
||||
* - VITE_LAMASSU_FIAT_CODE: USD, EUR, etc.
|
||||
* - VITE_LAMASSU_VALIDATOR_DEVICE: /dev/ttyJ5
|
||||
* - VITE_LAMASSU_DISPENSER_DEVICE: /dev/ttyJ7
|
||||
* - VITE_LAMASSU_CASSETTES: JSON array of cassette configs
|
||||
*/
|
||||
async function initializeForProduction() {
|
||||
console.log('[ATM] Initializing for production...')
|
||||
console.log('[ATM] Machine model:', deviceConfig.model)
|
||||
console.log('[ATM] Fiat currency:', deviceConfig.fiatCode)
|
||||
console.log('[ATM] Validator device:', deviceConfig.validator.device)
|
||||
console.log('[ATM] Dispenser device:', deviceConfig.dispenser.device)
|
||||
console.log('[ATM] Cassettes:', deviceConfig.dispenser.cassettes)
|
||||
|
||||
const halConfig = toHalConfig(deviceConfig)
|
||||
await initializeWithHal(halConfig)
|
||||
}
|
||||
|
||||
function send(event: Parameters<NonNullable<typeof actor.value>['send']>[0]) {
|
||||
if (!actor.value) {
|
||||
console.error('[ATM] Cannot send event: machine not initialized')
|
||||
return
|
||||
}
|
||||
console.log('[ATM] Sending event:', event)
|
||||
actor.value.send(event)
|
||||
}
|
||||
|
||||
// Convenience methods for common events
|
||||
function selectCashIn() {
|
||||
send({ type: 'SELECT_CASH_IN' })
|
||||
}
|
||||
|
||||
function selectCashOut() {
|
||||
send({ type: 'SELECT_CASH_OUT' })
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
send({ type: 'CANCEL' })
|
||||
}
|
||||
|
||||
function insertBill(denomination: number) {
|
||||
send({ type: 'BILL_INSERTED', denomination })
|
||||
}
|
||||
|
||||
function finishInserting() {
|
||||
send({ type: 'FINISH_INSERTING' })
|
||||
}
|
||||
|
||||
function selectAmount(amount: number) {
|
||||
send({ type: 'SELECT_AMOUNT', amount })
|
||||
}
|
||||
|
||||
function paymentReceived(preimage: string) {
|
||||
send({ type: 'PAYMENT_RECEIVED', preimage })
|
||||
}
|
||||
|
||||
function skipReceipt() {
|
||||
send({ type: 'SKIP_RECEIPT' })
|
||||
}
|
||||
|
||||
function cashTaken() {
|
||||
send({ type: 'CASH_DISPENSED' })
|
||||
}
|
||||
|
||||
// Cash-out selection methods
|
||||
function addDenomination(denomination: number) {
|
||||
send({ type: 'ADD_DENOMINATION', denomination })
|
||||
}
|
||||
|
||||
function removeDenomination(denomination: number) {
|
||||
send({ type: 'REMOVE_DENOMINATION', denomination })
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
send({ type: 'CLEAR_SELECTION' })
|
||||
}
|
||||
|
||||
function confirmAmount() {
|
||||
send({ type: 'CONFIRM_AMOUNT' })
|
||||
}
|
||||
|
||||
function toggleDebug() {
|
||||
debugMode.value = !debugMode.value
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
actor,
|
||||
snapshot,
|
||||
debugMode,
|
||||
useLiveServices,
|
||||
connectionStatus,
|
||||
halServices,
|
||||
isPayingInvoice,
|
||||
isRequestingDebit,
|
||||
paymentError,
|
||||
|
||||
// Computed
|
||||
currentState,
|
||||
context,
|
||||
isIdle,
|
||||
isCashIn,
|
||||
isCashOut,
|
||||
nestedState,
|
||||
|
||||
// Actions
|
||||
initialize,
|
||||
initializeWithLightning,
|
||||
initializeWithHal,
|
||||
initializeForProduction,
|
||||
send,
|
||||
selectCashIn,
|
||||
selectCashOut,
|
||||
cancel,
|
||||
insertBill,
|
||||
finishInserting,
|
||||
selectAmount,
|
||||
paymentReceived,
|
||||
payInvoice,
|
||||
requestDebit,
|
||||
generateLnurlWithdraw,
|
||||
lnurlWithdrawUri,
|
||||
skipReceipt,
|
||||
cashTaken,
|
||||
addDenomination,
|
||||
removeDenomination,
|
||||
clearSelection,
|
||||
confirmAmount,
|
||||
toggleDebug,
|
||||
}
|
||||
})
|
||||
123
apps/machine/src/style.css
Normal file
123
apps/machine/src/style.css
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
@import 'tailwindcss';
|
||||
@import 'tw-animate-css';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
/* ATM-specific semantic colors */
|
||||
--color-success: var(--success);
|
||||
--color-success-foreground: var(--success-foreground);
|
||||
--color-warning: var(--warning);
|
||||
--color-warning-foreground: var(--warning-foreground);
|
||||
--color-bitcoin: var(--bitcoin);
|
||||
--color-bitcoin-foreground: var(--bitcoin-foreground);
|
||||
/* QR code colors - high contrast for scanning */
|
||||
--color-qr: var(--qr);
|
||||
--color-qr-foreground: var(--qr-foreground);
|
||||
}
|
||||
|
||||
/*
|
||||
* Gruvbox Color Palette
|
||||
* https://github.com/morhetz/gruvbox
|
||||
*/
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
|
||||
/* Gruvbox Light */
|
||||
--background: #fbf1c7; /* bg0 hard */
|
||||
--foreground: #3c3836; /* fg1 */
|
||||
--card: #ebdbb2; /* bg1 */
|
||||
--card-foreground: #3c3836;
|
||||
--popover: #ebdbb2;
|
||||
--popover-foreground: #3c3836;
|
||||
--primary: #d65d0e; /* orange */
|
||||
--primary-foreground: #fbf1c7;
|
||||
--secondary: #d5c4a1; /* bg2 */
|
||||
--secondary-foreground: #3c3836;
|
||||
--muted: #d5c4a1;
|
||||
--muted-foreground: #665c54; /* bg3 */
|
||||
--accent: #458588; /* blue */
|
||||
--accent-foreground: #fbf1c7;
|
||||
--destructive: #cc241d; /* red */
|
||||
--border: #bdae93; /* fg3 */
|
||||
--input: #bdae93;
|
||||
--ring: #d65d0e;
|
||||
|
||||
/* ATM semantic colors - light */
|
||||
--success: #98971a; /* green */
|
||||
--success-foreground: #fbf1c7;
|
||||
--warning: #d79921; /* yellow */
|
||||
--warning-foreground: #3c3836;
|
||||
--bitcoin: #d65d0e; /* orange */
|
||||
--bitcoin-foreground: #fbf1c7;
|
||||
|
||||
/* QR code - high contrast for scanning */
|
||||
--qr: #fbf1c7; /* bg0 - light background */
|
||||
--qr-foreground: #282828; /* dark0 - dark modules */
|
||||
}
|
||||
|
||||
.dark {
|
||||
/* Gruvbox Dark */
|
||||
--background: #282828; /* bg0 */
|
||||
--foreground: #ebdbb2; /* fg1 */
|
||||
--card: #3c3836; /* bg1 */
|
||||
--card-foreground: #ebdbb2;
|
||||
--popover: #3c3836;
|
||||
--popover-foreground: #ebdbb2;
|
||||
--primary: #fe8019; /* bright orange */
|
||||
--primary-foreground: #282828;
|
||||
--secondary: #504945; /* bg2 */
|
||||
--secondary-foreground: #ebdbb2;
|
||||
--muted: #504945;
|
||||
--muted-foreground: #a89984; /* fg4 */
|
||||
--accent: #83a598; /* bright blue */
|
||||
--accent-foreground: #282828;
|
||||
--destructive: #fb4934; /* bright red */
|
||||
--border: #504945;
|
||||
--input: #504945;
|
||||
--ring: #fe8019;
|
||||
|
||||
/* ATM semantic colors - dark */
|
||||
--success: #b8bb26; /* bright green */
|
||||
--success-foreground: #282828;
|
||||
--warning: #fabd2f; /* bright yellow */
|
||||
--warning-foreground: #282828;
|
||||
--bitcoin: #fe8019; /* bright orange */
|
||||
--bitcoin-foreground: #282828;
|
||||
|
||||
/* QR code - warm tones easier on eyes in dark environments */
|
||||
--qr: #f5f0e6; /* warm cream - softer than pure white */
|
||||
--qr-foreground: #3c3836; /* gruvbox fg - maintains contrast */
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
28
apps/machine/src/types/electron.d.ts
vendored
Normal file
28
apps/machine/src/types/electron.d.ts
vendored
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
/**
|
||||
* Type declarations for Electron API exposed via preload
|
||||
*/
|
||||
|
||||
export interface RuntimeConfig {
|
||||
relayUrl: string
|
||||
lightningPubPubkey: string
|
||||
lightningPubApiUrl: string
|
||||
atmPrivateKey: string
|
||||
adminToken: string
|
||||
machineModel: string
|
||||
fiatCode: string
|
||||
validatorDevice?: string
|
||||
dispenserDevice?: string
|
||||
cassettes?: string
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electronAPI?: {
|
||||
getVersion: () => Promise<string>
|
||||
getConfig: () => Promise<RuntimeConfig>
|
||||
platform: NodeJS.Platform
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
476
apps/machine/src/views/CashInView.vue
Normal file
476
apps/machine/src/views/CashInView.vue
Normal file
|
|
@ -0,0 +1,476 @@
|
|||
<script setup lang="ts">
|
||||
import { watch, computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAtmStore } from '@/stores/atm'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import QRCode from '@/components/QRCode.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const atmStore = useAtmStore()
|
||||
|
||||
// CLINK Debit: customer's npub/pubkey
|
||||
const pubkeyInput = ref('')
|
||||
|
||||
// Invoice input for manual payment
|
||||
const invoiceInput = ref('')
|
||||
|
||||
// Copy state for ndebit URI
|
||||
const copied = ref(false)
|
||||
|
||||
// QR display mode: unified (default), lnurl-only, or clink-only
|
||||
const qrMode = ref<'unified' | 'lnurl' | 'clink'>('unified')
|
||||
const lnurlWithdraw = ref<string | null>(null)
|
||||
const isGeneratingLnurl = ref(false)
|
||||
|
||||
// Generate LNURL-withdraw when entering displayingQR state
|
||||
watch(
|
||||
() => atmStore.nestedState,
|
||||
async (state) => {
|
||||
if (state === 'displayingQR' && !lnurlWithdraw.value && !isGeneratingLnurl.value) {
|
||||
isGeneratingLnurl.value = true
|
||||
try {
|
||||
lnurlWithdraw.value = await atmStore.generateLnurlWithdraw()
|
||||
} catch (err) {
|
||||
console.error('[CashIn] Failed to generate LNURL-withdraw:', err)
|
||||
} finally {
|
||||
isGeneratingLnurl.value = false
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Compute the unified QR value (lightning + clink on separate lines)
|
||||
const unifiedQrValue = computed(() => {
|
||||
const ndebit = atmStore.context?.ndebitUri
|
||||
const lnurl = lnurlWithdraw.value
|
||||
|
||||
if (lnurl && ndebit) {
|
||||
// Unified: lightning scheme first (more universal), then clink
|
||||
return `lightning:${lnurl}\n${ndebit}`
|
||||
} else if (lnurl) {
|
||||
return `lightning:${lnurl}`
|
||||
} else if (ndebit) {
|
||||
return ndebit
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
// Get the appropriate QR value based on mode
|
||||
const currentQrValue = computed(() => {
|
||||
switch (qrMode.value) {
|
||||
case 'unified':
|
||||
return unifiedQrValue.value
|
||||
case 'lnurl':
|
||||
return lnurlWithdraw.value ? `lightning:${lnurlWithdraw.value}` : null
|
||||
case 'clink':
|
||||
return atmStore.context?.ndebitUri || null
|
||||
}
|
||||
})
|
||||
|
||||
// Copy current QR value to clipboard
|
||||
async function copyQrValue() {
|
||||
const uri = currentQrValue.value
|
||||
if (!uri) return
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(uri)
|
||||
copied.value = true
|
||||
setTimeout(() => {
|
||||
copied.value = false
|
||||
}, 2000)
|
||||
} catch (err) {
|
||||
console.error('[CashIn] Failed to copy:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Navigate back to idle when transaction completes
|
||||
watch(
|
||||
() => atmStore.isIdle,
|
||||
(isIdle) => {
|
||||
if (isIdle) {
|
||||
router.push('/')
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const nestedState = computed(() => atmStore.nestedState)
|
||||
const context = computed(() => atmStore.context)
|
||||
|
||||
function cancel() {
|
||||
atmStore.cancel()
|
||||
}
|
||||
|
||||
function finishInserting() {
|
||||
atmStore.finishInserting()
|
||||
}
|
||||
|
||||
function skipReceipt() {
|
||||
atmStore.skipReceipt()
|
||||
}
|
||||
|
||||
// Simulate bill insertion (for development)
|
||||
function simulateBill(denomination: number) {
|
||||
atmStore.insertBill(denomination)
|
||||
}
|
||||
|
||||
// Convert npub to hex pubkey if needed
|
||||
function normalizePubkey(input: string): string {
|
||||
const trimmed = input.trim()
|
||||
// If it's an npub, we'd need to decode it - for now just pass through
|
||||
// In production, use nostr-tools nip19.decode()
|
||||
if (trimmed.startsWith('npub1')) {
|
||||
// Simple check - in production decode properly
|
||||
console.log('[CashIn] npub detected, would decode in production')
|
||||
// For now, just return as-is and let the CLINK client handle it
|
||||
// TODO: Properly decode npub using nostr-tools
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
// Request CLINK Debit (Nostr-native payment)
|
||||
async function submitDebitRequest() {
|
||||
const pubkey = normalizePubkey(pubkeyInput.value)
|
||||
if (!pubkey) return
|
||||
|
||||
// Basic validation - should be 64 hex chars or npub
|
||||
if (!pubkey.startsWith('npub1') && !/^[0-9a-fA-F]{64}$/.test(pubkey)) {
|
||||
alert('Please enter a valid Nostr pubkey (64 hex chars) or npub')
|
||||
return
|
||||
}
|
||||
|
||||
await atmStore.requestDebit(pubkey)
|
||||
}
|
||||
|
||||
// Pay invoice (manual BOLT-11 payment)
|
||||
async function submitInvoice() {
|
||||
const invoice = invoiceInput.value.trim()
|
||||
if (!invoice) return
|
||||
|
||||
// Basic validation - should start with lnbc or lntb
|
||||
if (!invoice.toLowerCase().startsWith('lnbc') && !invoice.toLowerCase().startsWith('lntb')) {
|
||||
alert('Please enter a valid Lightning invoice (starts with lnbc or lntb)')
|
||||
return
|
||||
}
|
||||
|
||||
await atmStore.payInvoice(invoice)
|
||||
}
|
||||
|
||||
function formatSats(sats: number): string {
|
||||
return sats.toLocaleString()
|
||||
}
|
||||
|
||||
const isProcessing = computed(() => atmStore.isPayingInvoice || atmStore.isRequestingDebit)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-1 flex-col">
|
||||
<!-- Header -->
|
||||
<header class="flex items-center bg-black/30 px-8 py-4">
|
||||
<Button variant="ghost" class="text-muted-foreground" @click="cancel">← Cancel</Button>
|
||||
<h2 class="flex-1 text-center text-xl font-semibold">Buy Bitcoin</h2>
|
||||
<div class="w-20"></div>
|
||||
</header>
|
||||
|
||||
<!-- Content -->
|
||||
<main class="flex flex-1 items-center justify-center p-8">
|
||||
<!-- Fetching Rate -->
|
||||
<Card v-if="nestedState === 'fetchingRate'" class="w-80 border-0 bg-white/5">
|
||||
<CardContent class="flex flex-col items-center gap-4 p-8">
|
||||
<Skeleton class="h-10 w-10 rounded-full" />
|
||||
<p class="text-muted-foreground">Fetching exchange rate...</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Inserting Bills -->
|
||||
<Card v-else-if="nestedState === 'insertingBills'" class="w-96 border-0 bg-white/5">
|
||||
<CardHeader class="text-center">
|
||||
<CardTitle>Insert Cash</CardTitle>
|
||||
<CardDescription v-if="context">
|
||||
Rate: {{ formatSats(context.exchangeRate) }} sats/$
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-6">
|
||||
<!-- Bill Status -->
|
||||
<div v-if="context" class="rounded-lg bg-white/5 p-4">
|
||||
<div class="mb-2 flex justify-between">
|
||||
<span class="text-muted-foreground">Inserted:</span>
|
||||
<span class="text-2xl font-bold text-green-400">${{ context.fiatAmount / 100 }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">You'll receive:</span>
|
||||
<span class="font-semibold text-orange-400"
|
||||
>{{ formatSats(context.satsAmount) }} sats</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bill Acceptor Visual -->
|
||||
<div class="rounded-lg border-2 border-dashed border-white/20 p-6 text-center">
|
||||
<div class="mb-2 text-4xl">💵 ➡️ 🏧</div>
|
||||
<p class="text-sm text-muted-foreground">Insert bills into the acceptor</p>
|
||||
</div>
|
||||
|
||||
<!-- Dev: Simulate bill insertion -->
|
||||
<Alert v-if="atmStore.debugMode" class="border-red-500/50 bg-red-500/10">
|
||||
<AlertDescription>
|
||||
<p class="mb-2 text-xs text-red-400">Dev: Simulate bills</p>
|
||||
<div class="flex flex-wrap justify-center gap-2">
|
||||
<Button
|
||||
v-for="denom in [1, 5, 10, 20, 50, 100]"
|
||||
:key="denom"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-8"
|
||||
@click="simulateBill(denom)"
|
||||
>
|
||||
${{ denom }}
|
||||
</Button>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Button
|
||||
class="w-full bg-gradient-to-r from-orange-500 to-yellow-400 text-black hover:from-orange-600 hover:to-yellow-500"
|
||||
size="lg"
|
||||
:disabled="!context || context.billsInserted.length === 0"
|
||||
@click="finishInserting"
|
||||
>
|
||||
Done Inserting
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Generating NDebit -->
|
||||
<Card v-else-if="nestedState === 'generatingNdebit'" class="w-80 border-0 bg-white/5">
|
||||
<CardContent class="flex flex-col items-center gap-4 p-8">
|
||||
<Skeleton class="h-10 w-10 rounded-full" />
|
||||
<p class="text-muted-foreground">Preparing payment code...</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Display QR (unified lightning + clink) -->
|
||||
<Card v-else-if="nestedState === 'displayingQR'" class="w-[420px] border-0 bg-white/5">
|
||||
<CardContent class="pt-6 space-y-5">
|
||||
<!-- Amount Display -->
|
||||
<div class="text-center">
|
||||
<p class="text-sm text-muted-foreground">Withdraw</p>
|
||||
<p class="text-3xl font-bold text-orange-400">
|
||||
{{ formatSats(context?.satsAmount || 0) }} sats
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
${{ ((context?.fiatAmount || 0) / 100).toFixed(2) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Scan with selector -->
|
||||
<div class="text-center">
|
||||
<p class="text-xs text-muted-foreground mb-2">Scan with</p>
|
||||
<div class="flex flex-wrap gap-2 justify-center">
|
||||
<Button
|
||||
:variant="qrMode === 'unified' ? 'default' : 'outline'"
|
||||
size="sm"
|
||||
class="rounded-full px-4"
|
||||
@click="qrMode = 'unified'"
|
||||
>
|
||||
Universal
|
||||
</Button>
|
||||
<Button
|
||||
:variant="qrMode === 'lnurl' ? 'default' : 'outline'"
|
||||
size="sm"
|
||||
class="rounded-full px-4"
|
||||
@click="qrMode = 'lnurl'"
|
||||
>
|
||||
LNURL
|
||||
</Button>
|
||||
<Button
|
||||
:variant="qrMode === 'clink' ? 'default' : 'outline'"
|
||||
size="sm"
|
||||
class="rounded-full px-4"
|
||||
@click="qrMode = 'clink'"
|
||||
>
|
||||
CLINK
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- QR Code -->
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="relative">
|
||||
<Skeleton
|
||||
v-if="isGeneratingLnurl && !context?.ndebitUri"
|
||||
class="h-[240px] w-[240px] rounded-xl"
|
||||
/>
|
||||
<div v-else-if="currentQrValue" class="rounded-xl overflow-hidden bg-white p-3">
|
||||
<QRCode :value="currentQrValue" :size="216" />
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex h-[240px] w-[240px] items-center justify-center rounded-xl bg-white/10"
|
||||
>
|
||||
<p class="text-sm text-muted-foreground">Generating...</p>
|
||||
</div>
|
||||
<!-- Lightning bolt overlay -->
|
||||
<div
|
||||
v-if="currentQrValue"
|
||||
class="absolute inset-0 flex items-center justify-center pointer-events-none"
|
||||
>
|
||||
<div
|
||||
class="w-12 h-12 rounded-full bg-orange-500 flex items-center justify-center shadow-lg"
|
||||
>
|
||||
<span class="text-2xl">⚡</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mode label + truncated URI -->
|
||||
<div class="text-center space-y-1">
|
||||
<p class="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
{{
|
||||
qrMode === 'unified'
|
||||
? 'Universal'
|
||||
: qrMode === 'lnurl'
|
||||
? 'LNURL-withdraw'
|
||||
: 'CLINK ndebit'
|
||||
}}
|
||||
</p>
|
||||
<div v-if="currentQrValue" class="flex items-center justify-center gap-2">
|
||||
<code class="text-xs text-muted-foreground truncate max-w-[280px]">
|
||||
{{ currentQrValue.split('\n')[0].slice(0, 32) }}...
|
||||
</code>
|
||||
<Button variant="ghost" size="sm" class="h-6 w-6 p-0" @click="copyQrValue">
|
||||
<span v-if="copied">✓</span>
|
||||
<span v-else class="text-muted-foreground">📋</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Alternative: Manual Options (collapsed) -->
|
||||
<details class="rounded-lg bg-white/5 p-3">
|
||||
<summary class="cursor-pointer text-sm text-muted-foreground">
|
||||
Alternative: Manual payment options
|
||||
</summary>
|
||||
<div class="mt-4 space-y-4">
|
||||
<!-- CLINK Debit by pubkey -->
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs font-medium text-blue-400">Enter your npub/pubkey:</p>
|
||||
<div class="flex gap-2">
|
||||
<Input
|
||||
v-model="pubkeyInput"
|
||||
placeholder="npub1... or hex pubkey"
|
||||
class="flex-1 font-mono text-xs"
|
||||
:disabled="isProcessing"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="!pubkeyInput.trim() || isProcessing"
|
||||
@click="submitDebitRequest"
|
||||
>
|
||||
{{ atmStore.isRequestingDebit ? '...' : 'Send' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Invoice paste -->
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs font-medium text-orange-400">Or paste your invoice:</p>
|
||||
<div class="flex gap-2">
|
||||
<Input
|
||||
v-model="invoiceInput"
|
||||
placeholder="lnbc... or lntb..."
|
||||
class="flex-1 font-mono text-xs"
|
||||
:disabled="isProcessing"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="!invoiceInput.trim() || isProcessing"
|
||||
@click="submitInvoice"
|
||||
>
|
||||
{{ atmStore.isPayingInvoice ? '...' : 'Pay' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="atmStore.paymentError" class="text-xs text-red-400">
|
||||
Error: {{ atmStore.paymentError }}
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Debug: Simulate payment button -->
|
||||
<div v-if="atmStore.debugMode" class="pt-2 text-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="text-xs text-muted-foreground"
|
||||
@click="atmStore.paymentReceived('mock-preimage-' + Date.now())"
|
||||
>
|
||||
Dev: Skip to Success
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Ask for Receipt -->
|
||||
<Card v-else-if="nestedState === 'askForReceipt'" class="w-80 border-0 bg-white/5">
|
||||
<CardContent class="flex flex-col items-center gap-4 p-8">
|
||||
<div class="text-6xl text-green-400">✅</div>
|
||||
<CardTitle>Payment Sent!</CardTitle>
|
||||
<p class="text-muted-foreground">Would you like a receipt?</p>
|
||||
<div class="flex h-32 w-32 items-center justify-center rounded-lg bg-white">
|
||||
<p class="text-black">📱 Scan npub</p>
|
||||
</div>
|
||||
<Button variant="outline" @click="skipReceipt">No Thanks</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Sending Receipt -->
|
||||
<Card v-else-if="nestedState === 'sendingReceipt'" class="w-80 border-0 bg-white/5">
|
||||
<CardContent class="flex flex-col items-center gap-4 p-8">
|
||||
<Skeleton class="h-10 w-10 rounded-full" />
|
||||
<p class="text-muted-foreground">Sending receipt...</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Complete -->
|
||||
<Card v-else-if="nestedState === 'complete'" class="w-80 border-0 bg-green-900/30">
|
||||
<CardContent class="flex flex-col items-center gap-4 p-8 text-center">
|
||||
<div class="text-6xl">🎉</div>
|
||||
<CardTitle class="text-green-400">Transaction Complete!</CardTitle>
|
||||
<p v-if="context">
|
||||
You received
|
||||
<Badge variant="secondary">{{ formatSats(context.satsAmount) }} sats</Badge>
|
||||
</p>
|
||||
<p class="text-muted-foreground">Thank you!</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Error -->
|
||||
<Card v-else-if="nestedState === 'error'" class="w-80 border-0 bg-red-900/30">
|
||||
<CardContent class="flex flex-col items-center gap-4 p-8 text-center">
|
||||
<div class="text-6xl">⚠️</div>
|
||||
<CardTitle class="text-red-400">Error</CardTitle>
|
||||
<p class="text-muted-foreground">{{ context?.error || 'An error occurred' }}</p>
|
||||
<Button class="bg-gradient-to-r from-orange-500 to-yellow-400 text-black" @click="cancel">
|
||||
Return to Start
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Fallback -->
|
||||
<Card v-else class="w-80 border-0 bg-white/5">
|
||||
<CardContent class="p-8 text-center">
|
||||
<p class="text-muted-foreground">State: {{ nestedState }}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
334
apps/machine/src/views/CashOutView.vue
Normal file
334
apps/machine/src/views/CashOutView.vue
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
<script setup lang="ts">
|
||||
import { watch, computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAtmStore } from '@/stores/atm'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import QRCode from '@/components/QRCode.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const atmStore = useAtmStore()
|
||||
|
||||
// Copy invoice functionality
|
||||
const copyButtonText = ref('Copy Invoice')
|
||||
|
||||
async function copyInvoice() {
|
||||
if (!atmStore.context?.invoice) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(atmStore.context.invoice)
|
||||
copyButtonText.value = 'Copied!'
|
||||
setTimeout(() => {
|
||||
copyButtonText.value = 'Copy Invoice'
|
||||
}, 2000)
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err)
|
||||
copyButtonText.value = 'Copy failed'
|
||||
}
|
||||
}
|
||||
|
||||
// Navigate back to idle when transaction completes
|
||||
watch(
|
||||
() => atmStore.isIdle,
|
||||
(isIdle) => {
|
||||
if (isIdle) {
|
||||
router.push('/')
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const nestedState = computed(() => atmStore.nestedState)
|
||||
const context = computed(() => atmStore.context)
|
||||
|
||||
// Available denominations from inventory
|
||||
const availableDenominations = computed(() => {
|
||||
if (!context.value?.inventory) return []
|
||||
return Object.keys(context.value.inventory)
|
||||
.map(Number)
|
||||
.filter((d) => (context.value?.inventory[d] || 0) > 0)
|
||||
.sort((a, b) => b - a) // Largest first
|
||||
})
|
||||
|
||||
// Count of each denomination selected
|
||||
const selectionCounts = computed(() => {
|
||||
if (!context.value?.cashOutSelection) return {}
|
||||
const counts: Record<number, number> = {}
|
||||
for (const denom of context.value.cashOutSelection) {
|
||||
counts[denom] = (counts[denom] || 0) + 1
|
||||
}
|
||||
return counts
|
||||
})
|
||||
|
||||
// Check if we can add more of a denomination
|
||||
function canAdd(denom: number): boolean {
|
||||
if (!context.value) return false
|
||||
const available = context.value.inventory[denom] || 0
|
||||
const selected = selectionCounts.value[denom] || 0
|
||||
return selected < available
|
||||
}
|
||||
|
||||
// Check if we can remove a denomination
|
||||
function canRemove(denom: number): boolean {
|
||||
return (selectionCounts.value[denom] || 0) > 0
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
atmStore.cancel()
|
||||
}
|
||||
|
||||
function confirmCashTaken() {
|
||||
atmStore.cashTaken()
|
||||
}
|
||||
|
||||
function skipReceipt() {
|
||||
atmStore.skipReceipt()
|
||||
}
|
||||
|
||||
function formatSats(sats: number): string {
|
||||
return sats.toLocaleString()
|
||||
}
|
||||
|
||||
function formatFiat(cents: number): string {
|
||||
return (cents / 100).toFixed(2)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-1 flex-col">
|
||||
<!-- Header -->
|
||||
<header class="flex items-center bg-black/30 px-8 py-4">
|
||||
<Button variant="ghost" class="text-muted-foreground" @click="cancel">← Cancel</Button>
|
||||
<h2 class="flex-1 text-center text-xl font-semibold">Sell Bitcoin</h2>
|
||||
<div class="w-20"></div>
|
||||
</header>
|
||||
|
||||
<!-- Content -->
|
||||
<main class="flex flex-1 items-center justify-center p-8">
|
||||
<!-- Fetching Rate -->
|
||||
<Card v-if="nestedState === 'fetchingRate'" class="w-80 border-0 bg-white/5">
|
||||
<CardContent class="flex flex-col items-center gap-4 p-8">
|
||||
<Skeleton class="h-10 w-10 rounded-full" />
|
||||
<p class="text-muted-foreground">Fetching exchange rate...</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Selecting Amount - Main cash-out screen -->
|
||||
<Card v-else-if="nestedState === 'selectingAmount'" class="w-[520px] border-0 bg-white/5">
|
||||
<CardHeader class="text-center">
|
||||
<CardTitle>Select Cash Amount</CardTitle>
|
||||
<CardDescription v-if="context">
|
||||
Rate: <Badge variant="secondary">{{ formatSats(context.exchangeRate) }} sats/$</Badge>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-6">
|
||||
<!-- Denomination buttons -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div
|
||||
v-for="denom in availableDenominations"
|
||||
:key="denom"
|
||||
class="flex items-center justify-between rounded-lg bg-white/5 p-4"
|
||||
>
|
||||
<span class="text-2xl font-bold text-green-400">${{ denom }}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="!canRemove(denom)"
|
||||
@click="atmStore.removeDenomination(denom)"
|
||||
>
|
||||
-
|
||||
</Button>
|
||||
<span class="w-8 text-center text-lg">{{ selectionCounts[denom] || 0 }}</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="!canAdd(denom)"
|
||||
@click="atmStore.addDenomination(denom)"
|
||||
>
|
||||
+
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Selected amount summary -->
|
||||
<div class="rounded-lg bg-white/10 p-6 text-center">
|
||||
<p class="text-sm text-muted-foreground">You will receive</p>
|
||||
<p class="text-4xl font-bold text-green-400">
|
||||
${{ context ? formatFiat(context.fiatAmount) : '0.00' }}
|
||||
</p>
|
||||
<p class="mt-2 text-sm text-muted-foreground">
|
||||
You pay:
|
||||
<Badge variant="secondary" class="ml-1">
|
||||
{{ context ? formatSats(context.satsAmount) : 0 }} sats
|
||||
</Badge>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="flex gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
class="flex-1"
|
||||
:disabled="!context?.cashOutSelection?.length"
|
||||
@click="atmStore.clearSelection()"
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
<Button
|
||||
class="flex-1 bg-gradient-to-r from-orange-500 to-yellow-400 text-black hover:from-orange-600 hover:to-yellow-500"
|
||||
:disabled="!context?.cashOutSelection?.length"
|
||||
@click="atmStore.confirmAmount()"
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Instructions -->
|
||||
<Alert class="border-orange-500/50 bg-orange-500/10">
|
||||
<AlertDescription>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Select the cash amount you want to receive. You'll pay the equivalent in Bitcoin.
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Generating Invoice -->
|
||||
<Card v-else-if="nestedState === 'generatingInvoice'" class="w-80 border-0 bg-white/5">
|
||||
<CardContent class="flex flex-col items-center gap-4 p-8">
|
||||
<Skeleton class="h-10 w-10 rounded-full" />
|
||||
<p class="text-muted-foreground">Creating invoice...</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Displaying Invoice - Show QR for payment -->
|
||||
<Card v-else-if="nestedState === 'displayingInvoice'" class="w-[480px] border-0 bg-white/5">
|
||||
<CardHeader class="text-center">
|
||||
<CardTitle>Scan to Pay</CardTitle>
|
||||
<CardDescription v-if="context">
|
||||
Pay <Badge variant="secondary">{{ formatSats(context.satsAmount) }} sats</Badge> to
|
||||
receive <Badge class="bg-green-600">${{ formatFiat(context.fiatAmount) }}</Badge>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-6">
|
||||
<!-- Invoice QR Code -->
|
||||
<div class="flex flex-col items-center">
|
||||
<QRCode v-if="context?.invoice" :value="context.invoice" :size="220" />
|
||||
<p class="mt-4 text-center text-sm text-muted-foreground">
|
||||
Scan with any Lightning wallet
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Payment status -->
|
||||
<div class="flex flex-col items-center gap-4 py-4">
|
||||
<div class="h-12 w-12 animate-pulse rounded-full bg-orange-500/20">
|
||||
<div class="flex h-full items-center justify-center text-2xl">⏳</div>
|
||||
</div>
|
||||
<p class="text-center text-muted-foreground">Waiting for payment...</p>
|
||||
</div>
|
||||
|
||||
<!-- Invoice info with copy button -->
|
||||
<div v-if="context?.invoice" class="rounded-lg bg-white/5 p-3">
|
||||
<p class="mb-2 truncate text-center font-mono text-xs text-muted-foreground">
|
||||
{{ context.invoice.slice(0, 50) }}...
|
||||
</p>
|
||||
<div class="flex justify-center">
|
||||
<Button variant="outline" size="sm" @click="copyInvoice">
|
||||
{{ copyButtonText }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dev controls -->
|
||||
<Alert v-if="atmStore.debugMode" class="border-red-500/50 bg-red-500/10">
|
||||
<AlertDescription class="text-center">
|
||||
<p class="mb-2 text-xs text-red-400">Dev: Use alice-pay or button below</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="atmStore.paymentReceived('mock-preimage-' + Date.now())"
|
||||
>
|
||||
Simulate Payment
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Dispensing Cash -->
|
||||
<Card v-else-if="nestedState === 'dispensingCash'" class="w-80 border-0 bg-white/5">
|
||||
<CardContent class="flex flex-col items-center gap-4 p-8">
|
||||
<Skeleton class="h-10 w-10 rounded-full" />
|
||||
<p class="text-muted-foreground">Dispensing cash...</p>
|
||||
<p class="text-sm text-muted-foreground">Please wait</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Cash Ready -->
|
||||
<Card v-else-if="nestedState === 'waitingForCashTaken'" class="w-80 border-0 bg-green-900/30">
|
||||
<CardContent class="flex flex-col items-center gap-4 p-8 text-center">
|
||||
<div class="text-6xl text-green-400">✅</div>
|
||||
<CardTitle class="text-green-400">Cash Ready!</CardTitle>
|
||||
<p v-if="context">
|
||||
Please take your
|
||||
<Badge class="bg-green-600 text-lg">${{ formatFiat(context.fiatAmount) }}</Badge>
|
||||
</p>
|
||||
<Button
|
||||
class="bg-gradient-to-r from-orange-500 to-yellow-400 text-black hover:from-orange-600 hover:to-yellow-500"
|
||||
size="lg"
|
||||
@click="confirmCashTaken"
|
||||
>
|
||||
I've Taken the Cash
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Ask for Receipt -->
|
||||
<Card v-else-if="nestedState === 'askForReceipt'" class="w-80 border-0 bg-white/5">
|
||||
<CardContent class="flex flex-col items-center gap-4 p-8">
|
||||
<CardTitle>Would you like a receipt?</CardTitle>
|
||||
<p class="text-sm text-muted-foreground">Scan your npub to receive a Nostr DM receipt</p>
|
||||
<div class="flex h-32 w-32 items-center justify-center rounded-lg bg-white">
|
||||
<p class="text-black">📱 Scan npub</p>
|
||||
</div>
|
||||
<Button variant="outline" @click="skipReceipt">No Thanks</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Complete -->
|
||||
<Card v-else-if="nestedState === 'complete'" class="w-80 border-0 bg-green-900/30">
|
||||
<CardContent class="flex flex-col items-center gap-4 p-8 text-center">
|
||||
<div class="text-6xl">🎉</div>
|
||||
<CardTitle class="text-green-400">Transaction Complete!</CardTitle>
|
||||
<p class="text-muted-foreground">Thank you!</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Error -->
|
||||
<Card
|
||||
v-else-if="nestedState === 'error' || nestedState === 'dispenseError'"
|
||||
class="w-80 border-0 bg-red-900/30"
|
||||
>
|
||||
<CardContent class="flex flex-col items-center gap-4 p-8 text-center">
|
||||
<div class="text-6xl">⚠️</div>
|
||||
<CardTitle class="text-red-400">Error</CardTitle>
|
||||
<p class="text-muted-foreground">{{ context?.error || 'An error occurred' }}</p>
|
||||
<Button class="bg-gradient-to-r from-orange-500 to-yellow-400 text-black" @click="cancel">
|
||||
Return to Start
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Fallback -->
|
||||
<Card v-else class="w-80 border-0 bg-white/5">
|
||||
<CardContent class="p-8 text-center">
|
||||
<p class="text-muted-foreground">State: {{ nestedState }}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
84
apps/machine/src/views/IdleView.vue
Normal file
84
apps/machine/src/views/IdleView.vue
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
<script setup lang="ts">
|
||||
import { watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAtmStore } from '@/stores/atm'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
const router = useRouter()
|
||||
const atmStore = useAtmStore()
|
||||
|
||||
// Watch for state changes and navigate accordingly
|
||||
watch(
|
||||
() => atmStore.currentState,
|
||||
(state) => {
|
||||
if (state.includes('cashIn')) {
|
||||
router.push('/cash-in')
|
||||
} else if (state.includes('cashOut')) {
|
||||
router.push('/cash-out')
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
function handleCashIn() {
|
||||
atmStore.selectCashIn()
|
||||
}
|
||||
|
||||
function handleCashOut() {
|
||||
atmStore.selectCashOut()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-1 flex-col items-center justify-center gap-12 p-8">
|
||||
<!-- Logo -->
|
||||
<div class="text-center">
|
||||
<h1 class="text-5xl font-bold text-primary">⚡ Bitcoin ATM</h1>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex gap-8">
|
||||
<!-- Buy Bitcoin -->
|
||||
<Card
|
||||
class="h-52 w-52 cursor-pointer border-2 border-success/50 bg-success/20 transition-all hover:scale-105 hover:border-success hover:bg-success/30 active:scale-95"
|
||||
@click="handleCashIn"
|
||||
>
|
||||
<CardContent class="flex h-full flex-col items-center justify-center gap-2 p-6">
|
||||
<span class="text-5xl">💵</span>
|
||||
<span class="text-xl font-bold text-success">Buy Bitcoin</span>
|
||||
<span class="text-sm text-foreground/70">Insert cash, receive sats</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Sell Bitcoin -->
|
||||
<Card
|
||||
class="h-52 w-52 cursor-pointer border-2 border-warning/50 bg-warning/20 transition-all hover:scale-105 hover:border-warning hover:bg-warning/30 active:scale-95"
|
||||
@click="handleCashOut"
|
||||
>
|
||||
<CardContent class="flex h-full flex-col items-center justify-center gap-2 p-6">
|
||||
<span class="text-5xl">🏧</span>
|
||||
<span class="text-xl font-bold text-warning">Sell Bitcoin</span>
|
||||
<span class="text-sm text-foreground/70">Pay invoice, receive cash</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center gap-4">
|
||||
<Badge variant="outline" class="border-success text-success">No KYC</Badge>
|
||||
<Badge variant="outline" class="border-primary text-primary">No Registration</Badge>
|
||||
<Badge variant="outline" class="border-bitcoin text-bitcoin">Just Bitcoin</Badge>
|
||||
</div>
|
||||
|
||||
<!-- Debug toggle -->
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="absolute bottom-4 right-4 text-xs text-muted-foreground"
|
||||
@click="atmStore.toggleDebug"
|
||||
>
|
||||
{{ atmStore.debugMode ? 'Hide Debug' : 'Show Debug' }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
7
apps/machine/src/vite-env.d.ts
vendored
Normal file
7
apps/machine/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<object, object, unknown>
|
||||
export default component
|
||||
}
|
||||
27
apps/machine/tsconfig.json
Normal file
27
apps/machine/tsconfig.json
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"strict": true,
|
||||
"strictNullChecks": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"baseUrl": "."
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
11
apps/machine/tsconfig.node.json
Normal file
11
apps/machine/tsconfig.node.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
33
apps/machine/vite.config.ts
Normal file
33
apps/machine/vite.config.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import path from 'node:path'
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
// Dev server configuration
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
},
|
||||
// Build configuration for Electron renderer
|
||||
build: {
|
||||
// Target modern Chrome (Electron uses Chromium)
|
||||
target: 'chrome120',
|
||||
// Sourcemaps for debugging
|
||||
sourcemap: process.env.NODE_ENV === 'development',
|
||||
rollupOptions: {
|
||||
// @lamassu/hal uses Node.js APIs (serialport, node:events) and will only
|
||||
// run in Electron's main process. Exclude from renderer bundle.
|
||||
external: ['@lamassu/hal'],
|
||||
},
|
||||
},
|
||||
// Prevent Vite from clearing the screen (useful when running with Electron)
|
||||
clearScreen: false,
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue