Add Tauri 2.x ATM kiosk application

- Vue 3 + TypeScript + Vite frontend
- Tauri 2.x Rust backend for kiosk shell
- shadcn-vue components (Button, Card, Badge, Alert, Skeleton)
- Gruvbox color theme with semantic ATM colors
- XState state machine integration via Pinia store
- Three views: Idle, CashIn (buy), CashOut (sell)
- Dev mode with state machine debug overlay
- 1080x1920 portrait kiosk configuration
- Cargo workspace for rustfmt integration

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-01-24 12:18:20 -05:00
commit f068cdf01d
41 changed files with 2322 additions and 20 deletions

3
Cargo.toml Normal file
View file

@ -0,0 +1,3 @@
[workspace]
resolver = "2"
members = ["lamassu-next/apps/machine/src-tauri"]

3
lamassu-next/Cargo.toml Normal file
View file

@ -0,0 +1,3 @@
[workspace]
resolver = "2"
members = ["apps/machine/src-tauri"]

View 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": {}
}

View 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>

View file

@ -0,0 +1,43 @@
{
"name": "@lamassu/machine",
"version": "0.1.0",
"description": "Tauri 2.x ATM kiosk application with Vue 3 UI",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"tauri": "tauri",
"tauri:dev": "tauri dev",
"tauri:build": "tauri build",
"typecheck": "vue-tsc --noEmit",
"lint": "eslint src/"
},
"dependencies": {
"@lamassu/clink": "workspace:*",
"@lamassu/lightning": "workspace:*",
"@lamassu/nostr-client": "workspace:*",
"@lamassu/state-machine": "workspace:*",
"@vueuse/core": "^14.1.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-vue-next": "^0.563.0",
"pinia": "^2.2.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",
"@tauri-apps/api": "^2.1.0",
"@tauri-apps/cli": "^2.1.0",
"@types/node": "^22.0.0",
"@vitejs/plugin-vue": "^5.2.0",
"tailwindcss": "^4.0.0",
"tw-animate-css": "^1.4.0",
"typescript": "^5.7.0",
"vite": "^6.0.0",
"vue-tsc": "^2.1.0"
}
}

View file

@ -0,0 +1,24 @@
[package]
name = "lamassu-machine"
version = "0.1.0"
description = "Lamassu ATM Kiosk Application"
authors = ["Lamassu Industries"]
edition = "2021"
rust-version = "1.70"
[lib]
name = "lamassu_machine_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-shell = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
[features]
# This feature is used for production builds or when a dev server is not specified
custom-protocol = ["tauri/custom-protocol"]

View file

@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View file

@ -0,0 +1,35 @@
//! Lamassu ATM Tauri Application
//!
//! This is the Rust backend for the Lamassu ATM kiosk application.
//! Hardware drivers and native functionality will be implemented here.
use tauri::Manager;
/// Greet command for testing Tauri IPC
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}! Welcome to Lamassu ATM.", name)
}
/// Get application version
#[tauri::command]
fn get_version() -> String {
env!("CARGO_PKG_VERSION").to_string()
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.setup(|app| {
#[cfg(debug_assertions)]
{
let window = app.get_webview_window("main").unwrap();
window.open_devtools();
}
Ok(())
})
.invoke_handler(tauri::generate_handler![greet, get_version])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

View file

@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
lamassu_machine_lib::run()
}

View file

@ -0,0 +1,39 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Lamassu ATM",
"version": "0.1.0",
"identifier": "io.lamassu.atm",
"build": {
"beforeDevCommand": "pnpm dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "pnpm build",
"frontendDist": "../dist"
},
"app": {
"withGlobalTauri": true,
"windows": [
{
"title": "Lamassu ATM",
"width": 1080,
"height": 1920,
"resizable": false,
"fullscreen": false,
"decorations": true
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}

View file

@ -0,0 +1,28 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { useAtmStore } from '@/stores/atm'
const atmStore = useAtmStore()
onMounted(() => {
// Initialize the ATM state machine
atmStore.initialize()
})
</script>
<template>
<div
class="flex h-screen w-screen flex-col overflow-hidden bg-background font-sans text-foreground"
>
<router-view />
<!-- 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 text-success">State: {{ atmStore.currentState }}</div>
<pre class="text-muted-foreground">{{ JSON.stringify(atmStore.context, null, 2) }}</pre>
</div>
</div>
</template>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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'

View 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>

View file

@ -0,0 +1 @@
export { default as Skeleton } from './Skeleton.vue'

View 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))
}

View 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')

View file

@ -0,0 +1,180 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import {
createATMMachine,
createActor,
type ATMContext,
type ATMServices,
} from '@lamassu/state-machine'
import type { Actor, SnapshotFrom } from 'xstate'
// Service implementations will be injected
// For now, use mock implementations for development
const mockServices: ATMServices = {
generateClinkOffer: async (context) => {
console.log('[Mock] Generating CLINK offer for', context.satsAmount, 'sats')
// Return a fake noffer for development
return `noffer1mock${Date.now().toString(36)}`
},
generateInvoice: async (amountMsat) => {
console.log('[Mock] Generating invoice for', amountMsat, 'msats')
// Return a fake invoice for development
return `lnbc${amountMsat}n1mock${Date.now().toString(36)}`
},
getExchangeRate: async (currency) => {
console.log('[Mock] Fetching exchange rate for', currency)
// Return mock rate: 2500 sats per USD (40k USD/BTC)
return 2500
},
sendNostrReceipt: async (context) => {
console.log('[Mock] Sending Nostr receipt to', context.userNpub)
return true
},
dispenseCash: async (amounts) => {
console.log('[Mock] Dispensing cash:', amounts)
// Simulate dispense delay
await new Promise((resolve) => setTimeout(resolve, 2000))
return true
},
}
export const useAtmStore = defineStore('atm', () => {
// State
const actor = ref<Actor<ReturnType<typeof createATMMachine>> | null>(null)
const snapshot = ref<SnapshotFrom<ReturnType<typeof createATMMachine>> | null>(null)
const debugMode = ref(import.meta.env.DEV)
// 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) {
const machine = createATMMachine(services)
actor.value = createActor(machine)
// Subscribe to state changes
actor.value.subscribe((newSnapshot) => {
snapshot.value = newSnapshot
console.log('[ATM] State:', newSnapshot.value)
})
// Start the machine
actor.value.start()
console.log('[ATM] State machine initialized')
}
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' })
}
function toggleDebug() {
debugMode.value = !debugMode.value
}
return {
// State
actor,
snapshot,
debugMode,
// Computed
currentState,
context,
isIdle,
isCashIn,
isCashOut,
nestedState,
// Actions
initialize,
send,
selectCashIn,
selectCashOut,
cancel,
insertBill,
finishInserting,
selectAmount,
paymentReceived,
skipReceipt,
cashTaken,
toggleDebug,
}
})

View file

