// ResDri driver — API-driven app shell. // // Boot sequence: // 1) Read JWT from localStorage. If valid (/auth/me succeeds), enter app. // 2) Else show LoginScreen → on success, enter app. // 3) Once in app: fetch /drivers/me + /orders/active + /orders/pool; // connect WebSocket for live order.new / order.update. // // All state transitions hit real backend endpoints. const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{ "dark": false, "palette": "teal", "font": "Cairo", "density": "comfortable", "alertPlacement": "right", "homeState": "online", "radiusKm": 0 }/*EDITMODE-END*/; // radiusKm starts at 0 (falsy) so the server's defaultRadiusKm is used until // the driver explicitly moves the slider — then their choice persists. // Last-resort fallback config if /api/config can't be reached on boot. // Everything here is overridden the moment the server responds. const CONFIG_FALLBACK = { brandName: 'ResDri', pilotCity: 'أم الفحم', currency: '₪', defaultRadiusKm: 1.5, maxRadiusKm: 5, radiusMinKm: 0.5, radiusStepKm: 0.5, cityLat: 32.5167, cityLng: 35.1500, locationPushSeconds: 25, supportPhone: null, supportWhatsapp: null, }; // Map backend status → prototype flow id const STATUS_TO_FLOW = { PENDING: 'idle', ACCEPTED: 'toPickup', EN_ROUTE_PICKUP: 'toPickup', AT_PICKUP: 'atPickup', PICKED_UP: 'toDropoff', EN_ROUTE_DROPOFF: 'toDropoff', AT_DROPOFF: 'cash', DELIVERED: 'cash', COMPLETED: 'completed', CANCELLED: 'idle', }; function App() { const [t, setTweak] = useTweaks(TWEAK_DEFAULTS); // ── Auth + boot state const [authPhase, setAuthPhase] = React.useState('loading'); // 'loading' | 'login' | 'app' const [driver, setDriver] = React.useState(null); const [bootError, setBootError] = React.useState(null); // ── Server-driven config (radius bounds, city centre, currency, GPS cadence…) // Fetched once on boot. window.RESDRI_CONFIG is also set so non-React modules // (map.jsx projection) can read it. Falls back to CONFIG_FALLBACK until loaded. const [config, setConfig] = React.useState(window.RESDRI_CONFIG || CONFIG_FALLBACK); React.useEffect(() => { let cancelled = false; (async () => { try { const c = await window.api.getConfig(); if (cancelled || !c) return; const merged = { ...CONFIG_FALLBACK, ...c }; window.RESDRI_CONFIG = merged; setConfig(merged); } catch (e) { console.warn('[config] using fallback —', e.message); window.RESDRI_CONFIG = CONFIG_FALLBACK; } })(); return () => { cancelled = true; }; }, []); // Fullscreen mode: small viewport OR launched as installed PWA. // In fullscreen we drop the iOS device frame and the dev chrome (Tweaks panel, // step label, WS badge) so the app looks like a real native app on the phone. const [fullScreen, setFullScreen] = React.useState(() => computeFullScreen()); React.useEffect(() => { const recompute = () => setFullScreen(computeFullScreen()); window.addEventListener('resize', recompute); const mql = window.matchMedia('(display-mode: standalone)'); if (mql.addEventListener) mql.addEventListener('change', recompute); return () => { window.removeEventListener('resize', recompute); if (mql.removeEventListener) mql.removeEventListener('change', recompute); }; }, []); // ── App state const [pool, setPool] = React.useState([]); const [activeOrder, setActiveOrder] = React.useState(null); const [orderId, setOrderId] = React.useState(null); const [flow, setFlow] = React.useState('idle'); const [tab, setTab] = React.useState('home'); const [online, setOnline] = React.useState(false); const [wsStatus, setWsStatus] = React.useState('disconnected'); // Driver's last known GPS position; null until geolocation grants or seeded. const [driverLocation, setDriverLocation] = React.useState(null); // Working radius — driver's saved choice wins; otherwise the server default. // Bounds (min/max/step) also come from config. const radiusKm = t.radiusKm || config.defaultRadiusKm || 1.5; const setRadiusKm = (v) => setTweak('radiusKm', v); // Theme application React.useEffect(() => { const root = document.documentElement; root.dataset.theme = t.dark ? 'dark' : 'light'; root.dataset.palette = t.palette; root.dataset.density = t.density; root.style.setProperty('--font', `"${t.font}", "Cairo", "Tajawal", system-ui, sans-serif`); }, [t.dark, t.palette, t.font, t.density]); // ── Boot React.useEffect(() => { (async () => { if (!window.api.getToken()) { setAuthPhase('login'); return; } try { await window.api.me(); await enterApp(); } catch (e) { // Token invalid/expired window.api.setToken(null); setAuthPhase('login'); } })(); }, []); async function enterApp() { try { const [d, active, p] = await Promise.all([ window.api.driverMe(), window.api.active().catch(() => null), window.api.pool(), ]); setDriver(d); setOnline(!!d.isOnline); setPool(p || []); if (active && active.id) { setOrderId(active.id); setActiveOrder(active); setFlow(STATUS_TO_FLOW[active.status] || 'idle'); } else { setFlow('idle'); } setAuthPhase('app'); window.resdriWs.connect(); } catch (e) { setBootError(e.message || 'تعذّر التحميل'); setAuthPhase('login'); } } // ── Geolocation: watch the device GPS, push to backend, mirror to state. // Falls back silently when the browser blocks it (e.g., HTTP origin → no // geolocation on iOS Safari). The pool then uses the seeded city centre. React.useEffect(() => { if (authPhase !== 'app') return; if (!navigator.geolocation) return; // Seed with driver's stored location from /drivers/me if available if (driver?.currentLat && driver?.currentLng) { setDriverLocation({ lat: driver.currentLat, lng: driver.currentLng }); } let lastPush = 0; const pushIntervalMs = (config.locationPushSeconds || 25) * 1000; const watcher = navigator.geolocation.watchPosition( (p) => { const lat = p.coords.latitude, lng = p.coords.longitude; setDriverLocation({ lat, lng }); const now = Date.now(); if (now - lastPush > pushIntervalMs) { lastPush = now; window.api.updateLocation(lat, lng).catch(() => {}); } }, (err) => console.warn('[geo]', err.message), { enableHighAccuracy: true, maximumAge: 30000, timeout: 60000 } ); return () => navigator.geolocation.clearWatch(watcher); }, [authPhase, driver?.id, config.locationPushSeconds]); // ── WebSocket listeners React.useEffect(() => { if (authPhase !== 'app') return; const onStatus = (e) => setWsStatus(e.detail); const onNew = (e) => { const o = e.detail; // Add to pool list setPool((prev) => { if (prev.find((x) => x.id === o.id)) return prev; return [o, ...prev]; }); // If we're online and idle on home → fire the incoming alert setFlow((curFlow) => { if (curFlow === 'idle' && tab === 'home' && online) { setOrderId(o.id); return 'incoming'; } return curFlow; }); }; const onUpdate = (e) => { const o = e.detail; setPool((prev) => { // If it's still PENDING update in place; if status changed, remove. if (o.status === 'PENDING') { const i = prev.findIndex((x) => x.id === o.id); if (i >= 0) { const copy = [...prev]; copy[i] = o; return copy; } return [o, ...prev]; } return prev.filter((x) => x.id !== o.id); }); // If this is my active order, mirror its state setActiveOrder((cur) => (cur && cur.id === o.id ? o : cur)); }; window.addEventListener('resdri:ws-status', onStatus); window.addEventListener('resdri:order-new', onNew); window.addEventListener('resdri:order-update', onUpdate); return () => { window.removeEventListener('resdri:ws-status', onStatus); window.removeEventListener('resdri:order-new', onNew); window.removeEventListener('resdri:order-update', onUpdate); }; }, [authPhase, tab, online]); // Expose for the verifier / dev tools React.useEffect(() => { window.__appSetFlow = setFlow; window.__appSetTab = setTab; }, []); // ── Handlers const refreshPool = async () => { try { const p = await window.api.pool(); setPool(p || []); } catch (e) { console.warn('refreshPool', e); } }; const openOrder = async (id) => { try { const o = await window.api.order(id); if (!o) return; setOrderId(id); // If this is the current driver's active order already, go to its flow. if (o.driverId === driver?.id && o.status !== 'PENDING' && o.status !== 'CANCELLED') { setActiveOrder(o); setFlow(STATUS_TO_FLOW[o.status] || 'idle'); } else { setFlow('detail'); } } catch (e) { alert(e.message); } }; // accept(id?) — id can be passed explicitly (when accepting from the home/orders list) // or fall back to the currently selected orderId state (when accepting from detail). const accept = async (id) => { const targetId = id || orderId; if (!targetId) return; if (id) setOrderId(id); try { const o = await window.api.accept(targetId); setActiveOrder(o); try { await window.api.enRoutePickup(o.id); } catch {} setFlow('toPickup'); } catch (e) { alert(e.message); setFlow('idle'); await refreshPool(); } }; const skip = () => { setFlow('idle'); setOrderId(null); }; const arrivedAtPickup = async () => { if (!activeOrder) return setFlow('atPickup'); try { const o = await window.api.arrivedPickup(activeOrder.id); setActiveOrder(o); setFlow('atPickup'); } catch (e) { alert(e.message); } }; const confirmedPickup = async () => { if (!activeOrder) return setFlow('toDropoff'); try { const o1 = await window.api.pickedUp(activeOrder.id); const o2 = await window.api.enRouteDropoff(activeOrder.id); setActiveOrder(o2 || o1); setFlow('toDropoff'); } catch (e) { alert(e.message); } }; const arrivedAtDropoff = async () => { if (!activeOrder) return setFlow('cash'); try { const o = await window.api.arrivedDropoff(activeOrder.id); setActiveOrder(o); setFlow('cash'); } catch (e) { alert(e.message); } }; const collectedCash = async () => { if (!activeOrder) return setFlow('completed'); try { // delivered → complete with cash amount from order await window.api.delivered(activeOrder.id); const completed = await window.api.complete(activeOrder.id, activeOrder.cash); setActiveOrder(completed); // refresh driver wallet try { const d = await window.api.driverMe(); setDriver(d); } catch {} setFlow('completed'); } catch (e) { alert(e.message); } }; const backToHome = async () => { setFlow('idle'); setTab('home'); setOrderId(null); setActiveOrder(null); await refreshPool(); }; // Called as setOnline(true|false) from the lever; mirrors backend state. const setOnlineApi = async (next) => { setOnline(next); // optimistic try { await window.api.setOnline(next); } catch (e) { setOnline(!next); alert(e.message); } }; const logout = () => { window.api.setToken(null); window.resdriWs.disconnect(); setAuthPhase('login'); setDriver(null); setPool([]); setActiveOrder(null); setOrderId(null); setFlow('idle'); setTab('home'); }; // ── Rendering paths // 1) Loading — black device, dot spinner // 2) Login — phone+OTP // 3) App — the actual driver UI if (authPhase === 'loading') { return ; } if (authPhase === 'login') { return ( { await enterApp(); }} /> {bootError && } ); } // ── App body const order = activeOrder || (orderId ? pool.find((o) => o.id === orderId) : null) || pool[0]; const TAB_BAR_HEIGHT = 72; const isIdle = flow === 'idle'; let tabBody = null; if (isIdle) { if (tab === 'home') { const driverLatLng = driverLocation || (driver?.currentLat && driver?.currentLng ? { lat: driver.currentLat, lng: driver.currentLng } : null); tabBody = 0 ? 'online' : 'empty') : 'offline'} openOrder={openOrder} acceptOrder={(id) => accept(id)} today={Number(driver?.cashHeld) || 0} tabBarHeight={TAB_BAR_HEIGHT} orders={pool} driverName={driver?.fullName || ''} driverLatLng={driverLatLng} radiusKm={radiusKm} setRadiusKm={setRadiusKm} config={config} />; } else if (tab === 'orders') tabBody = accept(id)} radiusKm={radiusKm} orders={pool} driverName={driver?.fullName} config={config} />; else if (tab === 'earnings') tabBody = ; else if (tab === 'wallet') tabBody = ; else if (tab === 'profile') tabBody = ; } let flowBody = null; switch (flow) { case 'incoming': flowBody = order && ; break; case 'detail': flowBody = order && ; break; case 'toPickup': flowBody = order && ; break; case 'atPickup': flowBody = order && ; break; case 'toDropoff': flowBody = order && ; break; case 'cash': flowBody = order && setFlow('toDropoff')} />; break; case 'completed': flowBody = order && ; break; } // ── Inner content: tab body or flow body, plus the tab bar on idle screens. const inner = (
{isIdle ? tabBody : flowBody} {isIdle && }
); if (fullScreen) { return (
{inner}
); } return (
{inner} {window.RESDRI_DEV && (
)}
{/* Tweaks — only in dev mode */} {window.RESDRI_DEV && setTweak('dark', v)} /> { const lookup = { '#0F766E': 'teal', '#EA580C': 'orange', '#4338CA': 'indigo', '#18181B': 'mono' }; const first = Array.isArray(v) ? v[0] : v; setTweak('palette', lookup[first] || 'teal'); }} /> setTweak('font', v)} /> setTweak('density', v)} /> setTweak('alertPlacement', v)} /> { setTab(v); setFlow('idle'); }} /> } {/* In production we still need a logout path. Tiny floating ghost button in the bottom corner of the desktop wrapper (hidden in fullscreen/PWA mode where Profile tab → logout button is the path). */} {!window.RESDRI_DEV && !fullScreen && ( )}
); } // ── Visual atoms ────────────────────────────────────────────────── function DeviceShell({ children, t, fullScreen }) { if (fullScreen) { return (
{children}
); } return (
{children}
); } function computeFullScreen() { const standalone = (window.matchMedia && window.matchMedia('(display-mode: standalone)').matches) || window.navigator.standalone === true; const smallViewport = window.innerWidth < 500; return standalone || smallViewport; } function BootSpinner() { return (
{[0, 0.15, 0.3].map((d) => ( ))}
); } function BootError({ text }) { return (
{text}
); } function WsBadge({ status, pool }) { const c = status === 'connected' ? '#22C55E' : status === 'error' ? '#EF4444' : '#94A3B8'; return (
WS · {pool} في المسبح
); } function screenLabel(flow, tab) { const flowMap = { incoming: '02 Incoming order (hero)', detail: '03 Order detail', toPickup: '04 Heading to pickup', atPickup: '05 At pickup', toDropoff: '06 Heading to dropoff', cash: '07 Cash collection (hero)', completed: '08 Completed', }; if (flow !== 'idle') return flowMap[flow] || flow; return ({ home: '01 Home', orders: '01b Orders', earnings: '01c Earnings', wallet: '01d Wallet', profile: '01e Profile' }[tab] || tab); } function StepLabel({ flow, tab }) { const flowMap = { idle: ({ home: 'Home', orders: 'Orders', earnings: 'Earnings', wallet: 'Wallet', profile: 'Profile' })[tab], incoming: 'Incoming alert', detail: 'Order detail', toPickup: 'Heading to pickup', atPickup: 'At pickup', toDropoff: 'Heading to dropoff', cash: 'Cash collection', completed: 'Completed', }; const isIdle = flow === 'idle'; return (
{isIdle ? 'IDLE · TAB' : 'ACTIVE · FLOW'} · {flowMap[flow]}
); } Object.assign(window, { App }); const root = ReactDOM.createRoot(document.getElementById('root')); root.render();