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:
+13
-2
@@ -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 />} />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
@@ -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 />
|
||||||
|
|||||||
@@ -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('/')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user