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:
parent
df27e59dcb
commit
f068cdf01d
41 changed files with 2322 additions and 20 deletions
3
Cargo.toml
Normal file
3
Cargo.toml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["lamassu-next/apps/machine/src-tauri"]
|
||||
3
lamassu-next/Cargo.toml
Normal file
3
lamassu-next/Cargo.toml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["apps/machine/src-tauri"]
|
||||
21
lamassu-next/apps/machine/components.json
Normal file
21
lamassu-next/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": {}
|
||||
}
|
||||
27
lamassu-next/apps/machine/index.html
Normal file
27
lamassu-next/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>
|
||||
43
lamassu-next/apps/machine/package.json
Normal file
43
lamassu-next/apps/machine/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
24
lamassu-next/apps/machine/src-tauri/Cargo.toml
Normal file
24
lamassu-next/apps/machine/src-tauri/Cargo.toml
Normal 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"]
|
||||
3
lamassu-next/apps/machine/src-tauri/build.rs
Normal file
3
lamassu-next/apps/machine/src-tauri/build.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
35
lamassu-next/apps/machine/src-tauri/src/lib.rs
Normal file
35
lamassu-next/apps/machine/src-tauri/src/lib.rs
Normal 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");
|
||||
}
|
||||
6
lamassu-next/apps/machine/src-tauri/src/main.rs
Normal file
6
lamassu-next/apps/machine/src-tauri/src/main.rs
Normal 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()
|
||||
}
|
||||
39
lamassu-next/apps/machine/src-tauri/tauri.conf.json
Normal file
39
lamassu-next/apps/machine/src-tauri/tauri.conf.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
28
lamassu-next/apps/machine/src/App.vue
Normal file
28
lamassu-next/apps/machine/src/App.vue
Normal 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>
|
||||
17
lamassu-next/apps/machine/src/components/ui/alert/Alert.vue
Normal file
17
lamassu-next/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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
lamassu-next/apps/machine/src/components/ui/alert/index.ts
Normal file
24
lamassu-next/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
lamassu-next/apps/machine/src/components/ui/badge/Badge.vue
Normal file
28
lamassu-next/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
lamassu-next/apps/machine/src/components/ui/badge/index.ts
Normal file
24
lamassu-next/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>
|
||||
|
|
@ -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
lamassu-next/apps/machine/src/components/ui/button/index.ts
Normal file
35
lamassu-next/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
lamassu-next/apps/machine/src/components/ui/card/Card.vue
Normal file
22
lamassu-next/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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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'
|
||||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default as Skeleton } from './Skeleton.vue'
|
||||
7
lamassu-next/apps/machine/src/lib/utils.ts
Normal file
7
lamassu-next/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
lamassu-next/apps/machine/src/main.ts
Normal file
29
lamassu-next/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')
|
||||
180
lamassu-next/apps/machine/src/stores/atm.ts
Normal file
180
lamassu-next/apps/machine/src/stores/atm.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
112
lamassu-next/apps/machine/src/style.css
Normal file
112
lamassu-next/apps/machine/src/style.css
Normal 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;
|
||||
}
|
||||
}
|
||||
231
lamassu-next/apps/machine/src/views/CashInView.vue
Normal file
231
lamassu-next/apps/machine/src/views/CashInView.vue
Normal 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>
|
||||
219
lamassu-next/apps/machine/src/views/CashOutView.vue
Normal file
219
lamassu-next/apps/machine/src/views/CashOutView.vue
Normal 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>
|
||||
85
lamassu-next/apps/machine/src/views/IdleView.vue
Normal file
85
lamassu-next/apps/machine/src/views/IdleView.vue
Normal 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>
|
||||
7
lamassu-next/apps/machine/src/vite-env.d.ts
vendored
Normal file
7
lamassu-next/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
lamassu-next/apps/machine/tsconfig.json
Normal file
27
lamassu-next/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
lamassu-next/apps/machine/tsconfig.node.json
Normal file
11
lamassu-next/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
lamassu-next/apps/machine/vite.config.ts
Normal file
33
lamassu-next/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'),
|
||||
},
|
||||
},
|
||||
// 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,
|
||||
})
|
||||
834
lamassu-next/pnpm-lock.yaml
generated
834
lamassu-next/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue