feat(waiter): native pairing, venue switcher, QR scanner, Android back handling

- First run in the native app shows only the pairing screen (VenuesPage):
  scan the manager's pairing QR or type the server address; the server must
  answer /api/system/identity as xenia-pos with a supported api_version, and
  a QR's ?pair=<site_id> must match - a code from another venue is refused
- /venues (native only): list of paired venues, switch (reloads into that
  venue's own token/IndexedDB), remove (data kept - may hold unsynced
  orders). Reachable from the login screen, the user menu and the offline page
- QrScannerModal: WebView camera via qr-scanner (no Google Play Services
  dependency, carries over to iOS); requests the camera once up front so the
  Android prompt appears a single time and "denied" gets its own message
- Hardware back: @capacitor/app listener - no-op on root screens (/, /tables,
  /login), otherwise history back. Path-based because Chrome's history
  intervention makes the WebView report canGoBack=false for the sentinel
  entry AndroidBackGuard pushes
- Before pairing no IndexedDB is opened, so nothing is created under the
  un-namespaced name
- InstallAppBanner now shows in plain-HTTP browser mode only when the venue
  server actually serves the APK (HEAD /downloads/xenia-waiter.apk)

Verified on an Android 15 emulator (API 35) with real taps via adb and state
via WebView DevTools: wrong address -> error; other venue's QR -> refused;
pairing -> venue-namespaced storage, live WebSocket; back on /tables keeps the
app open; restart keeps venue + session; second venue pairs logged-out and
switching back keeps venue 1's session; camera deny -> one prompt + message;
allow -> live preview. Signed release APK installed and paired. Web modes
(same-origin, plain-HTTP LAN IP, VITE_SERVER_URL) re-verified in Edge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-28 13:31:23 +03:00
co-authored by Claude Opus 5.5
parent 0b0f5c60c3
commit a486e3ff63
10 changed files with 440 additions and 12 deletions
+13 -2
View File
@@ -25,7 +25,8 @@ import ConnectivityBar from './components/ConnectivityBar'
import AppShell from './components/AppShell' import AppShell from './components/AppShell'
import UpdatePrompt from './components/UpdatePrompt' import UpdatePrompt from './components/UpdatePrompt'
import InstallAppBanner from './components/InstallAppBanner' import InstallAppBanner from './components/InstallAppBanner'
import { deliveryMode } from './config/server' import VenuesPage from './pages/VenuesPage'
import { deliveryMode, getActiveVenue } from './config/server'
// ─── Utility ───────────────────────────────────────────────────────────────── // ─── Utility ─────────────────────────────────────────────────────────────────
@@ -288,6 +289,8 @@ function OfflineListener() {
const { status } = useConnectionStore() const { status } = useConnectionStore()
useEffect(() => { useEffect(() => {
function handler() { function handler() {
// The venue switcher must stay usable while the current venue is unreachable
if (window.location.pathname === '/venues') return
// If user is logged in, ConnectivityBar handles it — don't redirect to /offline // If user is logged in, ConnectivityBar handles it — don't redirect to /offline
if (token && status !== 'online') return if (token && status !== 'online') return
// Not logged in and server is down → redirect to offline page // Not logged in and server is down → redirect to offline page
@@ -313,11 +316,13 @@ function ThemeApplier() {
// push a sentinel entry so there is always one history entry behind it. If the // push a sentinel entry so there is always one history entry behind it. If the
// user backs into the sentinel we push it again — they can never back past /tables. // user backs into the sentinel we push it again — they can never back past /tables.
// //
// Only active when running as an installed PWA (standalone display mode). // Only active when running as an installed PWA (standalone display mode) or in
// the native app, where the hardware back button would otherwise close the app.
function AndroidBackGuard() { function AndroidBackGuard() {
useEffect(() => { useEffect(() => {
const isStandalone = window.matchMedia('(display-mode: standalone)').matches const isStandalone = window.matchMedia('(display-mode: standalone)').matches
|| window.navigator.standalone === true || window.navigator.standalone === true
|| deliveryMode() === 'native'
if (!isStandalone) return if (!isStandalone) return
function pushSentinel() { function pushSentinel() {
@@ -402,6 +407,11 @@ function TableListRoute() {
const queryClient = new QueryClient() const queryClient = new QueryClient()
export default function App() { export default function App() {
// Native app with no paired venue yet: nothing else can work, show pairing only
if (deliveryMode() === 'native' && !getActiveVenue()) {
return <VenuesPage firstRun />
}
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<BrowserRouter> <BrowserRouter>
@@ -420,6 +430,7 @@ export default function App() {
<Routes> <Routes>
<Route path="/login" element={<LoginGuard><LoginPage /></LoginGuard>} /> <Route path="/login" element={<LoginGuard><LoginPage /></LoginGuard>} />
<Route path="/offline" element={<OfflinePage />} /> <Route path="/offline" element={<OfflinePage />} />
<Route path="/venues" element={deliveryMode() === 'native' ? <VenuesPage /> : <Navigate to="/" replace />} />
<Route element={<AppLayout />}> <Route element={<AppLayout />}>
<Route index element={<Navigate to="/tables?tab=tables" replace />} /> <Route index element={<Navigate to="/tables?tab=tables" replace />} />
<Route path="/tables" element={<TableListRoute />} /> <Route path="/tables" element={<TableListRoute />} />
+19 -8
View File
@@ -1,10 +1,13 @@
import { useState } from 'react' import { useEffect, useState } from 'react'
import { deliveryMode } from '../config/server' import { deliveryMode } from '../config/server'
// Shown only in plain-HTTP browser mode (http://<LAN IP>), where the page can't // Shown only in plain-HTTP browser mode (http://<LAN IP>), where the page can't
// work offline after a reload and can't be installed as a real PWA. Hidden until // work offline after a reload and can't be installed as a real PWA.
// an app download link exists (VITE_APP_DOWNLOAD_URL at build time). //
const DOWNLOAD_URL = import.meta.env.VITE_APP_DOWNLOAD_URL || '' // The APK is served by the venue's own server (scripts/build-apk.mjs bundles the
// release APK into the web build), so the banner appears only when that file is
// actually there. VITE_APP_DOWNLOAD_URL overrides the location if ever needed.
const DOWNLOAD_URL = import.meta.env.VITE_APP_DOWNLOAD_URL || '/downloads/xenia-waiter.apk'
const DISMISS_KEY = 'install-banner-dismissed-until' const DISMISS_KEY = 'install-banner-dismissed-until'
const DISMISS_DAYS = 7 const DISMISS_DAYS = 7
@@ -17,9 +20,18 @@ function isDismissed() {
} }
export default function InstallAppBanner() { export default function InstallAppBanner() {
const [hidden, setHidden] = useState(isDismissed) const eligible = deliveryMode() === 'plain-web' && !isDismissed()
const [available, setAvailable] = useState(false)
const [hidden, setHidden] = useState(false)
if (!DOWNLOAD_URL || hidden || deliveryMode() !== 'plain-web') return null useEffect(() => {
if (!eligible) return
fetch(DOWNLOAD_URL, { method: 'HEAD' })
.then(r => setAvailable(r.ok && !(r.headers.get('content-type') || '').includes('text/html')))
.catch(() => {})
}, [eligible])
if (!eligible || !available || hidden) return null
function dismiss() { function dismiss() {
try { try {
@@ -51,8 +63,7 @@ export default function InstallAppBanner() {
</span> </span>
<a <a
href={DOWNLOAD_URL} href={DOWNLOAD_URL}
target="_blank" download
rel="noreferrer"
style={{ style={{
background: 'var(--accent)', background: 'var(--accent)',
color: '#0f172a', color: '#0f172a',
@@ -0,0 +1,90 @@
import { useEffect, useRef, useState } from 'react'
import QrScanner from 'qr-scanner'
const MSG_DENIED = 'Δεν δόθηκε άδεια κάμερας. Επιτρέψτε την από τις ρυθμίσεις του κινητού ή πληκτρολογήστε τη διεύθυνση.'
const MSG_NO_CAMERA = 'Δεν ήταν δυνατή η ενεργοποίηση της κάμερας. Πληκτρολογήστε τη διεύθυνση.'
/**
* Full-screen camera view that resolves the first QR code it sees.
* Runs in the WebView (getUserMedia), so it needs no Google Play Services and
* works the same on iOS later. Capacitor turns the WebView's camera request into
* the normal Android runtime permission prompt.
*/
export default function QrScannerModal({ onResult, onClose }) {
const videoRef = useRef(null)
const [error, setError] = useState(null)
useEffect(() => {
let done = false
let scanner = null
async function start() {
// Ask for the camera ONCE ourselves: qr-scanner retries with another camera
// after a refusal, which would show the Android permission prompt twice, and
// its errors don't tell "denied" apart from "no camera".
try {
const probe = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } })
probe.getTracks().forEach(t => t.stop())
} catch (err) {
if (!done) setError(err?.name === 'NotAllowedError' ? MSG_DENIED : MSG_NO_CAMERA)
return
}
if (done) return
scanner = new QrScanner(
videoRef.current,
result => {
if (done) return
done = true
scanner.stop()
onResult(result.data)
},
{ preferredCamera: 'environment', returnDetailedScanResult: true, highlightScanRegion: true },
)
scanner.start().catch(() => {
if (!done) setError(MSG_NO_CAMERA)
})
}
start()
return () => {
done = true
scanner?.destroy()
}
}, [onResult])
return (
<div style={{
position: 'fixed', inset: 0, zIndex: 10000, background: '#000',
display: 'flex', flexDirection: 'column',
}}>
<div style={{ flex: 1, position: 'relative', overflow: 'hidden' }}>
{/* 1px transparent poster hides the WebView's big "play" placeholder before the stream starts */}
<video
ref={videoRef}
poster="data:image/gif;base64,R0lGODlhAQABAAAAACw="
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
muted
playsInline
/>
{error && (
<div style={{
position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 32, textAlign: 'center', color: '#e2e8f0', fontSize: 15, lineHeight: 1.5,
background: 'rgba(15,23,42,0.92)',
}}>
{error}
</div>
)}
</div>
<div style={{ padding: 16, display: 'flex', flexDirection: 'column', gap: 10, background: '#0f172a' }}>
{!error && (
<p style={{ color: '#94a3b8', fontSize: 13, textAlign: 'center' }}>
Σκοπεύστε το QR από τον διαχειριστή (Ρυθμίσεις → Γενικά → Σερβιτόροι WiFi)
</p>
)}
<button className="btn btn--secondary btn--lg" onClick={onClose} style={{ width: '100%' }}>Ακύρωση</button>
</div>
</div>
)
}
+9
View File
@@ -4,6 +4,7 @@ import useAuthStore from '../store/authStore'
import useShiftStore from '../store/shiftStore' import useShiftStore from '../store/shiftStore'
import useThemeStore from '../store/themeStore' import useThemeStore from '../store/themeStore'
import client from '../api/client' import client from '../api/client'
import { deliveryMode, getActiveVenue } from '../config/server'
function formatTime(iso) { function formatTime(iso) {
if (!iso) return '' if (!iso) return ''
@@ -168,6 +169,14 @@ export default function UserMenu() {
</> </>
)} )}
{/* ── Venue switch (native app only) ───────────────── */}
{deliveryMode() === 'native' && (
<button className="user-menu-item" onClick={() => { setOpen(false); navigate('/venues') }}>
<span className="user-menu-item__icon">🏪</span>
<span>Κατάστημα: {getActiveVenue()?.name ?? '—'}</span>
</button>
)}
{/* ── Theme toggle ──────────────────────────────────── */} {/* ── Theme toggle ──────────────────────────────────── */}
<button className="user-menu-item" onClick={() => { toggle(); setOpen(false) }}> <button className="user-menu-item" onClick={() => { toggle(); setOpen(false) }}>
<span className="user-menu-item__icon">{dark ? '☀️' : '🌙'}</span> <span className="user-menu-item__icon">{dark ? '☀️' : '🌙'}</span>
+11 -1
View File
@@ -3,11 +3,21 @@ import { createRoot } from 'react-dom/client'
import './index.css' import './index.css'
import App from './App.jsx' import App from './App.jsx'
import db from './db/posdb.js' import db from './db/posdb.js'
import { deliveryMode, getActiveVenue } from './config/server'
// Ensure the IndexedDB schema is fully open before any component mounts. // Ensure the IndexedDB schema is fully open before any component mounts.
// Without this, table accessors (db.tables, db.orders, etc.) can be undefined // Without this, table accessors (db.tables, db.orders, etc.) can be undefined
// when components try to use them synchronously on first render. // when components try to use them synchronously on first render.
db.open().catch(err => console.error('[IDB] open failed:', err)).finally(() => { //
// Native app before pairing: there is no venue yet, so don't create a database
// under the wrong (un-namespaced) name — App renders only the pairing screen.
const native = deliveryMode() === 'native'
if (native) {
import('./native/backButton').then(m => m.installBackButtonHandler())
}
const ready = native && !getActiveVenue() ? Promise.resolve() : db.open()
ready.catch(err => console.error('[IDB] open failed:', err)).finally(() => {
createRoot(document.getElementById('root')).render( createRoot(document.getElementById('root')).render(
<StrictMode> <StrictMode>
<App /> <App />
+24
View File
@@ -0,0 +1,24 @@
/**
* Android hardware back button in the native app.
*
* Without a listener, Android closes the app on back — waiters would drop out of
* the POS mid-service. Same intent as AndroidBackGuard for the installed PWA:
* back never leaves the POS from a root screen.
*
* Decided by path, not by a history sentinel: Chrome's history intervention marks
* entries pushed without a user gesture as skippable, so the WebView reports
* canGoBack=false even with the sentinel in place.
*/
const ROOT_PATHS = ['/', '/tables', '/login']
export async function installBackButtonHandler() {
const { App } = await import('@capacitor/app')
App.addListener('backButton', ({ canGoBack }) => {
if (ROOT_PATHS.includes(window.location.pathname)) return
if (canGoBack) {
window.history.back()
} else {
window.location.replace('/')
}
})
}
+104
View File
@@ -0,0 +1,104 @@
/**
* Pairing a phone with a venue server (native app).
*
* Input comes from the pairing QR code shown in the manager
* (`http://<LAN IP>/?pair=<site_id>`) or from an address typed by hand
* (`192.168.1.50`, `192.168.1.50:8080`, `http://…`). Either way the server must
* answer GET /api/system/identity as a Xenia server with an API version this app
* understands, and — when the QR carried a site id — the SAME site id, so a code
* from one venue can't silently pair with a different machine that took its IP.
*
* Contract: docs/reference/lan-access.md
*/
import { saveVenue } from '../config/server'
// api_version values (from /api/system/identity) this build can talk to.
export const SUPPORTED_API_VERSIONS = [1]
const PROBE_TIMEOUT_MS = 5000
export class PairingError extends Error {}
/**
* Turn scanned or typed text into { baseUrl, expectedSiteId }.
* Only the origin of a URL is kept — paths and other params are ignored.
*/
export function parseServerInput(raw) {
const text = String(raw || '').trim()
if (!text) throw new PairingError('Συμπληρώστε τη διεύθυνση του server.')
const withScheme = /^https?:\/\//i.test(text) ? text : `http://${text}`
let url
try {
url = new URL(withScheme)
} catch {
throw new PairingError('Μη έγκυρη διεύθυνση. Παράδειγμα: 192.168.1.50')
}
if (!url.hostname) throw new PairingError('Μη έγκυρη διεύθυνση. Παράδειγμα: 192.168.1.50')
return {
baseUrl: url.origin,
expectedSiteId: url.searchParams.get('pair') || null,
}
}
/** Ask a server who it is. Resolves to the identity object or throws PairingError. */
export async function probeServer(baseUrl) {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS)
let res
try {
res = await fetch(`${baseUrl}/api/system/identity`, { signal: controller.signal })
} catch {
throw new PairingError(
'Δεν βρέθηκε server σε αυτή τη διεύθυνση. Ελέγξτε ότι το κινητό είναι στο ίδιο WiFi με το κατάστημα.'
)
} finally {
clearTimeout(timer)
}
if (res.status === 403) {
throw new PairingError('Ο server αρνήθηκε τη σύνδεση. Το κινητό πρέπει να είναι στο WiFi του καταστήματος.')
}
let identity = null
try {
identity = await res.json()
} catch {
// not JSON → not a Xenia server
}
if (!res.ok || identity?.app !== 'xenia-pos') {
throw new PairingError('Η διεύθυνση δεν ανήκει σε server Xenia.')
}
if (!SUPPORTED_API_VERSIONS.includes(identity.api_version)) {
throw new PairingError(
`Ο server (έκδοση ${identity.version}) δεν είναι συμβατός με αυτή την εφαρμογή. Ενημερώστε την εφαρμογή ή τον server.`
)
}
return identity
}
/** Storage-safe venue id for servers without a SITE_ID (dev / unregistered). */
function fallbackSiteId(baseUrl) {
return 'local-' + new URL(baseUrl).host.replace(/[^a-z0-9]+/gi, '-')
}
/**
* Validate a scanned/typed address and save it as a paired venue.
* Does NOT switch to it — the caller decides (switchVenue reloads the app).
*/
export async function pairWith(raw) {
const { baseUrl, expectedSiteId } = parseServerInput(raw)
const identity = await probeServer(baseUrl)
if (expectedSiteId && identity.site_id && identity.site_id !== expectedSiteId) {
throw new PairingError(
'Σε αυτή τη διεύθυνση απαντά διαφορετικό κατάστημα από αυτό του QR. Ζητήστε νέο QR από τον διαχειριστή.'
)
}
return saveVenue({
siteId: identity.site_id || fallbackSiteId(baseUrl),
name: identity.venue_name || new URL(baseUrl).host,
baseUrl,
})
}
+9 -1
View File
@@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom'
import PinPad from '../components/PinPad' import PinPad from '../components/PinPad'
import useAuthStore from '../store/authStore' import useAuthStore from '../store/authStore'
import client from '../api/client' import client from '../api/client'
import { assetUrl } from '../config/server' import { assetUrl, deliveryMode, getActiveVenue } from '../config/server'
// ─── Waiter card ────────────────────────────────────────────────────────────── // ─── Waiter card ──────────────────────────────────────────────────────────────
@@ -128,6 +128,14 @@ export default function LoginPage() {
<div style={{ flexShrink: 0, padding: '40px 16px 20px', textAlign: 'center' }}> <div style={{ flexShrink: 0, padding: '40px 16px 20px', textAlign: 'center' }}>
<h1 className="app-title" style={{ marginBottom: 10 }}>Xenia POS</h1> <h1 className="app-title" style={{ marginBottom: 10 }}>Xenia POS</h1>
<p className="app-subtitle">Ποιος είσαι;</p> <p className="app-subtitle">Ποιος είσαι;</p>
{deliveryMode() === 'native' && (
<button
onClick={() => navigate('/venues')}
style={{ marginTop: 10, background: 'none', border: 'none', color: 'var(--muted)', fontSize: 13, cursor: 'pointer' }}
>
🏪 {getActiveVenue()?.name} · <span style={{ color: 'var(--accent)' }}>Αλλαγή</span>
</button>
)}
</div> </div>
{/* Scrollable card list */} {/* Scrollable card list */}
+10
View File
@@ -1,4 +1,5 @@
import client from '../api/client' import client from '../api/client'
import { deliveryMode } from '../config/server'
import { useState } from 'react' import { useState } from 'react'
export default function OfflinePage() { export default function OfflinePage() {
@@ -25,6 +26,15 @@ export default function OfflinePage() {
<button className="btn btn--primary btn--lg" onClick={retry} disabled={checking}> <button className="btn btn--primary btn--lg" onClick={retry} disabled={checking}>
{checking ? 'Έλεγχος…' : 'Επανάληψη'} {checking ? 'Έλεγχος…' : 'Επανάληψη'}
</button> </button>
{deliveryMode() === 'native' && (
<button
className="btn btn--secondary btn--lg"
style={{ marginTop: 12 }}
onClick={() => { window.location.href = '/venues' }}
>
Αλλαγή καταστήματος
</button>
)}
</div> </div>
</div> </div>
) )
+151
View File
@@ -0,0 +1,151 @@
import { useCallback, useEffect, useState } from 'react'
import useThemeStore from '../store/themeStore'
import QrScannerModal from '../components/QrScannerModal'
import { pairWith } from '../native/pairing'
import { getActiveVenue, listVenues, removeVenue, switchVenue } from '../config/server'
/**
* Native app: pair with a venue server and switch between paired venues.
*
* `firstRun` — rendered on its own (no router, no auth) when the app has no
* active venue yet. Otherwise it's the /venues route, reachable from the login
* screen, the user menu and the offline screen.
*/
export default function VenuesPage({ firstRun = false }) {
const dark = useThemeStore(s => s.dark)
const [venues, setVenues] = useState(listVenues)
const [address, setAddress] = useState('')
const [scanning, setScanning] = useState(false)
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
const active = getActiveVenue()
// First run renders outside <App>'s ThemeApplier
useEffect(() => {
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light')
}, [dark])
const connect = useCallback(async raw => {
setScanning(false)
setBusy(true)
setError(null)
try {
const venue = await pairWith(raw)
switchVenue(venue.siteId) // reloads the app into the venue
} catch (e) {
setError(e.message || 'Αποτυχία σύνδεσης.')
setBusy(false)
}
}, [])
function handleRemove(venue) {
const msg = venue.siteId === active?.siteId
? `Αφαίρεση του ενεργού καταστήματος «${venue.name}»; Θα χρειαστεί νέα σύνδεση.`
: `Αφαίρεση του καταστήματος «${venue.name}» από αυτό το κινητό;`
if (!window.confirm(msg)) return
removeVenue(venue.siteId)
if (venue.siteId === active?.siteId) {
window.location.replace('/')
} else {
setVenues(listVenues())
}
}
return (
<div className="page" style={{ padding: '24px 20px', gap: 24, display: 'flex', flexDirection: 'column', overflowY: 'auto' }}>
{!firstRun && (
<button
onClick={() => window.history.length > 1 ? window.history.back() : window.location.replace('/')}
style={{ alignSelf: 'flex-start', background: 'none', border: 'none', color: 'var(--muted)', fontSize: 15, cursor: 'pointer', padding: 0 }}
>
← Πίσω
</button>
)}
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: 44, marginBottom: 8 }}>🏪</div>
<h1 style={{ fontSize: 22, fontWeight: 700, color: 'var(--text)', marginBottom: 6 }}>
{firstRun ? 'Σύνδεση με κατάστημα' : 'Καταστήματα'}
</h1>
<p style={{ fontSize: 14, color: 'var(--muted)', lineHeight: 1.5 }}>
Σαρώστε το QR από τον διαχειριστή ή πληκτρολογήστε τη διεύθυνση του server.
Το κινητό πρέπει να είναι στο WiFi του καταστήματος.
</p>
</div>
{venues.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{venues.map(v => {
const isActive = v.siteId === active?.siteId
return (
<div key={v.siteId} style={{
display: 'flex', alignItems: 'center', gap: 12,
background: 'var(--bg2)', border: `1px solid ${isActive ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 14, padding: '12px 14px',
}}>
<button
onClick={() => !isActive && switchVenue(v.siteId)}
disabled={busy}
style={{ flex: 1, textAlign: 'left', background: 'none', border: 'none', padding: 0, cursor: isActive ? 'default' : 'pointer' }}
>
<div style={{ fontSize: 16, fontWeight: 600, color: 'var(--text)' }}>
{v.name}
{isActive && (
<span style={{ marginLeft: 8, fontSize: 11, fontWeight: 700, color: 'var(--accent)' }}>ΕΝΕΡΓΟ</span>
)}
</div>
<div style={{ fontSize: 12, color: 'var(--muted)', fontFamily: 'monospace' }}>{v.baseUrl}</div>
</button>
<button
onClick={() => handleRemove(v)}
aria-label="Αφαίρεση"
style={{ background: 'none', border: 'none', color: 'var(--muted)', fontSize: 22, cursor: 'pointer', padding: 4 }}
>
×
</button>
</div>
)
})}
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<button className="btn btn--accent btn--lg" onClick={() => { setError(null); setScanning(true) }} disabled={busy}>
📷 Σάρωση QR
</button>
<div style={{ display: 'flex', gap: 8 }}>
<input
value={address}
onChange={e => setAddress(e.target.value)}
onKeyDown={e => e.key === 'Enter' && address && connect(address)}
placeholder="π.χ. 192.168.1.50"
inputMode="url"
autoCapitalize="off"
autoCorrect="off"
disabled={busy}
style={{
flex: 1, minWidth: 0, background: 'var(--bg3)', border: '1px solid var(--border)',
borderRadius: 12, padding: '0 14px', height: 52, color: 'var(--text)', fontSize: 16, outline: 'none',
}}
/>
<button className="btn btn--secondary" onClick={() => connect(address)} disabled={busy || !address}
style={{ height: 52, padding: '0 18px' }}>
{busy ? 'Έλεγχος…' : 'Σύνδεση'}
</button>
</div>
{error && (
<p style={{
fontSize: 14, color: 'var(--danger)', background: 'var(--danger-dim)',
borderRadius: 10, padding: '10px 12px', lineHeight: 1.45,
}}>
{error}
</p>
)}
</div>
{scanning && <QrScannerModal onResult={connect} onClose={() => setScanning(false)} />}
</div>
)
}