No users yet.
);
-}
\ No newline at end of file
+}
diff --git a/orderly/client/src/pages/Profile.jsx b/orderly/client/src/pages/Profile.jsx
index e9fb804..320725d 100644
--- a/orderly/client/src/pages/Profile.jsx
+++ b/orderly/client/src/pages/Profile.jsx
@@ -13,17 +13,32 @@ export default function Profile() {
.catch((err) => console.error("Profile fetch failed", err));
}, []);
- return
+ );
}
diff --git a/orderly/client/src/pages/SalesOrders.jsx b/orderly/client/src/pages/SalesOrders.jsx
index 0968ed6..ff059a1 100644
--- a/orderly/client/src/pages/SalesOrders.jsx
+++ b/orderly/client/src/pages/SalesOrders.jsx
@@ -17,7 +17,7 @@ export default function SalesOrders() {
})
.catch((err) => {
if (!on) return;
- setError(err?.reponse?.error || "Failed to load orders");
+ setError(err?.response?.data?.error || "Failed to load orders");
setStatus("error");
});
@@ -26,19 +26,42 @@ export default function SalesOrders() {
};
}, []);
- if (status === "loading")
- return
-
Sales • Orders
- {list.length === 0 ? (
-
No orders yet.
- ) : (
-
{JSON.stringify(list, null, 2)}
- )}
+
+
+ Sales • Orders
+ Orders synced from the protected sales endpoint.
+
+
+
+ {list.length === 0 ? (
+ No orders yet.
+ ) : (
+ {JSON.stringify(list, null, 2)}
+ )}
+
);
}
diff --git a/orderly/client/src/styles/app.css b/orderly/client/src/styles/app.css
new file mode 100644
index 0000000..8eeb529
--- /dev/null
+++ b/orderly/client/src/styles/app.css
@@ -0,0 +1,166 @@
+@import './theme.css';
+
+#root {
+ min-height: 100vh;
+ background: var(--bg);
+}
+
+.app-shell {
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+}
+
+.app-content {
+ flex: 1;
+ width: 100%;
+ max-width: 960px;
+ margin: 0 auto;
+ padding: 24px;
+}
+
+.top-nav {
+ position: sticky;
+ top: 0;
+ z-index: 20;
+ backdrop-filter: blur(10px);
+ background: var(--nav-bg);
+ border-bottom: 1px solid var(--nav-border);
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ padding: 16px 24px;
+}
+
+.top-nav a {
+ color: var(--text);
+ font-weight: 500;
+ text-decoration: none;
+ padding: 6px 10px;
+ border-radius: 8px;
+ transition: background 0.2s ease, color 0.2s ease;
+}
+
+.top-nav a:hover,
+.top-nav a:focus {
+ background: rgba(148, 163, 184, 0.16);
+ outline: none;
+}
+
+.top-nav a[aria-current='page'] {
+ background: rgba(59, 130, 246, 0.18);
+ color: var(--accent);
+}
+
+.top-nav__spacer {
+ flex: 1;
+}
+
+.theme-toggle {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ background: transparent;
+ border: 1px solid var(--surface-border);
+ border-radius: 999px;
+ padding: 6px 12px;
+ cursor: pointer;
+ color: var(--text);
+ font-size: 0.9rem;
+ transition: border-color 0.2s ease, background 0.2s ease;
+}
+
+.theme-toggle:hover,
+.theme-toggle:focus {
+ border-color: var(--accent);
+ background: rgba(59, 130, 246, 0.12);
+ outline: none;
+}
+
+.theme-toggle__label {
+ display: none;
+}
+
+@media (min-width: 640px) {
+ .theme-toggle__label {
+ display: inline;
+ }
+}
+
+.link-button {
+ background: transparent;
+ border: none;
+ color: var(--accent);
+ cursor: pointer;
+ font-weight: 500;
+ padding: 6px 10px;
+ border-radius: 8px;
+}
+
+.link-button:hover,
+.link-button:focus {
+ background: rgba(148, 163, 184, 0.16);
+ outline: none;
+}
+
+.link-button.danger {
+ color: var(--danger);
+}
+
+.page {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+.page__section {
+ background: var(--surface);
+ border: 1px solid var(--surface-border);
+ border-radius: 16px;
+ padding: 20px;
+ box-shadow: var(--shadow);
+ transition: background 0.3s ease, border-color 0.3s ease;
+}
+
+.page__section h2,
+.page__section h4 {
+ margin-top: 0;
+}
+
+.page__section h4 {
+ margin-bottom: 12px;
+}
+
+.page__status {
+ color: var(--text-muted);
+}
+
+.page__status--error {
+ color: var(--danger);
+}
+
+pre {
+ background: var(--code-bg);
+ color: var(--code-text);
+ padding: 16px;
+ border-radius: 12px;
+ overflow-x: auto;
+}
+
+button.primary {
+ align-self: flex-start;
+ background: var(--accent);
+ border: none;
+ color: #fff;
+ border-radius: 10px;
+ padding: 10px 16px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: background 0.2s ease;
+}
+
+button.primary:hover,
+button.primary:focus {
+ background: var(--accent-600);
+ outline: none;
+}
diff --git a/orderly/client/src/styles/auth.css b/orderly/client/src/styles/auth.css
index 6c826b2..2b7be21 100644
--- a/orderly/client/src/styles/auth.css
+++ b/orderly/client/src/styles/auth.css
@@ -1,20 +1,10 @@
-:root {
- --bg: #0b1020;
- --panel: #12182b;
- --border: #26304a;
- --text: #e6e8f0;
- --muted: #9aa3b2;
- --accent: #3b82f6;
- --accent-600: #2563eb;
- --danger: #ef4444;
-}
-
html, body, #root {
height: 100%;
background: var(--bg);
color: var(--text);
margin: 0;
- font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji","Segoe UI Emoji";
+ font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji";
+ transition: background 0.3s ease, color 0.3s ease;
}
.auth-container {
@@ -22,27 +12,30 @@ html, body, #root {
display: grid;
place-items: center;
padding: 2rem 1rem;
+ background: var(--bg);
}
.auth-card {
width: 100%;
max-width: 420px;
- background: var(--panel);
- border: 1px solid var(--border);
+ background: var(--surface);
+ border: 1px solid var(--surface-border);
border-radius: 14px;
padding: 24px;
- box-shadow: 0 10px 30px rgba(0,0,0,0.25);
+ box-shadow: var(--shadow);
+ transition: background 0.3s ease, border-color 0.3s ease;
}
.auth-card h2 {
margin: 0 0 14px;
font-size: 1.4rem;
font-weight: 600;
+ color: var(--text);
}
.auth-subtitle {
margin: 0 0 18px;
- color: var(--muted);
+ color: var(--text-muted);
font-size: 0.95rem;
}
@@ -55,21 +48,26 @@ html, body, #root {
.field label {
font-size: 0.85rem;
- color: var(--muted);
+ color: var(--text-muted);
}
.field input {
- background: #0e1426;
+ background: var(--input-bg);
color: var(--text);
- border: 1px solid var(--border);
+ border: 1px solid var(--input-border);
border-radius: 10px;
padding: 10px 12px;
outline: none;
+ transition: border-color 0.2s ease, box-shadow 0.2s ease;
+}
+
+.field input::placeholder {
+ color: var(--text-muted);
}
.field input:focus {
border-color: var(--accent);
- box-shadow: 0 0 0 3px rgba(59,130,246,0.2);
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2);
}
.actions {
@@ -87,15 +85,24 @@ html, body, #root {
padding: 10px 14px;
font-weight: 600;
cursor: pointer;
+ transition: background 0.2s ease, transform 0.2s ease;
+}
+
+.btn:hover,
+.btn:focus {
+ background: var(--accent-600);
+ transform: translateY(-1px);
+ outline: none;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
+ transform: none;
}
.link {
- color: var(--muted);
+ color: var(--text-muted);
font-size: 0.9rem;
}
diff --git a/orderly/client/src/styles/theme.css b/orderly/client/src/styles/theme.css
new file mode 100644
index 0000000..577799a
--- /dev/null
+++ b/orderly/client/src/styles/theme.css
@@ -0,0 +1,58 @@
+:root {
+ color-scheme: light;
+ --bg: #f8fafc;
+ --surface: #ffffff;
+ --surface-border: #e2e8f0;
+ --text: #0f172a;
+ --text-muted: #475569;
+ --accent: #2563eb;
+ --accent-600: #1d4ed8;
+ --danger: #dc2626;
+ --code-bg: #0f172a;
+ --code-text: #f8fafc;
+ --input-bg: #ffffff;
+ --input-border: #cbd5f5;
+ --nav-bg: rgba(255, 255, 255, 0.75);
+ --nav-border: rgba(148, 163, 184, 0.5);
+ --shadow: 0 18px 45px rgba(15, 23, 42, 0.08);
+}
+
+:root[data-theme='dark'],
+[data-theme='dark'] {
+ color-scheme: dark;
+ --bg: #0b1020;
+ --surface: #12182b;
+ --surface-border: #26304a;
+ --text: #e6e8f0;
+ --text-muted: #9aa3b2;
+ --accent: #3b82f6;
+ --accent-600: #2563eb;
+ --danger: #f87171;
+ --code-bg: #0e1426;
+ --code-text: #e6e8f0;
+ --input-bg: #0e1426;
+ --input-border: #26304a;
+ --nav-bg: rgba(11, 16, 32, 0.85);
+ --nav-border: rgba(38, 48, 74, 0.9);
+ --shadow: 0 18px 45px rgba(0, 0, 0, 0.35);
+}
+
+body {
+ background: var(--bg);
+ color: var(--text);
+ font-family: 'Inter', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
+ margin: 0;
+ transition: background 0.3s ease, color 0.3s ease;
+}
+
+a {
+ color: var(--accent);
+}
+
+a:hover {
+ color: var(--accent-600);
+}
+
+button {
+ font-family: inherit;
+}
diff --git a/orderly/client/src/theme/ThemeProvider.jsx b/orderly/client/src/theme/ThemeProvider.jsx
new file mode 100644
index 0000000..4931b51
--- /dev/null
+++ b/orderly/client/src/theme/ThemeProvider.jsx
@@ -0,0 +1,69 @@
+import { createContext, useContext, useEffect, useMemo, useState } from 'react'
+
+const ThemeContext = createContext(null)
+const STORAGE_KEY = 'orderly-theme'
+
+function getInitialTheme() {
+ if (typeof window === 'undefined') return 'light'
+ try {
+ const stored = window.localStorage.getItem(STORAGE_KEY)
+ if (stored === 'light' || stored === 'dark') {
+ document.documentElement.dataset.theme = stored
+ return stored
+ }
+ } catch {
+ // ignore storage errors
+ }
+ const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
+ const fallback = prefersDark ? 'dark' : 'light'
+ document.documentElement.dataset.theme = fallback
+ return fallback
+}
+
+export function ThemeProvider({ children }) {
+ const [theme, setTheme] = useState(getInitialTheme)
+
+ useEffect(() => {
+ document.documentElement.dataset.theme = theme
+ try {
+ window.localStorage.setItem(STORAGE_KEY, theme)
+ } catch {
+ // ignore storage errors
+ }
+ }, [theme])
+
+ useEffect(() => {
+ if (!window.matchMedia) return
+ const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
+ const handleChange = (event) => {
+ try {
+ const stored = window.localStorage.getItem(STORAGE_KEY)
+ if (stored === 'light' || stored === 'dark') {
+ return
+ }
+ } catch {
+ // ignore storage errors
+ }
+ setTheme(event.matches ? 'dark' : 'light')
+ }
+ mediaQuery.addEventListener('change', handleChange)
+ return () => mediaQuery.removeEventListener('change', handleChange)
+ }, [])
+
+ const value = useMemo(() => ({
+ theme,
+ isDark: theme === 'dark',
+ toggleTheme: () => setTheme((prev) => (prev === 'dark' ? 'light' : 'dark')),
+ }), [theme])
+
+ return
{children}
+}
+
+// eslint-disable-next-line react-refresh/only-export-components
+export function useTheme() {
+ const ctx = useContext(ThemeContext)
+ if (!ctx) {
+ throw new Error('useTheme must be used within a ThemeProvider')
+ }
+ return ctx
+}
diff --git a/orderly/client/src/theme/ThemeToggle.jsx b/orderly/client/src/theme/ThemeToggle.jsx
new file mode 100644
index 0000000..32d4669
--- /dev/null
+++ b/orderly/client/src/theme/ThemeToggle.jsx
@@ -0,0 +1,18 @@
+import { useTheme } from './ThemeProvider'
+
+export default function ThemeToggle({ className = '' }) {
+ const { theme, toggleTheme } = useTheme()
+ const nextTheme = theme === 'dark' ? 'light' : 'dark'
+
+ return (
+
+ {theme === 'dark' ? '🌙' : '☀️'}
+ {theme === 'dark' ? 'Dark' : 'Light'} mode
+
+ )
+}
diff --git a/orderly/desktop/.env.example b/orderly/desktop/.env.example
new file mode 100644
index 0000000..e39ff29
--- /dev/null
+++ b/orderly/desktop/.env.example
@@ -0,0 +1,2 @@
+# API endpoint for the authentication server
+VITE_API_URL=http://localhost:3000/api
diff --git a/orderly/desktop/README.md b/orderly/desktop/README.md
new file mode 100644
index 0000000..095f2e6
--- /dev/null
+++ b/orderly/desktop/README.md
@@ -0,0 +1,33 @@
+# Orderly Desktop
+
+Electron + React desktop client that wraps the existing web SPA codebase.
+
+The renderer imports the routes, auth context, and UI components directly from
+`../client`, so both experiences stay in lockstep with zero duplication. Runtime
+configuration comes from the regular Vite env vars and, when packaged, the
+`window.orderlyDesktop.apiBaseUrl` bridge injected by the preload script.
+
+## Getting started
+
+```bash
+cd desktop
+npm install
+npm run dev
+```
+
+The development server starts Vite on port 5173 and spawns Electron once the
+renderer bundle is ready.
+
+Create a `.env` file based on `.env.example` to point the desktop shell at your
+API server (falls back to `http://localhost:3000/api`).
+
+## Packaging
+
+To create distributable binaries for macOS, Windows, and Linux, run:
+
+```bash
+npm run build
+```
+
+Artifacts are written to the `release/` directory using `electron-builder`
+targets (`dmg`, `nsis`, `AppImage`, `deb`).
diff --git a/orderly/desktop/electron/main.js b/orderly/desktop/electron/main.js
new file mode 100644
index 0000000..24e065f
--- /dev/null
+++ b/orderly/desktop/electron/main.js
@@ -0,0 +1,50 @@
+const { app, BrowserWindow, nativeTheme } = require('electron');
+const path = require('path');
+
+const isDev = !app.isPackaged;
+
+function createMainWindow() {
+ const mainWindow = new BrowserWindow({
+ width: 1280,
+ height: 800,
+ minWidth: 960,
+ minHeight: 600,
+ show: false,
+ backgroundColor: nativeTheme.shouldUseDarkColors ? '#1f2933' : '#f9fafb',
+ webPreferences: {
+ preload: path.join(__dirname, 'preload.js'),
+ contextIsolation: true,
+ sandbox: false,
+ },
+ });
+
+ mainWindow.on('ready-to-show', () => {
+ mainWindow.show();
+ });
+
+ const devServerUrl = process.env.VITE_DEV_SERVER_URL || 'http://localhost:5173';
+
+ if (isDev) {
+ mainWindow.loadURL(devServerUrl);
+ mainWindow.webContents.openDevTools({ mode: 'detach' });
+ } else {
+ const indexPath = path.join(__dirname, '..', 'dist', 'index.html');
+ mainWindow.loadFile(indexPath);
+ }
+}
+
+app.whenReady().then(() => {
+ createMainWindow();
+
+ app.on('activate', () => {
+ if (BrowserWindow.getAllWindows().length === 0) {
+ createMainWindow();
+ }
+ });
+});
+
+app.on('window-all-closed', () => {
+ if (process.platform !== 'darwin') {
+ app.quit();
+ }
+});
diff --git a/orderly/desktop/electron/preload.js b/orderly/desktop/electron/preload.js
new file mode 100644
index 0000000..a550a9f
--- /dev/null
+++ b/orderly/desktop/electron/preload.js
@@ -0,0 +1,7 @@
+const { contextBridge } = require('electron');
+
+const apiBaseUrl = process.env.API_URL || process.env.VITE_API_URL || 'http://localhost:3000/api';
+
+contextBridge.exposeInMainWorld('orderlyDesktop', {
+ apiBaseUrl,
+});
diff --git a/orderly/desktop/eslint.config.js b/orderly/desktop/eslint.config.js
new file mode 100644
index 0000000..cee1e2c
--- /dev/null
+++ b/orderly/desktop/eslint.config.js
@@ -0,0 +1,29 @@
+import js from '@eslint/js'
+import globals from 'globals'
+import reactHooks from 'eslint-plugin-react-hooks'
+import reactRefresh from 'eslint-plugin-react-refresh'
+import { defineConfig, globalIgnores } from 'eslint/config'
+
+export default defineConfig([
+ globalIgnores(['dist']),
+ {
+ files: ['**/*.{js,jsx}'],
+ extends: [
+ js.configs.recommended,
+ reactHooks.configs['recommended-latest'],
+ reactRefresh.configs.vite,
+ ],
+ languageOptions: {
+ ecmaVersion: 2020,
+ globals: globals.browser,
+ parserOptions: {
+ ecmaVersion: 'latest',
+ ecmaFeatures: { jsx: true },
+ sourceType: 'module',
+ },
+ },
+ rules: {
+ 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
+ },
+ },
+])
diff --git a/orderly/desktop/index.html b/orderly/desktop/index.html
new file mode 100644
index 0000000..05d3284
--- /dev/null
+++ b/orderly/desktop/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
Orderly Desktop
+
+
+
+
+
+
diff --git a/orderly/desktop/package.json b/orderly/desktop/package.json
new file mode 100644
index 0000000..b29c43b
--- /dev/null
+++ b/orderly/desktop/package.json
@@ -0,0 +1,68 @@
+{
+ "name": "orderly-desktop",
+ "productName": "Orderly Desktop",
+ "version": "0.1.0",
+ "private": true,
+ "description": "Cross-platform Electron desktop shell for the Orderly authentication demo.",
+ "main": "electron/main.js",
+ "author": "",
+ "license": "MIT",
+ "scripts": {
+ "postinstall": "electron-builder install-app-deps",
+ "dev": "concurrently \"npm:dev:renderer\" \"npm:dev:electron\"",
+ "dev:renderer": "vite",
+ "dev:electron": "wait-on tcp:5173 && electron .",
+ "build": "npm run build:renderer && npm run build:electron",
+ "build:renderer": "vite build",
+ "build:electron": "electron-builder",
+ "lint": "eslint \"src/**/*.{js,jsx}\""
+ },
+ "dependencies": {
+ "axios": "^1.11.0",
+ "react": "^19.1.1",
+ "react-dom": "^19.1.1",
+ "react-router-dom": "^7.8.2"
+ },
+ "devDependencies": {
+ "@eslint/js": "^9.33.0",
+ "@types/react": "^19.1.10",
+ "@types/react-dom": "^19.1.7",
+ "@vitejs/plugin-react": "^5.0.0",
+ "concurrently": "^9.1.0",
+ "electron": "^31.3.1",
+ "electron-builder": "^25.1.8",
+ "eslint": "^9.33.0",
+ "eslint-plugin-react-hooks": "^5.2.0",
+ "eslint-plugin-react-refresh": "^0.4.20",
+ "globals": "^16.3.0",
+ "wait-on": "^7.2.0",
+ "vite": "^7.1.2"
+ },
+ "build": {
+ "appId": "com.orderly.desktop",
+ "files": [
+ "dist/**/*",
+ "electron/**/*"
+ ],
+ "directories": {
+ "buildResources": "build",
+ "output": "release"
+ },
+ "mac": {
+ "target": "dmg"
+ },
+ "win": {
+ "target": "nsis"
+ },
+ "linux": {
+ "target": [
+ "AppImage",
+ "deb"
+ ],
+ "category": "Utility"
+ },
+ "extraMetadata": {
+ "main": "electron/main.js"
+ }
+ }
+}
diff --git a/orderly/desktop/public/vite.svg b/orderly/desktop/public/vite.svg
new file mode 100644
index 0000000..e7b8dfb
--- /dev/null
+++ b/orderly/desktop/public/vite.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/orderly/desktop/src/main.jsx b/orderly/desktop/src/main.jsx
new file mode 100644
index 0000000..4fd03f0
--- /dev/null
+++ b/orderly/desktop/src/main.jsx
@@ -0,0 +1,29 @@
+import { StrictMode } from 'react'
+import { createRoot } from 'react-dom/client'
+import { HashRouter } from 'react-router-dom'
+import App from '@web/App.jsx'
+import { AuthProvider } from '@web/auth/AuthContext.jsx'
+import { ThemeProvider } from '@web/theme/ThemeProvider.jsx'
+import '@web/styles/app.css'
+import api from '@web/api/api.js'
+
+api
+ .get('/health')
+ .then((r) => console.log('API health:', r.data))
+ .catch((err) => console.error('API health failed:', err.message))
+
+function DesktopShell() {
+ return (
+
+
+
+
+
+
+
+
+
+ )
+}
+
+createRoot(document.getElementById('root')).render(
)
diff --git a/orderly/desktop/vite.config.js b/orderly/desktop/vite.config.js
new file mode 100644
index 0000000..16a4936
--- /dev/null
+++ b/orderly/desktop/vite.config.js
@@ -0,0 +1,29 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+import { fileURLToPath } from 'node:url'
+import { dirname, resolve } from 'node:path'
+
+const __dirname = dirname(fileURLToPath(import.meta.url))
+
+export default defineConfig({
+ base: './',
+ plugins: [react()],
+ resolve: {
+ alias: {
+ '@web': resolve(__dirname, '../client/src'),
+ },
+ },
+ server: {
+ fs: {
+ allow: [resolve(__dirname, '..', 'client')],
+ },
+ },
+ optimizeDeps: {
+ include: ['react', 'react-dom', 'react-router-dom'],
+ },
+ build: {
+ rollupOptions: {
+ external: [],
+ },
+ },
+})
diff --git a/orderly/mobile/.env.example b/orderly/mobile/.env.example
new file mode 100644
index 0000000..c230577
--- /dev/null
+++ b/orderly/mobile/.env.example
@@ -0,0 +1,2 @@
+# API endpoint for the Orderly backend
+EXPO_PUBLIC_API_URL=http://localhost:3000/api
diff --git a/orderly/mobile/.gitignore b/orderly/mobile/.gitignore
new file mode 100644
index 0000000..bb4cd03
--- /dev/null
+++ b/orderly/mobile/.gitignore
@@ -0,0 +1,9 @@
+# Expo / React Native
+node_modules
+.expo
+.expo-shared
+web-build
+android
+ios
+*.log
+.env
diff --git a/orderly/mobile/App.js b/orderly/mobile/App.js
new file mode 100644
index 0000000..8502201
--- /dev/null
+++ b/orderly/mobile/App.js
@@ -0,0 +1,24 @@
+import { StatusBar } from 'expo-status-bar'
+import RootNavigator from './src/navigation/RootNavigator'
+import { AuthProvider } from './src/context/AuthContext'
+import { ThemeProvider, useTheme } from './src/context/ThemeContext'
+
+function Shell() {
+ const { isDark } = useTheme()
+ return (
+ <>
+
+
+ >
+ )
+}
+
+export default function App() {
+ return (
+
+
+
+
+
+ )
+}
diff --git a/orderly/mobile/README.md b/orderly/mobile/README.md
new file mode 100644
index 0000000..259cc39
--- /dev/null
+++ b/orderly/mobile/README.md
@@ -0,0 +1,59 @@
+# Orderly Mobile (React Native)
+
+A cross-platform React Native (Expo) client that mirrors the existing web dashboard. It
+shares the authentication flows (email/password + MFA) and consumes the same REST API
+endpoints for profile data, accounts invoices, sales orders, and admin user management.
+
+## Getting started
+
+```bash
+cd orderly/mobile
+npm install
+```
+
+Create a `.env` file based on `.env.example` and point `EXPO_PUBLIC_API_URL` at your API
+instance (the default assumes the Node server is running on `http://localhost:3000/api`).
+
+```bash
+cp .env.example .env
+```
+
+Then launch the Expo development server:
+
+```bash
+npm run start
+```
+
+From the Expo CLI UI you can open the native app on:
+
+- **iOS** simulators (or a physical device via the Expo Go app)
+- **Android** emulators/devices
+- **Web** (for quick inspection in a browser)
+
+For convenience there are shortcut scripts:
+
+```bash
+npm run ios # iOS simulator
+npm run android
+npm run web
+```
+
+## How it works
+
+- `src/context/AuthContext.js` mirrors the web client's auth provider, storing the access
+ token + user profile in `AsyncStorage` and attaching the access token to the shared Axios
+ instance. The refresh-token interceptor matches the browser behaviour so sessions stay
+ alive as long as the refresh cookie is valid.
+- `src/navigation/RootNavigator.jsx` toggles between the login stack and the authenticated
+ tab navigator. Tabs expose profile, accounts, sales, and admin screens, all guarded by
+ the same permission checks used on the web.
+- Each screen hits the corresponding API endpoint and renders either the JSON payload or a
+ friendly permission error when the user lacks access.
+
+## Linting
+
+You can run Expo's lint rules with:
+
+```bash
+npm run lint
+```
diff --git a/orderly/mobile/app.config.js b/orderly/mobile/app.config.js
new file mode 100644
index 0000000..e751f85
--- /dev/null
+++ b/orderly/mobile/app.config.js
@@ -0,0 +1,29 @@
+export default () => ({
+ expo: {
+ name: 'Orderly Mobile',
+ slug: 'orderly-mobile',
+ version: '1.0.0',
+ orientation: 'portrait',
+ scheme: 'orderly',
+ userInterfaceStyle: 'light',
+ splash: {
+ resizeMode: 'contain',
+ backgroundColor: '#ffffff',
+ },
+ assetBundlePatterns: ['**/*'],
+ ios: {
+ supportsTablet: true,
+ },
+ android: {
+ adaptiveIcon: {
+ backgroundColor: '#ffffff',
+ },
+ },
+ web: {
+ bundler: 'metro',
+ },
+ extra: {
+ apiBaseUrl: process.env.EXPO_PUBLIC_API_URL ?? 'http://localhost:3000/api',
+ },
+ },
+})
diff --git a/orderly/mobile/babel.config.js b/orderly/mobile/babel.config.js
new file mode 100644
index 0000000..e1e3637
--- /dev/null
+++ b/orderly/mobile/babel.config.js
@@ -0,0 +1,6 @@
+module.exports = function (api) {
+ api.cache(true)
+ return {
+ presets: ['babel-preset-expo'],
+ }
+}
diff --git a/orderly/mobile/package.json b/orderly/mobile/package.json
new file mode 100644
index 0000000..da607ec
--- /dev/null
+++ b/orderly/mobile/package.json
@@ -0,0 +1,31 @@
+{
+ "name": "orderly-mobile",
+ "version": "0.1.0",
+ "private": true,
+ "main": "node_modules/expo/AppEntry.js",
+ "scripts": {
+ "start": "expo start",
+ "android": "expo start --android",
+ "ios": "expo start --ios",
+ "web": "expo start --web",
+ "lint": "expo lint"
+ },
+ "dependencies": {
+ "@react-native-async-storage/async-storage": "~1.21.0",
+ "@react-navigation/bottom-tabs": "^6.5.12",
+ "@react-navigation/native": "^6.1.6",
+ "@react-navigation/native-stack": "^6.9.12",
+ "axios": "^1.6.7",
+ "expo": "~50.0.6",
+ "expo-constants": "~15.4.5",
+ "expo-status-bar": "~1.11.1",
+ "react": "18.2.0",
+ "react-native": "0.73.4",
+ "react-native-safe-area-context": "4.8.2",
+ "react-native-screens": "~3.29.0"
+ },
+ "devDependencies": {
+ "@babel/core": "^7.20.0",
+ "babel-preset-expo": "^10.0.1"
+ }
+}
diff --git a/orderly/mobile/src/api/api.js b/orderly/mobile/src/api/api.js
new file mode 100644
index 0000000..7deabf6
--- /dev/null
+++ b/orderly/mobile/src/api/api.js
@@ -0,0 +1,59 @@
+import axios from 'axios'
+import Constants from 'expo-constants'
+
+const expoExtra = Constants?.expoConfig?.extra ?? {}
+const fallbackBaseUrl =
+ process.env.EXPO_PUBLIC_API_URL || expoExtra.apiBaseUrl || 'http://localhost:3000/api'
+
+const api = axios.create({
+ baseURL: fallbackBaseUrl,
+ withCredentials: true,
+})
+
+export function setAccessToken(token) {
+ if (token) {
+ api.defaults.headers.common.Authorization = `Bearer ${token}`
+ } else {
+ delete api.defaults.headers.common.Authorization
+ }
+}
+
+let refreshing = null
+
+api.interceptors.response.use(
+ (response) => response,
+ async (error) => {
+ const { response, config } = error || {}
+ if (!response || response.status !== 401 || config?.__retried) {
+ throw error
+ }
+
+ try {
+ if (!refreshing) {
+ refreshing = api.post('/auth/refresh').finally(() => {
+ refreshing = null
+ })
+ }
+
+ const refreshResp = await refreshing
+ const newToken = refreshResp?.data?.accessToken
+ if (!newToken) {
+ throw error
+ }
+
+ setAccessToken(newToken)
+ config.__retried = true
+ config.headers = {
+ ...(config.headers || {}),
+ Authorization: `Bearer ${newToken}`,
+ }
+
+ return api(config)
+ } catch (refreshError) {
+ setAccessToken(null)
+ throw refreshError
+ }
+ }
+)
+
+export default api
diff --git a/orderly/mobile/src/components/LoadingOverlay.jsx b/orderly/mobile/src/components/LoadingOverlay.jsx
new file mode 100644
index 0000000..1c02da7
--- /dev/null
+++ b/orderly/mobile/src/components/LoadingOverlay.jsx
@@ -0,0 +1,28 @@
+import { ActivityIndicator, StyleSheet, Text, View } from 'react-native'
+import { useTheme } from '../context/ThemeContext'
+
+export default function LoadingOverlay({ message = 'Loading...' }) {
+ const { colors } = useTheme()
+
+ return (
+
+
+ {message}
+
+ )
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ padding: 24,
+ },
+ message: {
+ marginTop: 12,
+ fontSize: 16,
+ textAlign: 'center',
+ fontWeight: '600',
+ },
+})
diff --git a/orderly/mobile/src/components/ThemeToggleButton.jsx b/orderly/mobile/src/components/ThemeToggleButton.jsx
new file mode 100644
index 0000000..3c29be0
--- /dev/null
+++ b/orderly/mobile/src/components/ThemeToggleButton.jsx
@@ -0,0 +1,31 @@
+import { StyleSheet, Text, TouchableOpacity } from 'react-native'
+import { useTheme } from '../context/ThemeContext'
+
+export default function ThemeToggleButton() {
+ const { isDark, toggleTheme, colors } = useTheme()
+
+ return (
+
+ {isDark ? '🌙' : '☀️'}
+
+ )
+}
+
+const styles = StyleSheet.create({
+ button: {
+ paddingHorizontal: 12,
+ paddingVertical: 6,
+ borderRadius: 999,
+ borderWidth: 1,
+ marginRight: 12,
+ },
+ label: {
+ fontSize: 16,
+ fontWeight: '600',
+ },
+})
diff --git a/orderly/mobile/src/context/AuthContext.js b/orderly/mobile/src/context/AuthContext.js
new file mode 100644
index 0000000..f336fe1
--- /dev/null
+++ b/orderly/mobile/src/context/AuthContext.js
@@ -0,0 +1,171 @@
+import AsyncStorage from '@react-native-async-storage/async-storage'
+import { createContext, useContext, useEffect, useMemo, useState } from 'react'
+import api, { setAccessToken } from '../api/api'
+
+const AuthContext = createContext(null)
+
+const STORAGE_KEYS = {
+ token: 'orderly/accessToken',
+ user: 'orderly/user',
+ nextPath: 'orderly/nextPath',
+}
+
+async function saveToStorage({ token, user, nextPath }) {
+ const operations = []
+ if (token != null) {
+ operations.push(AsyncStorage.setItem(STORAGE_KEYS.token, token))
+ }
+ if (user != null) {
+ operations.push(AsyncStorage.setItem(STORAGE_KEYS.user, JSON.stringify(user)))
+ }
+ if (nextPath != null) {
+ operations.push(AsyncStorage.setItem(STORAGE_KEYS.nextPath, nextPath))
+ }
+ if (operations.length) {
+ await Promise.all(operations)
+ }
+}
+
+async function loadFromStorage() {
+ const [token, userRaw, nextPath] = await Promise.all([
+ AsyncStorage.getItem(STORAGE_KEYS.token),
+ AsyncStorage.getItem(STORAGE_KEYS.user),
+ AsyncStorage.getItem(STORAGE_KEYS.nextPath),
+ ])
+
+ return {
+ token,
+ user: userRaw ? JSON.parse(userRaw) : null,
+ nextPath: nextPath || '/profile',
+ }
+}
+
+async function clearStorage() {
+ await Promise.all(
+ Object.values(STORAGE_KEYS).map((key) => AsyncStorage.removeItem(key))
+ )
+}
+
+export function AuthProvider({ children }) {
+ const [accessToken, setToken] = useState(null)
+ const [user, setUser] = useState(null)
+ const [nextPath, setNextPath] = useState('/profile')
+ const [loading, setLoading] = useState(false)
+ const [booting, setBooting] = useState(true)
+
+ useEffect(() => {
+ let active = true
+
+ async function bootstrap() {
+ try {
+ const stored = await loadFromStorage()
+ if (!active) return
+
+ if (stored.token) {
+ setToken(stored.token)
+ setAccessToken(stored.token)
+ }
+ if (stored.user) {
+ setUser(stored.user)
+ }
+ if (stored.nextPath) {
+ setNextPath(stored.nextPath)
+ }
+ } finally {
+ if (active) setBooting(false)
+ }
+ }
+
+ bootstrap()
+
+ return () => {
+ active = false
+ }
+ }, [])
+
+ async function setSession({ token, user: userData, nextPath: incomingNextPath }) {
+ if (token) {
+ setToken(token)
+ setAccessToken(token)
+ }
+ if (userData) {
+ setUser(userData)
+ }
+ if (incomingNextPath) {
+ setNextPath(incomingNextPath)
+ }
+ await saveToStorage({ token, user: userData, nextPath: incomingNextPath })
+ }
+
+ async function clearSession() {
+ setToken(null)
+ setAccessToken(null)
+ setUser(null)
+ setNextPath('/profile')
+ await clearStorage()
+ }
+
+ async function login({ email, password, totp, backupCode }) {
+ setLoading(true)
+ try {
+ const response = await api.post('/auth/login', {
+ email,
+ password,
+ totp,
+ backupCode,
+ })
+
+ const { accessToken: token, user: userPayload, nextPath: responseNextPath } =
+ response.data || {}
+
+ if (!token) {
+ return { ok: false, error: 'No access token returned from server' }
+ }
+
+ await setSession({ token, user: userPayload, nextPath: responseNextPath })
+
+ return { ok: true, nextPath: responseNextPath || '/profile' }
+ } catch (error) {
+ const message =
+ error?.response?.data?.error ||
+ error?.response?.data?.errorMessage ||
+ 'Unable to login'
+ return { ok: false, error: message }
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ async function logout() {
+ try {
+ await api.post('/auth/logout')
+ } catch (error) {
+ // Ignore logout failures but log for debugging
+ console.warn('[logout] request failed', error?.response?.data || error.message)
+ } finally {
+ await clearSession()
+ }
+ }
+
+ const value = useMemo(
+ () => ({
+ accessToken,
+ user,
+ nextPath,
+ loading,
+ booting,
+ login,
+ logout,
+ setSession,
+ clearSession,
+ setBooting,
+ }),
+ [accessToken, user, nextPath, loading, booting]
+ )
+
+ return
{children}
+}
+
+export function useAuth() {
+ return useContext(AuthContext)
+}
diff --git a/orderly/mobile/src/context/ThemeContext.js b/orderly/mobile/src/context/ThemeContext.js
new file mode 100644
index 0000000..99f3fe4
--- /dev/null
+++ b/orderly/mobile/src/context/ThemeContext.js
@@ -0,0 +1,111 @@
+import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
+import { Appearance } from 'react-native'
+import AsyncStorage from '@react-native-async-storage/async-storage'
+import { DarkTheme, DefaultTheme } from '@react-navigation/native'
+
+const STORAGE_KEY = 'orderly-mobile-theme'
+
+const palettes = {
+ light: {
+ background: '#f8fafc',
+ surface: '#ffffff',
+ surfaceAlt: '#e2e8f0',
+ text: '#0f172a',
+ textMuted: '#475569',
+ border: '#d0d5dd',
+ accent: '#2563eb',
+ danger: '#dc2626',
+ codeBg: '#111827',
+ codeText: '#f8fafc',
+ overlay: 'rgba(248, 250, 252, 0.85)',
+ },
+ dark: {
+ background: '#0b1020',
+ surface: '#12182b',
+ surfaceAlt: '#26304a',
+ text: '#e6e8f0',
+ textMuted: '#9aa3b2',
+ border: '#26304a',
+ accent: '#3b82f6',
+ danger: '#f87171',
+ codeBg: '#0e1426',
+ codeText: '#e6e8f0',
+ overlay: 'rgba(11, 16, 32, 0.85)',
+ },
+}
+
+const ThemeContext = createContext(null)
+
+export function ThemeProvider({ children }) {
+ const systemScheme = Appearance.getColorScheme()
+ const [theme, setTheme] = useState(systemScheme === 'dark' ? 'dark' : 'light')
+ const [explicit, setExplicit] = useState(false)
+
+ useEffect(() => {
+ AsyncStorage.getItem(STORAGE_KEY)
+ .then((stored) => {
+ if (stored === 'light' || stored === 'dark') {
+ setTheme(stored)
+ setExplicit(true)
+ }
+ })
+ .catch(() => {
+ // ignore storage errors
+ })
+ }, [])
+
+ useEffect(() => {
+ if (explicit) return undefined
+ const subscription = Appearance.addChangeListener(({ colorScheme }) => {
+ setTheme(colorScheme === 'dark' ? 'dark' : 'light')
+ })
+ return () => subscription.remove()
+ }, [explicit])
+
+ useEffect(() => {
+ if (!explicit) return
+ AsyncStorage.setItem(STORAGE_KEY, theme).catch(() => {
+ // ignore storage errors
+ })
+ }, [theme, explicit])
+
+ const toggleTheme = useCallback(() => {
+ setExplicit(true)
+ setTheme((prev) => (prev === 'dark' ? 'light' : 'dark'))
+ }, [])
+
+ const value = useMemo(() => {
+ const palette = palettes[theme]
+ const base = theme === 'dark' ? DarkTheme : DefaultTheme
+ const navTheme = {
+ ...base,
+ colors: {
+ ...base.colors,
+ primary: palette.accent,
+ background: palette.background,
+ card: palette.surface,
+ text: palette.text,
+ border: palette.border,
+ notification: palette.accent,
+ },
+ }
+
+ return {
+ theme,
+ isDark: theme === 'dark',
+ colors: palette,
+ navTheme,
+ toggleTheme,
+ }
+ }, [theme, toggleTheme])
+
+ return
{children}
+}
+
+export function useTheme() {
+ const ctx = useContext(ThemeContext)
+ if (!ctx) {
+ throw new Error('useTheme must be used within a ThemeProvider')
+ }
+ return ctx
+}
diff --git a/orderly/mobile/src/navigation/RootNavigator.jsx b/orderly/mobile/src/navigation/RootNavigator.jsx
new file mode 100644
index 0000000..243f284
--- /dev/null
+++ b/orderly/mobile/src/navigation/RootNavigator.jsx
@@ -0,0 +1,69 @@
+import { NavigationContainer } from '@react-navigation/native'
+import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'
+import { createNativeStackNavigator } from '@react-navigation/native-stack'
+import { useAuth } from '../context/AuthContext'
+import LoadingOverlay from '../components/LoadingOverlay'
+import LoginScreen from '../screens/LoginScreen'
+import ProfileScreen from '../screens/ProfileScreen'
+import AccountsScreen from '../screens/AccountsScreen'
+import SalesScreen from '../screens/SalesScreen'
+import AdminUsersScreen from '../screens/AdminUsersScreen'
+import { useTheme } from '../context/ThemeContext'
+import ThemeToggleButton from '../components/ThemeToggleButton'
+
+const Stack = createNativeStackNavigator()
+const Tab = createBottomTabNavigator()
+
+function AuthStack() {
+ return (
+
+
+
+ )
+}
+
+function AppTabs() {
+ const { colors } = useTheme()
+ return (
+
,
+ tabBarStyle: {
+ backgroundColor: colors.surface,
+ borderTopColor: colors.border,
+ },
+ tabBarActiveTintColor: colors.accent,
+ tabBarInactiveTintColor: colors.textMuted,
+ }}
+ >
+
+
+
+
+
+ )
+}
+
+export default function RootNavigator() {
+ const { accessToken, booting } = useAuth()
+ const { navTheme } = useTheme()
+
+ if (booting) {
+ return
+ }
+
+ return (
+
+ {accessToken ? : }
+
+ )
+}
diff --git a/orderly/mobile/src/screens/AccountsScreen.jsx b/orderly/mobile/src/screens/AccountsScreen.jsx
new file mode 100644
index 0000000..8101981
--- /dev/null
+++ b/orderly/mobile/src/screens/AccountsScreen.jsx
@@ -0,0 +1,151 @@
+import { useEffect, useMemo, useState } from 'react'
+import { FlatList, RefreshControl, SafeAreaView, StyleSheet, Text, View } from 'react-native'
+import { useAuth } from '../context/AuthContext'
+import { useTheme } from '../context/ThemeContext'
+import api from '../api/api'
+
+export default function AccountsScreen() {
+ const { user } = useAuth()
+ const { colors } = useTheme()
+ const [invoices, setInvoices] = useState([])
+ const [error, setError] = useState(null)
+ const [refreshing, setRefreshing] = useState(false)
+ const styles = useMemo(() => createStyles(colors), [colors])
+
+ const canView = user?.permissions?.includes('accounts:read')
+
+ async function loadInvoices() {
+ if (!canView) {
+ return
+ }
+
+ try {
+ const response = await api.get('/accounts/invoices')
+ setInvoices(response.data?.invoices || [])
+ setError(null)
+ } catch (err) {
+ setError(err?.response?.data?.error || err.message)
+ }
+ }
+
+ useEffect(() => {
+ loadInvoices()
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [canView])
+
+ async function handleRefresh() {
+ setRefreshing(true)
+ await loadInvoices()
+ setRefreshing(false)
+ }
+
+ return (
+
+ {!canView ? (
+
+ Access restricted
+
+ Your account does not have permission to view Accounts invoices.
+
+
+ ) : (
+ item.id || item._id || String(item.number)}
+ contentContainerStyle={styles.list}
+ refreshControl={
+
+ }
+ ListHeaderComponent={() => (
+
+ Accounts Invoices
+ {error ? Error: {error} : null}
+
+ )}
+ renderItem={({ item }) => (
+
+ Invoice #{item.number}
+ Customer: {item.customer}
+ Total: {item.total}
+ Status: {item.status}
+
+ )}
+ ListEmptyComponent={() => (
+ No invoices found.
+ )}
+ />
+ )}
+
+ )
+}
+
+const createStyles = (colors) =>
+ StyleSheet.create({
+ safeArea: {
+ flex: 1,
+ backgroundColor: colors.background,
+ },
+ centered: {
+ flex: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ padding: 24,
+ backgroundColor: colors.background,
+ },
+ title: {
+ fontSize: 20,
+ fontWeight: '700',
+ marginBottom: 8,
+ color: colors.text,
+ },
+ subtitle: {
+ fontSize: 14,
+ color: colors.textMuted,
+ textAlign: 'center',
+ },
+ list: {
+ paddingHorizontal: 16,
+ paddingBottom: 16,
+ backgroundColor: colors.background,
+ },
+ header: {
+ marginBottom: 12,
+ },
+ error: {
+ color: colors.danger,
+ marginTop: 4,
+ },
+ card: {
+ backgroundColor: colors.surface,
+ borderRadius: 16,
+ padding: 16,
+ borderWidth: 1,
+ borderColor: colors.border,
+ marginBottom: 12,
+ shadowColor: '#000',
+ shadowOpacity: 0.08,
+ shadowRadius: 12,
+ shadowOffset: { width: 0, height: 6 },
+ elevation: 3,
+ },
+ invoiceTitle: {
+ fontSize: 16,
+ fontWeight: '600',
+ marginBottom: 8,
+ color: colors.text,
+ },
+ invoiceDetail: {
+ fontSize: 14,
+ color: colors.textMuted,
+ },
+ empty: {
+ textAlign: 'center',
+ padding: 40,
+ color: colors.textMuted,
+ },
+ })
diff --git a/orderly/mobile/src/screens/AdminUsersScreen.jsx b/orderly/mobile/src/screens/AdminUsersScreen.jsx
new file mode 100644
index 0000000..56154da
--- /dev/null
+++ b/orderly/mobile/src/screens/AdminUsersScreen.jsx
@@ -0,0 +1,152 @@
+import { useEffect, useMemo, useState } from 'react'
+import { FlatList, RefreshControl, SafeAreaView, StyleSheet, Text, View } from 'react-native'
+import { useAuth } from '../context/AuthContext'
+import { useTheme } from '../context/ThemeContext'
+import api from '../api/api'
+
+export default function AdminUsersScreen() {
+ const { user } = useAuth()
+ const { colors } = useTheme()
+ const [users, setUsers] = useState([])
+ const [error, setError] = useState(null)
+ const [refreshing, setRefreshing] = useState(false)
+ const styles = useMemo(() => createStyles(colors), [colors])
+
+ const canView = user?.permissions?.includes('admin:read')
+
+ async function loadUsers() {
+ if (!canView) {
+ return
+ }
+
+ try {
+ const response = await api.get('/admin/users')
+ setUsers(response.data?.users || [])
+ setError(null)
+ } catch (err) {
+ setError(err?.response?.data?.error || err.message)
+ }
+ }
+
+ useEffect(() => {
+ loadUsers()
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [canView])
+
+ async function handleRefresh() {
+ setRefreshing(true)
+ await loadUsers()
+ setRefreshing(false)
+ }
+
+ return (
+
+ {!canView ? (
+
+ Admin access required
+
+ Only administrators can view the full user directory.
+
+
+ ) : (
+ item.id || item._id || item.email}
+ contentContainerStyle={styles.list}
+ refreshControl={
+
+ }
+ ListHeaderComponent={() => (
+
+ Admin • Users
+ {error ? Error: {error} : null}
+
+ )}
+ renderItem={({ item }) => (
+
+ {item.email}
+ Roles: {(item.roles || []).join(', ') || '—'}
+
+ Permissions: {(item.permissions || []).join(', ') || '—'}
+
+
+ )}
+ ListEmptyComponent={() => (
+ No users found.
+ )}
+ />
+ )}
+
+ )
+}
+
+const createStyles = (colors) =>
+ StyleSheet.create({
+ safeArea: {
+ flex: 1,
+ backgroundColor: colors.background,
+ },
+ centered: {
+ flex: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ padding: 24,
+ backgroundColor: colors.background,
+ },
+ title: {
+ fontSize: 20,
+ fontWeight: '700',
+ marginBottom: 8,
+ color: colors.text,
+ },
+ subtitle: {
+ fontSize: 14,
+ color: colors.textMuted,
+ textAlign: 'center',
+ },
+ list: {
+ paddingHorizontal: 16,
+ paddingBottom: 16,
+ backgroundColor: colors.background,
+ },
+ header: {
+ marginBottom: 12,
+ },
+ error: {
+ color: colors.danger,
+ marginTop: 4,
+ },
+ card: {
+ backgroundColor: colors.surface,
+ borderRadius: 16,
+ padding: 16,
+ borderWidth: 1,
+ borderColor: colors.border,
+ marginBottom: 12,
+ shadowColor: '#000',
+ shadowOpacity: 0.08,
+ shadowRadius: 12,
+ shadowOffset: { width: 0, height: 6 },
+ elevation: 3,
+ },
+ userEmail: {
+ fontSize: 16,
+ fontWeight: '600',
+ marginBottom: 8,
+ color: colors.text,
+ },
+ userDetail: {
+ fontSize: 14,
+ color: colors.textMuted,
+ },
+ empty: {
+ textAlign: 'center',
+ padding: 40,
+ color: colors.textMuted,
+ },
+ })
diff --git a/orderly/mobile/src/screens/LoginScreen.jsx b/orderly/mobile/src/screens/LoginScreen.jsx
new file mode 100644
index 0000000..1d71ce7
--- /dev/null
+++ b/orderly/mobile/src/screens/LoginScreen.jsx
@@ -0,0 +1,187 @@
+import { useMemo, useState } from 'react'
+import {
+ Alert,
+ KeyboardAvoidingView,
+ Platform,
+ ScrollView,
+ StyleSheet,
+ Text,
+ TextInput,
+ TouchableOpacity,
+ View,
+} from 'react-native'
+import { useAuth } from '../context/AuthContext'
+import { useTheme } from '../context/ThemeContext'
+
+export default function LoginScreen() {
+ const { login, loading } = useAuth()
+ const { colors } = useTheme()
+ const [email, setEmail] = useState('')
+ const [password, setPassword] = useState('')
+ const [totp, setTotp] = useState('')
+ const [backupCode, setBackupCode] = useState('')
+ const styles = useMemo(() => createStyles(colors), [colors])
+
+ async function handleSubmit() {
+ if (!email || !password) {
+ Alert.alert('Missing credentials', 'Please enter your email and password.')
+ return
+ }
+
+ const result = await login({ email, password, totp, backupCode })
+ if (!result.ok) {
+ Alert.alert('Login failed', result.error || 'Please try again.')
+ }
+ }
+
+ return (
+
+
+
+ Orderly Mobile Login
+
+ Sign in with the same credentials you use on the web dashboard.
+
+
+ Email
+
+
+ Password
+
+
+ Two-factor authentication (optional)
+
+
+
+
+
+ {loading ? 'Signing in…' : 'Sign in'}
+
+
+
+
+ )
+}
+
+const createStyles = (colors) =>
+ StyleSheet.create({
+ flex: {
+ flex: 1,
+ backgroundColor: colors.background,
+ },
+ container: {
+ flexGrow: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ padding: 24,
+ },
+ card: {
+ width: '100%',
+ maxWidth: 420,
+ backgroundColor: colors.surface,
+ borderRadius: 16,
+ padding: 24,
+ borderWidth: 1,
+ borderColor: colors.border,
+ shadowColor: '#000',
+ shadowOpacity: 0.12,
+ shadowRadius: 18,
+ shadowOffset: { width: 0, height: 10 },
+ elevation: 6,
+ },
+ heading: {
+ fontSize: 24,
+ fontWeight: '600',
+ marginBottom: 8,
+ textAlign: 'center',
+ color: colors.text,
+ },
+ description: {
+ fontSize: 14,
+ color: colors.textMuted,
+ textAlign: 'center',
+ marginBottom: 24,
+ },
+ label: {
+ fontSize: 14,
+ fontWeight: '500',
+ marginBottom: 6,
+ color: colors.text,
+ },
+ input: {
+ borderWidth: 1,
+ borderColor: colors.border,
+ borderRadius: 10,
+ paddingHorizontal: 12,
+ paddingVertical: 10,
+ marginBottom: 16,
+ fontSize: 16,
+ backgroundColor: colors.surfaceAlt,
+ color: colors.text,
+ },
+ subHeading: {
+ fontSize: 12,
+ fontWeight: '500',
+ marginBottom: 8,
+ color: colors.textMuted,
+ },
+ button: {
+ backgroundColor: colors.accent,
+ borderRadius: 10,
+ paddingVertical: 14,
+ alignItems: 'center',
+ },
+ buttonDisabled: {
+ opacity: 0.7,
+ },
+ buttonLabel: {
+ color: '#ffffff',
+ fontSize: 16,
+ fontWeight: '600',
+ },
+ })
diff --git a/orderly/mobile/src/screens/ProfileScreen.jsx b/orderly/mobile/src/screens/ProfileScreen.jsx
new file mode 100644
index 0000000..c974c99
--- /dev/null
+++ b/orderly/mobile/src/screens/ProfileScreen.jsx
@@ -0,0 +1,123 @@
+import { useEffect, useMemo, useState } from 'react'
+import { Platform, RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native'
+import { SafeAreaView } from 'react-native-safe-area-context'
+import { useAuth } from '../context/AuthContext'
+import { useTheme } from '../context/ThemeContext'
+import api from '../api/api'
+
+export default function ProfileScreen() {
+ const { user, logout } = useAuth()
+ const { colors } = useTheme()
+ const [profile, setProfile] = useState(null)
+ const [error, setError] = useState(null)
+ const [refreshing, setRefreshing] = useState(false)
+ const styles = useMemo(() => createStyles(colors), [colors])
+
+ async function loadProfile() {
+ try {
+ const response = await api.get('/profile')
+ setProfile(response.data)
+ setError(null)
+ } catch (err) {
+ setError(err?.response?.data?.error || err.message)
+ }
+ }
+
+ useEffect(() => {
+ loadProfile()
+ }, [])
+
+ async function handleRefresh() {
+ setRefreshing(true)
+ await loadProfile()
+ setRefreshing(false)
+ }
+
+ return (
+
+
+ }
+ >
+ Welcome back
+
+ Auth context user
+
+ {JSON.stringify(user, null, 2)}
+
+
+
+
+ /profile response
+
+
+ {error
+ ? `Error: ${error}`
+ : profile
+ ? JSON.stringify(profile, null, 2)
+ : 'Loading profile…'}
+
+
+
+
+
+ Sign out
+
+
+
+ )
+}
+
+const createStyles = (colors) =>
+ StyleSheet.create({
+ safeArea: {
+ flex: 1,
+ backgroundColor: colors.background,
+ },
+ container: {
+ padding: 24,
+ },
+ heading: {
+ fontSize: 22,
+ fontWeight: '700',
+ marginBottom: 16,
+ color: colors.text,
+ },
+ section: {
+ marginBottom: 24,
+ backgroundColor: colors.surface,
+ borderRadius: 16,
+ borderWidth: 1,
+ borderColor: colors.border,
+ padding: 16,
+ },
+ sectionTitle: {
+ fontSize: 16,
+ fontWeight: '600',
+ marginBottom: 8,
+ color: colors.text,
+ },
+ codeBlock: {
+ borderRadius: 10,
+ backgroundColor: colors.codeBg,
+ padding: 16,
+ },
+ code: {
+ color: colors.codeText,
+ fontFamily: Platform.select({ ios: 'Menlo', android: 'monospace', default: 'monospace' }),
+ fontSize: 12,
+ },
+ logout: {
+ marginTop: 8,
+ fontSize: 16,
+ color: colors.danger,
+ fontWeight: '600',
+ },
+ })
diff --git a/orderly/mobile/src/screens/SalesScreen.jsx b/orderly/mobile/src/screens/SalesScreen.jsx
new file mode 100644
index 0000000..8378465
--- /dev/null
+++ b/orderly/mobile/src/screens/SalesScreen.jsx
@@ -0,0 +1,151 @@
+import { useEffect, useMemo, useState } from 'react'
+import { FlatList, RefreshControl, SafeAreaView, StyleSheet, Text, View } from 'react-native'
+import { useAuth } from '../context/AuthContext'
+import { useTheme } from '../context/ThemeContext'
+import api from '../api/api'
+
+export default function SalesScreen() {
+ const { user } = useAuth()
+ const { colors } = useTheme()
+ const [orders, setOrders] = useState([])
+ const [error, setError] = useState(null)
+ const [refreshing, setRefreshing] = useState(false)
+ const styles = useMemo(() => createStyles(colors), [colors])
+
+ const canView = user?.permissions?.includes('sales:read')
+
+ async function loadOrders() {
+ if (!canView) {
+ return
+ }
+
+ try {
+ const response = await api.get('/sales/orders')
+ setOrders(response.data?.orders || [])
+ setError(null)
+ } catch (err) {
+ setError(err?.response?.data?.error || err.message)
+ }
+ }
+
+ useEffect(() => {
+ loadOrders()
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [canView])
+
+ async function handleRefresh() {
+ setRefreshing(true)
+ await loadOrders()
+ setRefreshing(false)
+ }
+
+ return (
+
+ {!canView ? (
+
+ Access restricted
+
+ You need the sales:read permission to see order data.
+
+
+ ) : (
+ item.id || item._id || String(item.number)}
+ contentContainerStyle={styles.list}
+ refreshControl={
+
+ }
+ ListHeaderComponent={() => (
+
+ Sales Orders
+ {error ? Error: {error} : null}
+
+ )}
+ renderItem={({ item }) => (
+
+ Order #{item.number}
+ Customer: {item.customer}
+ Amount: {item.amount}
+ Status: {item.status}
+
+ )}
+ ListEmptyComponent={() => (
+ No orders found.
+ )}
+ />
+ )}
+
+ )
+}
+
+const createStyles = (colors) =>
+ StyleSheet.create({
+ safeArea: {
+ flex: 1,
+ backgroundColor: colors.background,
+ },
+ centered: {
+ flex: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ padding: 24,
+ backgroundColor: colors.background,
+ },
+ title: {
+ fontSize: 20,
+ fontWeight: '700',
+ marginBottom: 8,
+ color: colors.text,
+ },
+ subtitle: {
+ fontSize: 14,
+ color: colors.textMuted,
+ textAlign: 'center',
+ },
+ list: {
+ paddingHorizontal: 16,
+ paddingBottom: 16,
+ backgroundColor: colors.background,
+ },
+ header: {
+ marginBottom: 12,
+ },
+ error: {
+ color: colors.danger,
+ marginTop: 4,
+ },
+ card: {
+ backgroundColor: colors.surface,
+ borderRadius: 16,
+ padding: 16,
+ borderWidth: 1,
+ borderColor: colors.border,
+ marginBottom: 12,
+ shadowColor: '#000',
+ shadowOpacity: 0.08,
+ shadowRadius: 12,
+ shadowOffset: { width: 0, height: 6 },
+ elevation: 3,
+ },
+ orderTitle: {
+ fontSize: 16,
+ fontWeight: '600',
+ marginBottom: 8,
+ color: colors.text,
+ },
+ orderDetail: {
+ fontSize: 14,
+ color: colors.textMuted,
+ },
+ empty: {
+ textAlign: 'center',
+ padding: 40,
+ color: colors.textMuted,
+ },
+ })