@ -0,0 +1,112 @@
@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);
}
/*
* 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;
}
.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;
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

View file

@ -0,0 +1,231 @@
<script setup lang="ts">
import { watch, computed } 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'
const router = useRouter()
const atmStore = useAtmStore()
// 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)
}
function formatSats(sats: number): string {
return sats.toLocaleString()
}
</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 Offer -->
<Card v-else-if="nestedState === 'generatingOffer'" 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">Generating CLINK offer...</p>
</CardContent>
</Card>
<!-- Display QR -->
<Card v-else-if="nestedState === 'displayingQR'" class="w-96 border-0 bg-white/5">
<CardHeader class="text-center">
<CardTitle>Scan to Receive</CardTitle>
<CardDescription v-if="context">
<Badge variant="secondary" class="text-lg">
{{ formatSats(context.satsAmount) }} sats
</Badge>
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div
class="mx-auto flex h-52 w-52 flex-col items-center justify-center rounded-lg bg-white"
>
<p class="text-3xl">📱</p>
<p class="text-sm text-black">QR Code</p>
<code
v-if="context?.clinkOffer"
class="mt-2 block max-w-full break-all px-2 text-xs text-zinc-500"
>
{{ context.clinkOffer.slice(0, 24) }}...
</code>
</div>
<p class="text-center text-sm text-muted-foreground">
Open your Lightning wallet and scan the code
</p>
<!-- Dev controls -->
<Alert v-if="atmStore.debugMode" class="border-red-500/50 bg-red-500/10">
<AlertDescription class="text-center">
<Button
variant="outline"
size="sm"
@click="atmStore.paymentReceived('mock-preimage-' + Date.now())"
>
Dev: Simulate Payment
</Button>
</AlertDescription>
</Alert>
</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 for using Lamassu</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>

View file

@ -0,0 +1,219 @@
<script setup lang="ts">
import { watch, computed } 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'
const router = useRouter()
const atmStore = useAtmStore()
// 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 for cash-out
const amounts = [20, 40, 60, 80, 100]
function selectAmount(amount: number) {
atmStore.selectAmount(amount)
}
function cancel() {
atmStore.cancel()
}
function confirmCashTaken() {
atmStore.cashTaken()
}
function skipReceipt() {
atmStore.skipReceipt()
}
function formatSats(sats: number): string {
return sats.toLocaleString()
}
</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>
<!-- Select Amount -->
<Card v-else-if="nestedState === 'selectingAmount'" class="border-0 bg-white/5">
<CardHeader class="text-center">
<CardTitle>Select Amount</CardTitle>
<CardDescription v-if="context">
Rate: {{ formatSats(context.exchangeRate) }} sats/$
</CardDescription>
</CardHeader>
<CardContent>
<div class="grid grid-cols-3 gap-4">
<Card
v-for="amount in amounts"
:key="amount"
class="cursor-pointer border-2 border-transparent bg-white/5 transition hover:border-orange-500 hover:bg-white/10"
@click="selectAmount(amount)"
>
<CardContent class="flex flex-col items-center p-4">
<span class="text-2xl font-bold">${{ amount }}</span>
<span v-if="context" class="mt-1 text-sm text-muted-foreground">
≈ {{ formatSats(Math.floor(amount * context.exchangeRate)) }} sats
</span>
</CardContent>
</Card>
</div>
</CardContent>
</Card>
<!-- Generating Invoice -->
<Card
v-else-if="nestedState === 'calculatingInvoice' || 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">Generating invoice...</p>
</CardContent>
</Card>
<!-- Display Invoice QR -->
<Card v-else-if="nestedState === 'displayingInvoice'" class="w-96 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">${{ context.fiatAmount / 100 }}</Badge>
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div
class="mx-auto flex h-52 w-52 flex-col items-center justify-center rounded-lg bg-white"
>
<p class="text-3xl">📱</p>
<p class="text-sm text-black">QR Code</p>
<code
v-if="context?.invoice"
class="mt-2 block max-w-full break-all px-2 text-xs text-zinc-500"
>
{{ context.invoice.slice(0, 24) }}...
</code>
</div>
<p class="text-center text-sm text-muted-foreground">Waiting for payment...</p>
<!-- Dev controls -->
<Alert v-if="atmStore.debugMode" class="border-red-500/50 bg-red-500/10">
<AlertDescription class="text-center">
<Button
variant="outline"
size="sm"
@click="atmStore.paymentReceived('mock-preimage-' + Date.now())"
>
Dev: 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">${{ context.fiatAmount / 100 }}</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 for using Lamassu</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>

View file

@ -0,0 +1,85 @@
<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">⚡ Lamassu</h1>
<p class="mt-2 text-lg text-muted-foreground">Bitcoin ATM</p>
</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>

View 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
}

View 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" }]
}

View file

@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

View 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'),
},
},
// Tauri expects a fixed port during development
server: {
port: 1420,
strictPort: true,
watch: {
ignored: ['**/src-tauri/**'],
},
},
// Build configuration for Tauri
build: {
// Tauri uses Chromium on Windows and WebKit on macOS and Linux
target: process.env.TAURI_ENV_PLATFORM === 'windows' ? 'chrome105' : 'safari14',
// Don't minify for debug builds
minify: !process.env.TAURI_ENV_DEBUG ? 'esbuild' : false,
// Produce sourcemaps for debug builds
sourcemap: !!process.env.TAURI_ENV_DEBUG,
},
// Prevent Vite from clearing the screen
clearScreen: false,
})

File diff suppressed because it is too large Load diff