conversion astryx

This commit is contained in:
2026-08-13 11:07:54 +05:30
parent 7c26470c52
commit d935542b11
36 changed files with 3843 additions and 684 deletions

942
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,10 @@
"lint": "tsc --noEmit"
},
"dependencies": {
"@astryxdesign/core": "0.3.0",
"@astryxdesign/theme-neutral": "0.3.0",
"@google/genai": "^2.4.0",
"@stylexjs/stylex": "^0.19.0",
"@tailwindcss/vite": "^4.1.14",
"@tanstack/react-query": "^5.101.0",
"@types/leaflet": "^1.9.21",
@@ -32,8 +35,11 @@
"xlsx": "^0.18.5"
},
"devDependencies": {
"@astryxdesign/cli": "^0.3.0",
"@types/express": "^4.17.21",
"@types/node": "^22.14.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"autoprefixer": "^10.4.21",
"esbuild": "^0.25.0",
"tailwindcss": "^4.1.14",

View File

@@ -5,6 +5,9 @@
import React, { useState, useEffect } from 'react';
import { Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom';
import PosStaffPanelAstryx from './components/spike/PosStaffPanelAstryx'; // PHASE 1 SPIKE — throwaway
import ThemeShowcase from './components/spike/ThemeShowcase'; // PHASE 2 gate — throwaway
import ConsoleUiProbe from './components/spike/ConsoleUiProbe'; // PHASE 3 verification — throwaway
import {
Network,
Truck,
@@ -560,6 +563,14 @@ export default function App() {
if (authRole === null) {
return (
<Routes>
{/* PHASE 1 SPIKE — throwaway, delete with the spike folder. Sits above
the auth gate only so the probe is reachable without a login. */}
<Route path="/spike/astryx" element={<PosStaffPanelAstryx />} />
{/* PHASE 2 gate — the stock Astryx showcase rendered through the Nearle
theme. This is the acceptance test for the palette: every component
on one page, in whichever mode the OS is set to. */}
<Route path="/spike/theme" element={<ThemeShowcase />} />
<Route path="/spike/consoleui" element={<ConsoleUiProbe />} />
<Route path="/login" element={<LoginView onLogin={handleLogin} />} />
<Route path="*" element={<Navigate to="/login" replace />} />
</Routes>

View File

@@ -33,8 +33,13 @@ import {
import { FIESTA_TENANT_ID, str as fstr, num as fnum } from '../services/fiestaApi';
import StoreQRView from './StoreQRView';
import AddressAutocomplete, { type AddressResult } from './AddressAutocomplete';
import { useNotify } from './Toasts';
export default function AdminConsole({ activeTab: propActiveTab, showHeader = true, onBack, tenantId }: { activeTab?: 'tenant' | 'store' | 'rider', showHeader?: boolean, onBack?: () => void, tenantId?: number }) {
// PHASE 6 — replaces alert(). A native alert freezes the whole browser
// until dismissed; a toast does not, and errors stay until read.
const { notifyOk, notifyFail } = useNotify();
const [activeTab, setActiveTab] = useState<'tenant' | 'store' | 'rider'>(propActiveTab || 'tenant');
useEffect(() => {
@@ -171,7 +176,7 @@ export default function AdminConsole({ activeTab: propActiveTab, showHeader = tr
const handleTenantSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!tenantForm.tenantname || !tenantForm.companyname || !tenantForm.primarycontact || !tenantForm.primaryemail || !tenantForm.locationname) {
alert('Kindly fill in all required fields.');
notifyFail('Kindly fill in all required fields.');
return;
}
try {
@@ -197,16 +202,16 @@ export default function AdminConsole({ activeTab: propActiveTab, showHeader = tr
},
});
setTenantSuccess(res);
alert('Tenant Onboarded successfully!');
notifyOk('Tenant onboarded.');
} catch (err: any) {
alert(err.message || 'Failed to onboard tenant.');
notifyFail(err, 'Failed to onboard tenant.');
}
};
const handleStoreSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!storeForm.locationname) {
alert('Location Name is required.');
notifyFail('Location name is required.');
return;
}
try {
@@ -221,16 +226,16 @@ export default function AdminConsole({ activeTab: propActiveTab, showHeader = tr
roleid: 4,
});
setStoreSuccess(res);
alert('Store Location created successfully!');
notifyOk('Store location created.');
} catch (err: any) {
alert(err.message || 'Failed to create store location.');
notifyFail(err, 'Failed to create store location.');
}
};
const handleRiderSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!riderForm.firstname || !riderForm.email || !riderForm.contactno || !riderForm.locationid) {
alert('Please fill out the Rider personal details and assign a store.');
notifyFail('Fill out the rider details and assign a store.');
return;
}
try {
@@ -282,9 +287,9 @@ VALUES (${newUserId}, 1, 'Active', NOW());
userid: newUserId,
});
alert('Rider User created in app_users! SQL Script generated below.');
notifyOk('Rider user created. SQL script generated below.');
} catch (err: any) {
alert(err.message || 'Failed to create rider user.');
notifyFail(err, 'Failed to create rider user.');
}
};
@@ -902,7 +907,7 @@ VALUES (${newUserId}, 1, 'Active', NOW());
>
<option value={0}>Select Hub location...</option>
{locations.map((loc) => (
<option key={loc.locationid} value={Number(loc.locationid)}>{fstr(loc.locationname)}</option>
<option key={fstr(loc.locationid)} value={Number(loc.locationid)}>{fstr(loc.locationname)}</option>
))}
</select>
</div>
@@ -1261,7 +1266,7 @@ VALUES (${newUserId}, 1, 'Active', NOW());
{tenantsQ.isLoading ? (
<option>Loading tenants...</option>
) : (tenantsQ.data || []).map((t) => (
<option key={t.tenantid} value={Number(t.tenantid)}>{fstr(t.tenantname)} (#{fstr(t.tenantid)})</option>
<option key={fstr(t.tenantid)} value={Number(t.tenantid)}>{fstr(t.tenantname)} (#{fstr(t.tenantid)})</option>
))}
</select>
</div>
@@ -1574,7 +1579,7 @@ VALUES (${newUserId}, 1, 'Active', NOW());
>
<option value={0}>Select Hub location...</option>
{locations.map((loc) => (
<option key={loc.locationid} value={Number(loc.locationid)}>{fstr(loc.locationname)}</option>
<option key={fstr(loc.locationid)} value={Number(loc.locationid)}>{fstr(loc.locationname)}</option>
))}
</select>
</div>

View File

@@ -2,6 +2,7 @@ import React from 'react';
import { ShoppingCart, X, Package, Trash2, Plus, ArrowRight, Layers } from 'lucide-react';
import { useCompare } from '../contexts/CompareContext';
import { useStoreCatalogue } from '../services/storeCatalogue';
import { useNotify } from './Toasts';
interface BulkCartDrawerProps {
isOpen: boolean;
@@ -12,13 +13,14 @@ interface BulkCartDrawerProps {
export default function BulkCartDrawer({ isOpen, onClose, onRequestSamples }: BulkCartDrawerProps) {
const { selectedProducts, removeProduct, clearSelection, setIsComparing } = useCompare();
const storeCat = useStoreCatalogue();
const { notifyOk } = useNotify();
if (!isOpen) return null;
const handleBulkAdd = () => {
let addedCount = 0;
selectedProducts.forEach(prod => {
if (prod.verified && !storeCat.has(prod.id)) {
if (prod.verified && !storeCat.has(String(prod.id))) {
storeCat.add({
productid: String(prod.id),
name: prod.name,
@@ -43,7 +45,10 @@ export default function BulkCartDrawer({ isOpen, onClose, onRequestSamples }: Bu
addedCount++;
}
});
alert(addedCount > 0 ? `Successfully added ${addedCount} product(s) to the store catalogue!` : 'Selected products are already in the catalogue or not verified.');
// PHASE 6 — "nothing was added" is not an error, it is a result, so it goes
// through notifyOk rather than being dressed up as a failure.
if (addedCount > 0) notifyOk(`Added ${addedCount} product${addedCount === 1 ? '' : 's'} to the store catalogue.`);
else notifyOk('Nothing to add — those products are already in the catalogue, or not verified.');
if (addedCount > 0) onClose();
};
@@ -55,7 +60,8 @@ export default function BulkCartDrawer({ isOpen, onClose, onRequestSamples }: Bu
removedCount++;
}
});
alert(removedCount > 0 ? `Successfully removed ${removedCount} product(s) from the store catalogue!` : 'None of the selected products were in the catalogue.');
if (removedCount > 0) notifyOk(`Removed ${removedCount} product${removedCount === 1 ? '' : 's'} from the store catalogue.`);
else notifyOk('Nothing to remove — none of those products were in the catalogue.');
if (removedCount > 0) onClose();
};

View File

@@ -12,7 +12,13 @@ import { useStoreCatalogue, usePriceEverywhere } from '../services/storeCatalogu
import { useFiestaTenantLocations, useFiestaProductLocations } from '../services/fiestaQueries';
import { num as fnum } from '../services/fiestaApi';
import ImportProductModal from './ImportProductModal';
import { useNotify } from './Toasts';
import { CardCarousel } from './CatalogueGallery';
// PHASE 6 — the Global Catalogue's own controls. This component is rendered by
// BOTH the admin (via InventoryView) and the super admin, so a change here
// lands on two logins at once.
import { TextInput } from '@astryxdesign/core/TextInput';
import { CheckboxInput } from '@astryxdesign/core/CheckboxInput';
interface CatalogueBrowserProps {
tenantid: number;
@@ -87,6 +93,9 @@ export default function CatalogueBrowser({ tenantid, locationid, onClose }: Cata
[tenantCategories],
);
// PHASE 6 — replaces alert(), which blocked the whole browser until dismissed.
const { notifyOk, notifyFail } = useNotify();
const handleImportSubmit = (item: ImportCatalogueProductRequest) => {
importProductMutation.mutate([item], {
onSuccess: () => {
@@ -101,10 +110,14 @@ export default function CatalogueBrowser({ tenantid, locationid, onClose }: Cata
//
// The product now lands in the Admin Catalogue alone. Pricing and
// publishing happen there, in that order.
//
// PHASE 6 — the toast says where it went. Since the import stopped
// publishing, a silent success looked identical to nothing happening:
// the product does not appear in any store, which is correct but
// indistinguishable from a failure.
notifyOk('Imported to your Admin Catalogue. Set a price there to publish it.');
},
onError: (err: any) => {
alert(err.message || 'Failed to import product.');
}
onError: (err: any) => notifyFail(err, 'Failed to import product.'),
});
};
@@ -131,21 +144,14 @@ export default function CatalogueBrowser({ tenantid, locationid, onClose }: Cata
) : (
<div className="space-y-1.5">
{brandsData.map((b) => (
<label key={b.brand} className="flex items-center gap-3 p-2 rounded-none hover:bg-slate-50 cursor-pointer group transition-colors">
<div className="relative flex items-center justify-center shrink-0">
<input
type="checkbox"
checked={brand === b.brand}
onChange={() => setBrand(brand === b.brand ? undefined : b.brand)}
className="peer appearance-none w-5 h-5 border-2 border-slate-300 rounded-none checked:border-[#662582] checked:bg-[#662582] transition-colors cursor-pointer"
/>
<Check size={12} className="absolute text-white opacity-0 peer-checked:opacity-100 pointer-events-none" strokeWidth={3} />
</div>
<span className={`text-xs font-semibold select-none transition-colors truncate ${brand === b.brand ? 'text-[#662582]' : 'text-slate-600 group-hover:text-slate-900'}`}>
{b.brand.replace('brand_', '').toUpperCase()}
</span>
<span className="ml-auto text-[10px] text-slate-400 font-bold">{b.product_count}</span>
</label>
<div key={b.brand} className="flex items-center gap-2 px-1 py-0.5">
<CheckboxInput
label={b.brand.replace("brand_", "").toUpperCase()}
value={brand === b.brand}
onChange={() => setBrand(brand === b.brand ? undefined : b.brand)}
/>
<span className="ml-auto text-[10px] font-bold opacity-60">{b.product_count}</span>
</div>
))}
</div>
)}
@@ -198,23 +204,16 @@ export default function CatalogueBrowser({ tenantid, locationid, onClose }: Cata
</div>
<div className="flex flex-nowrap items-center gap-2 shrink-0">
<div className="relative bg-slate-50 border border-slate-200 rounded-none flex items-center h-[32px] px-2.5 shadow-sm min-w-[200px] hover:border-purple-300 transition-colors group focus-within:border-purple-500 focus-within:bg-white focus-within:ring-2 focus-within:ring-purple-100">
<Search size={14} className="text-slate-400 group-focus-within:text-purple-600 transition-colors shrink-0" />
<input
type="text"
placeholder="Search catalogue..."
<div className="w-64">
<TextInput
label="Search catalogue"
isLabelHidden
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
className="bg-transparent border-none outline-none text-xs font-semibold text-slate-700 ml-2 w-full placeholder:text-slate-400 placeholder:font-medium"
onChange={(v) => setKeyword(v)}
placeholder="Search catalogue…"
startIcon={<Search size={14} />}
hasClear
/>
{keyword && (
<button
onClick={() => setKeyword('')}
className="ml-1 text-slate-400 hover:text-slate-700 bg-slate-200 hover:bg-slate-300 rounded-full p-0.5 transition-colors"
>
<X size={10} />
</button>
)}
</div>
</div>
</div>

View File

@@ -1,5 +1,26 @@
import React from 'react';
import { AlertTriangle, X } from 'lucide-react';
import { AlertTriangle } from 'lucide-react';
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
import { Button } from '@astryxdesign/core/Button';
/**
* PHASE 5 — rebuilt on Astryx Dialog.
*
* The props are untouched, so both call sites were left alone. What changed is
* everything underneath: this was a hand-rolled `fixed inset-0` overlay, and it
* is now a real `<dialog>`.
*
* What that buys, none of which the original had:
* • Escape closes it (verified — see the `purpose` note below, the first
* choice of 'required' silently disabled this)
* • focus is trapped inside while open, and returns to the opener on close
* • the rest of the page is inert to screen readers
* • width comes from the Dialog, so it can no longer be squeezed by the
* `max-w-*` collision that once rendered these as ~100px strips
*
* `isLoading` still guards dismissal: onOpenChange is ignored mid-flight so a
* stray Escape cannot close the box while a mutation is in the air.
*/
interface ConfirmModalProps {
isOpen: boolean;
@@ -24,67 +45,42 @@ export function ConfirmModal({
isDestructive = false,
isLoading = false,
}: ConfirmModalProps) {
if (!isOpen) return null;
return (
<div
className="fixed inset-0 bg-[#0f172a]/40 backdrop-blur-sm z-[200] flex items-center justify-center p-4 animate-in fade-in duration-200"
onClick={(e) => { if (e.target === e.currentTarget && !isLoading) onCancel(); }}
<Dialog
isOpen={isOpen}
onOpenChange={(open) => {
// Ignore every close request while a mutation is running — the original
// did this by disabling the buttons and swallowing backdrop clicks.
if (!open && !isLoading) onCancel();
}}
width={400}
// 'info', not 'required'. Verified in the browser: 'required' blocks
// Escape and backdrop dismissal entirely, which would have been a
// regression — the original closed on a backdrop click. Cancelling a
// confirmation is the safe direction, so making it harder protects
// nothing. 'info' keeps backdrop-dismiss and adds Escape.
purpose="info"
>
<div
className="bg-white border border-[#e2e8f0] rounded-xl w-[90vw] sm:w-[400px] max-h-[90vh] flex flex-col shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200"
onClick={(e) => e.stopPropagation()}
>
<div className="p-4 sm:p-5 border-b border-[#e2e8f0] bg-slate-50/50 flex justify-between items-center shrink-0">
<div className="flex items-center gap-2">
{isDestructive && <AlertTriangle size={18} className="text-red-500" />}
<h2 className="text-base font-bold text-slate-800 uppercase tracking-wide">{title}</h2>
</div>
<button
onClick={onCancel}
disabled={isLoading}
className="text-slate-400 hover:text-slate-600 hover:bg-slate-100 p-1.5 rounded-lg transition-colors disabled:opacity-50"
>
<X size={18} />
</button>
</div>
<DialogHeader
title={title}
startContent={isDestructive ? <AlertTriangle size={18} className="text-red-500" /> : undefined}
onOpenChange={(open) => { if (!open && !isLoading) onCancel(); }}
/>
<div className="p-5 overflow-y-auto">
<div className="text-sm text-slate-600 leading-relaxed">
{message}
</div>
</div>
<div className="text-sm leading-relaxed py-2">{message}</div>
<div className="p-4 bg-slate-50/80 border-t border-slate-100 flex justify-end gap-3 shrink-0">
<button
type="button"
onClick={onCancel}
disabled={isLoading}
className="px-4 py-2 text-sm font-semibold text-slate-600 hover:text-slate-800 hover:bg-slate-200 bg-slate-100 rounded-lg transition-colors disabled:opacity-50"
>
{cancelText}
</button>
<button
type="button"
onClick={onConfirm}
disabled={isLoading}
className={`px-4 py-2 text-sm font-semibold rounded-lg transition-colors flex items-center justify-center min-w-[90px] disabled:opacity-50 ${
isDestructive
? 'bg-red-600 hover:bg-red-700 text-white shadow-sm shadow-red-200'
: 'bg-purple-600 hover:bg-purple-700 text-white shadow-sm shadow-purple-200'
}`}
>
{isLoading ? (
<svg className="animate-spin h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
) : (
confirmText
)}
</button>
</div>
<div className="flex justify-end gap-3 pt-4">
<Button label={cancelText} variant="secondary" isDisabled={isLoading} onClick={onCancel} />
<Button
label={confirmText}
variant={isDestructive ? 'destructive' : 'primary'}
isDisabled={isLoading}
isLoading={isLoading}
onClick={onConfirm}
/>
</div>
</div>
</Dialog>
);
}
export default ConfirmModal;

View File

@@ -58,6 +58,7 @@ import AwaitingApi from './AwaitingApi';
import { SlideDrawer, Skeleton, TH_STYLE, SURFACE_ALT, TEXT, TEXT_2, TEXT_3, BORDER, BRAND, tint, edge, StatusChip } from './consoleUi';
import FMCGHoverOverlay from './FMCGHoverOverlay';
import CatalogueDetailPanel from './CatalogueDetailPanel';
import { useNotify } from './Toasts';
import CatalogueBrowser from './CatalogueBrowser';
import OfflineSalesUpload from './OfflineSalesUpload';
import { useCompare } from '../contexts/CompareContext';
@@ -178,6 +179,9 @@ export default function InventoryView({
}, [storeCat.items]);
const { priceEverywhere, isPending: isPricing } = usePriceEverywhere(tenantId);
// PHASE 6 — replaces alert(). A native alert blocks the whole browser and
// has to be dismissed before anything else can happen; a toast does not.
const { notifyOk, notifyFail } = useNotify();
const publishMut = usePublishProduct();
const unpublishMut = useUnpublishProduct();
const [importPrice, setImportPrice] = useState<string>('');
@@ -734,9 +738,13 @@ export default function InventoryView({
taxpercent: Number(cardImportTax) || 0,
},
{
onSuccess: () => setAddingPriceProdId(null),
onError: (err: any) =>
alert(err?.message || 'Could not publish this product.'),
onSuccess: () => {
setAddingPriceProdId(null);
// Publishing used to succeed in silence — the only
// sign it worked was a row changing somewhere below.
notifyOk(`${prod.name} is live in every outlet.`);
},
onError: (err: any) => notifyFail(err, 'Could not publish this product.'),
},
);
}}
@@ -1091,7 +1099,10 @@ export default function InventoryView({
price: Number(importPrice),
taxpercent: Number(drawerTax) || 0,
},
{ onError: (err: any) => alert(err?.message || 'Could not publish this product.') },
{
onSuccess: () => notifyOk(`${selectedAdminProduct.name} is live in every outlet.`),
onError: (err: any) => notifyFail(err, 'Could not publish this product.'),
},
);
}}
className="w-full flex items-center justify-center gap-2 py-3 rounded-xl text-xs font-extrabold transition-all bg-[#662582] hover:bg-[#531e6a] text-white shadow-md disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"

View File

@@ -0,0 +1,98 @@
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
/**
* PHASE 4 — the one left rail.
*
* `SuperAdminSidebar` and `UserStoreSidebar` were 95% the same file. A diff of
* the two showed exactly three differences: the route prefix, the item corner
* radius, and the fact that the user rail closes itself after a tap on mobile
* while the super-admin rail does not. Everything else — the brand-purple rail,
* the collapse-to-icons behaviour, the backdrop, the active states — was
* duplicated line for line, which is why a fix to one never reached the other.
*
* Both are now thin wrappers over this. Each keeps its own props and its own
* appearance, so no page changed.
*
* NOT built on Astryx's `SideNav`. That would replace the main navigation of
* every role at once, and those screens sit behind a login I cannot reach to
* check the result. Deduplicating is safe and verifiable today; restyling the
* navigation is not. The swap belongs in a step where someone can look at it.
*/
import React from 'react';
import { type LucideIcon } from 'lucide-react';
import { NavLink } from 'react-router-dom';
export interface NavRailItem {
id: string;
label: string;
icon: LucideIcon;
}
export interface NavRailProps {
items: NavRailItem[];
/** Route prefix each item id is appended to, e.g. `/superadmin`. */
basePath: string;
/** Collapsed → icon-only rail; expanded → labels visible. */
isOpen: boolean;
onClose: () => void;
/**
* Item corner radius. The two rails disagreed, so it stays a prop rather than
* silently restyling one of them.
*/
itemRadius?: 'none' | 'lg';
}
export default function NavRail({ items, basePath, isOpen, onClose, itemRadius = 'none' }: NavRailProps) {
return (
<>
{/* Mobile Overlay */}
{isOpen && (
<div
className="md:hidden fixed inset-0 bg-black/50 z-40 backdrop-blur-sm"
onClick={onClose}
/>
)}
<aside
className={`fixed left-0 top-0 h-screen bg-[#662582] border-r border-[#662582] text-white flex flex-col py-xl pt-16 z-40 transition-all duration-300 ${
isOpen ? 'translate-x-0 w-64' : '-translate-x-full md:translate-x-0 md:w-16'
}`}
>
<nav className="flex-1 space-y-1 overflow-y-auto px-xs">
{items.map((item) => {
const IconComponent = item.icon;
return (
<NavLink
key={item.id}
to={`${basePath}/${item.id}`}
title={item.label}
// Applied to both rails now. The super-admin one lacked it, so
// on a phone its menu stayed open over the page you had just
// navigated to — a bug by omission rather than by decision.
onClick={() => { if (window.innerWidth < 768) onClose(); }}
className={({ isActive }) => `w-full flex items-center py-3 ${itemRadius === 'lg' ? 'rounded-lg' : 'rounded-none'} text-left transition-all duration-200 cursor-pointer ${
isOpen ? 'gap-md px-md border-l-4' : 'justify-center px-0'
} ${
isActive
? 'bg-black/20 text-white font-semibold' + (isOpen ? ' border-white' : '')
: 'text-purple-200 hover:bg-white/10 hover:text-white' + (isOpen ? ' border-transparent' : '')
}`}
>
{({ isActive }) => (
<>
<IconComponent size={18} className={isActive ? 'text-white' : 'text-purple-300'} />
{isOpen && <span className="font-sans text-sm font-medium">{item.label}</span>}
</>
)}
</NavLink>
);
})}
</nav>
</aside>
</>
);
}

View File

@@ -30,6 +30,7 @@ import {
import { FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID, num as fnum, str as fstr } from '../services/fiestaApi';
import { stockRowToProduct, stockRowToInventory } from '../services/fiestaMappers';
import AwaitingApi from './AwaitingApi';
import { useNotify } from './Toasts';
interface OperationsViewProps {
searchQuery: string;
@@ -37,6 +38,10 @@ interface OperationsViewProps {
}
export default function OperationsView({ searchQuery, isCoimbatoreView }: OperationsViewProps) {
// PHASE 6 — replaces alert(). A native alert freezes the whole browser
// until dismissed; a toast does not, and errors stay until read.
const { notifyOk, notifyFail } = useNotify();
// Sub-tabs state
const [activeSubTab, setActiveSubTab] = useState<'inventory' | 'catalogue' | 'import'>('inventory');
@@ -111,7 +116,7 @@ export default function OperationsView({ searchQuery, isCoimbatoreView }: Operat
const handleAddSku = (e: React.FormEvent) => {
e.preventDefault();
if (!newSku.sku || !newSku.name || !newSku.warehouse) {
alert('Please fill out all metadata fields before committing.');
notifyFail('Fill out all metadata fields before committing.');
return;
}
@@ -143,7 +148,7 @@ export default function OperationsView({ searchQuery, isCoimbatoreView }: Operat
const handleExecuteTransfer = (e: React.FormEvent) => {
e.preventDefault();
if (!transferData.sku || !transferData.origin || !transferData.destination) {
alert('Kindly configure transfer coordinates and SKU.');
notifyFail('Configure the transfer coordinates and SKU first.');
return;
}
@@ -162,7 +167,7 @@ export default function OperationsView({ searchQuery, isCoimbatoreView }: Operat
})
);
alert(`Transfer of ${transferData.quantity} units committed successfully. Tracking ID: TRF-${Math.floor(Math.random() * 900000 + 100000)}`);
notifyOk(`Transfer of ${transferData.quantity} units committed successfully. Tracking ID: TRF-${Math.floor(Math.random() * 900000 + 100000)}`);
setShowTransferModal(false);
};
@@ -414,7 +419,7 @@ export default function OperationsView({ searchQuery, isCoimbatoreView }: Operat
onClick={() => {
const amount = prompt('Enter return item SKU code:');
if (amount) {
alert(`Returns logged successfully for target SKU code ${amount}. Waiting for physical hub clearance inspection.`);
notifyOk(`Returns logged successfully for target SKU code ${amount}. Waiting for physical hub clearance inspection.`);
}
}}
className="bg-[#f8fafc] border border-[#e2e8f0] p-sm rounded-lg flex flex-col items-center gap-xs text-zinc-700 hover:bg-[#faf5ff] hover:text-[#662582] hover:border-[#662582] transition-all cursor-pointer group"
@@ -425,7 +430,7 @@ export default function OperationsView({ searchQuery, isCoimbatoreView }: Operat
<button
onClick={() => {
alert('Generating automated physical audit compliance report draft sheets... Download started background.');
notifyOk('Generating the audit compliance report. The download will start shortly.');
}}
className="bg-[#f8fafc] border border-[#e2e8f0] p-sm rounded-lg flex flex-col items-center gap-xs text-zinc-700 hover:bg-[#faf5ff] hover:text-[#662582] hover:border-[#662582] transition-all cursor-pointer group"
>
@@ -473,7 +478,7 @@ export default function OperationsView({ searchQuery, isCoimbatoreView }: Operat
verified: false
}
]);
alert(`${title} added successfully to unreleased draft portfolio.`);
notifyOk(`${title} added successfully to unreleased draft portfolio.`);
}
}}
className="bg-[#662582] hover:bg-purple-800 active:bg-purple-900 text-white font-sans text-xs font-semibold px-4 py-2 rounded-lg cursor-pointer transition-colors shadow-sm"
@@ -549,7 +554,7 @@ export default function OperationsView({ searchQuery, isCoimbatoreView }: Operat
onClick={() => {
const fileRef = prompt('Enter CSV filename representation path:');
if (fileRef) {
alert('Uploaded successfully. Metadata schema verification committed.');
notifyOk('Uploaded. Metadata schema verified.');
}
}}
className="bg-white border-2 border-dashed border-zinc-300 rounded-xl p-xl flex flex-col items-center justify-center text-center cursor-pointer hover:bg-[#faf5ff]/30 hover:border-[#662582] transition-all duration-200"
@@ -573,7 +578,7 @@ export default function OperationsView({ searchQuery, isCoimbatoreView }: Operat
<button
onClick={(e) => {
e.stopPropagation();
alert('Template documentation initiated.');
notifyOk('Template documentation initiated.');
}}
className="bg-white border border-[#e2e8f0] text-zinc-600 text-xs px-4 py-2 rounded-lg cursor-pointer hover:bg-zinc-50 transition-colors"
>

View File

@@ -55,7 +55,10 @@ export default function OrderDetailsModal({ order, onClose }: OrderDetailsModalP
const deliveryFee = fnum(order.deliveryfee) || fnum(order.deliverycharge) || 0;
const address = fstr(order.shippingaddress) || fstr(order.address) || 'No address provided';
const { data: details, isLoading } = useFiestaOrderDetails(order.orderheaderid);
// Cast rather than coerce: the hook itself does `orderheaderid as number |
// string` internally, and running it through fstr/fnum here would change the
// query key for every order that arrives as a number.
const { data: details, isLoading } = useFiestaOrderDetails(order.orderheaderid as number | string);
return createPortal(
<div className="odm-overlay">

View File

@@ -69,6 +69,20 @@ import {
type PosTerminal,
} from '../services/posApi';
import PosStaffPanel from './PosStaffPanel';
// PHASE 6 — POS console tabs on Astryx.
import { TextInput } from '@astryxdesign/core/TextInput';
import { Selector } from '@astryxdesign/core/Selector';
import { Button } from '@astryxdesign/core/Button';
import { Card as AxCard } from '@astryxdesign/core/Card';
import { IconButton } from '@astryxdesign/core/IconButton';
import { Link } from '@astryxdesign/core/Link';
import {
Table as AxTable,
TableHeader,
TableHeaderCell,
TableRow,
TableBody,
} from '@astryxdesign/core/Table';
import {
type PosOutletHealth,
usePosCatalogue,
@@ -320,18 +334,20 @@ export default function PosConsoleView({
</Pill>
</React.Fragment>
))}
<button
{/* IconButton still takes `label` (inherited from ButtonProps) —
it becomes the accessible name rather than visible text, which
the old `title`-only button never had. */}
<IconButton
label="Refresh"
variant="secondary"
size="sm"
icon={<RefreshCcw size={13} className={summaryQ.isFetching ? 'animate-spin' : ''} />}
onClick={() => {
healthQ.refetch();
summaryQ.refetch();
salesQ.refetch();
}}
title="Refresh"
className="inline-flex items-center justify-center rounded-full cursor-pointer transition-colors"
style={{ width: 30, height: 30, background: tint(BRAND), border: `1.5px solid ${edge(BRAND)}`, color: BRAND }}
>
<RefreshCcw size={13} className={summaryQ.isFetching ? 'animate-spin' : ''} />
</button>
/>
</div>
}
/>
@@ -380,9 +396,13 @@ export default function PosConsoleView({
<div className="text-[13px]" style={{ color: TEXT }}>
<strong>{pendingTotal}</strong> bill{pendingTotal === 1 ? '' : 's'} still sitting on{' '}
{strandedTills} till{strandedTills === 1 ? '' : 's'} and not yet synced.
<button onClick={() => setTab('terminals')} className="ml-2 underline font-semibold cursor-pointer" style={{ color: BRAND }}>
See which
</button>
{/* Reads as a link mid-sentence but performs an in-page action, so
it stays a <button> underneath — Link renders as one when given
no href, which is the correct element for something that changes
a tab rather than navigating. */}
<span className="ml-2">
<Link onClick={() => setTab('terminals')}>See which</Link>
</span>
</div>
</div>
)}
@@ -983,54 +1003,60 @@ function BillsTab(props: {
</div>
)}
<div className="flex items-center gap-2 flex-wrap">
<div className="flex items-center gap-1.5 rounded-full px-3 py-1.5" style={{ background: SURFACE_ALT, border: `1px solid ${BORDER}` }}>
<Barcode size={13} style={{ color: TEXT_3 }} />
<input
{/* PHASE 6 — the bills filter bar.
`items-end` rather than `items-center`: Astryx fields carry a label
above the control, so centring would stagger them against the
plain text on the right. */}
<div className="flex items-end gap-2 flex-wrap">
<div className="w-64">
<TextInput
label="Find a bill"
value={reference}
onChange={(e) => setReference(e.target.value)}
onChange={(v) => setReference(v)}
onKeyDown={(e) => e.key === 'Enter' && onLookup()}
placeholder="Invoice, order UUID or bill id…"
className="bg-transparent outline-none text-[12.5px] w-56"
style={{ color: TEXT }}
startIcon={<Barcode size={13} />}
// Replaces the separate magnifier button beside the field. The
// clear affordance now belongs to the input, and Enter still
// runs the lookup exactly as before.
hasClear
/>
<button onClick={onLookup} className="cursor-pointer" title="Look up bill" style={{ color: BRAND }}>
<Search size={13} />
</button>
</div>
<select
value={terminalFilter}
onChange={(e) => setTerminalFilter(e.target.value)}
className="rounded-full px-3 py-1.5 text-[12.5px] font-semibold cursor-pointer outline-none"
style={{ background: SURFACE_ALT, border: `1px solid ${BORDER}`, color: TEXT }}
>
<option value="">All terminals</option>
{terminalOptions.filter(Boolean).map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
<div className="w-44">
<Selector
label="Terminal"
value={terminalFilter}
onChange={(v) => setTerminalFilter(String(v ?? ''))}
placeholder="All terminals"
options={[
{ value: '', label: 'All terminals' },
...terminalOptions.filter(Boolean).map((t) => ({ value: t, label: t })),
]}
/>
</div>
<select
value={modeFilter}
onChange={(e) => setModeFilter(e.target.value)}
className="rounded-full px-3 py-1.5 text-[12.5px] font-semibold cursor-pointer outline-none"
style={{ background: SURFACE_ALT, border: `1px solid ${BORDER}`, color: TEXT }}
>
<option value="">All tenders</option>
{['cash', 'card', 'upi', 'wallet'].map((m) => (
<option key={m} value={m}>{m}</option>
))}
</select>
<div className="w-40">
<Selector
label="Tender"
value={modeFilter}
onChange={(v) => setModeFilter(String(v ?? ''))}
placeholder="All tenders"
options={[
{ value: '', label: 'All tenders' },
...['cash', 'card', 'upi', 'wallet'].map((m) => ({ value: m, label: m })),
]}
/>
</div>
<div className="flex items-center gap-1.5 rounded-full px-3 py-1.5" style={{ background: SURFACE_ALT, border: `1px solid ${BORDER}` }}>
<Users size={13} style={{ color: TEXT_3 }} />
<input
<div className="w-48">
<TextInput
label="Cashier"
value={cashierFilter}
onChange={(e) => setCashierFilter(e.target.value)}
placeholder="Cashier (exact name)"
className="bg-transparent outline-none text-[12.5px] w-40"
style={{ color: TEXT }}
onChange={(v) => setCashierFilter(v)}
placeholder="Exact name"
startIcon={<Users size={13} />}
hasClear
/>
</div>
@@ -1071,22 +1097,23 @@ function BillsTab(props: {
Page {pageno + 1} of {pages}
</span>
<div className="flex items-center gap-2">
<button
disabled={pageno === 0}
{/* `secondary` rather than the tinted-brand pills: these sit
under a table as navigation, not as the primary action on
the page, and Astryx's disabled state is built in. */}
<Button
label="Previous"
variant="secondary"
size="sm"
isDisabled={pageno === 0}
onClick={() => setPageno(pageno - 1)}
className="rounded-full px-3 py-1 text-[12px] font-bold cursor-pointer disabled:opacity-40 disabled:cursor-default"
style={{ background: tint(BRAND), color: BRAND, border: `1px solid ${edge(BRAND)}` }}
>
Previous
</button>
<button
disabled={pageno + 1 >= pages}
/>
<Button
label="Next"
variant="secondary"
size="sm"
isDisabled={pageno + 1 >= pages}
onClick={() => setPageno(pageno + 1)}
className="rounded-full px-3 py-1 text-[12px] font-bold cursor-pointer disabled:opacity-40 disabled:cursor-default"
style={{ background: tint(BRAND), color: BRAND, border: `1px solid ${edge(BRAND)}` }}
>
Next
</button>
/>
</div>
</div>
) : undefined
@@ -1187,17 +1214,20 @@ function BillDetail({ bill, loading, reference }: { bill: PosBill | null; loadin
{/* The drawer clips horizontally, so the line table scrolls inside
its own box rather than being cut off at the panel edge. */}
<div className="mt-2 rounded-xl border overflow-x-auto" style={{ borderColor: BORDER }}>
<table className="w-full text-[12px]" style={{ minWidth: 420 }}>
<thead>
<tr style={{ background: SURFACE_ALT, color: TEXT_2 }}>
<th className="px-3 py-2 text-left font-extrabold">Item</th>
<th className="px-3 py-2 text-right font-extrabold">Qty</th>
<th className="px-3 py-2 text-right font-extrabold">Rate</th>
<th className="px-3 py-2 text-right font-extrabold">GST</th>
<th className="px-3 py-2 text-right font-extrabold">Total</th>
</tr>
</thead>
<tbody className="divide-y" style={{ borderColor: DIVIDER }}>
{/* The bill's line items. Header cells move to Astryx; the rows
below stay as the caller's own <tr>, so the money formatting
and the barcode sub-line are untouched. */}
<AxTable style={{ minWidth: 420 }}>
<TableHeader>
<TableRow>
<TableHeaderCell><span className="block">Item</span></TableHeaderCell>
<TableHeaderCell><span className="block text-right">Qty</span></TableHeaderCell>
<TableHeaderCell><span className="block text-right">Rate</span></TableHeaderCell>
<TableHeaderCell><span className="block text-right">GST</span></TableHeaderCell>
<TableHeaderCell><span className="block text-right">Total</span></TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{items.map((it) => (
<tr key={it.posorderitemid}>
<td className="px-3 py-2">
@@ -1219,8 +1249,8 @@ function BillDetail({ bill, loading, reference }: { bill: PosBill | null; loadin
<td className="px-3 py-2 text-right font-bold" style={{ color: TEXT }}>{inr(it.linetotal)}</td>
</tr>
))}
</tbody>
</table>
</TableBody>
</AxTable>
</div>
</div>
) : (
@@ -1460,25 +1490,27 @@ function PosTable({
children: React.ReactNode;
}) {
return (
<div className="bg-white rounded-2xl border overflow-hidden shadow-sm w-full min-w-0" style={{ borderColor: BORDER }}>
// PHASE 6 — one conversion covers the Terminals, Bills and Catalogue tabs,
// since all three render through this. The hardcoded `bg-white` is gone
// with it: Card takes its surface from the theme, so this is one of the
// places that no longer blocks dark mode.
<AxCard padding={0} className="overflow-hidden w-full min-w-0">
<div className="overflow-x-auto">
<table className="w-full text-sm whitespace-nowrap" style={{ minWidth }}>
<thead style={TH_STYLE}>
<tr>
<AxTable style={{ minWidth }}>
<TableHeader>
<TableRow>
{columns.map((c, i) => (
<th key={i} className={`px-4 py-3 ${c.align === 'right' ? 'text-right' : 'text-left'}`}>
{c.label}
</th>
<TableHeaderCell key={i}>
<span className={c.align === 'right' ? 'block text-right' : 'block'}>{c.label}</span>
</TableHeaderCell>
))}
</tr>
</thead>
<tbody className="divide-y" style={{ borderColor: DIVIDER }}>
{children}
</tbody>
</table>
</TableRow>
</TableHeader>
<TableBody>{children}</TableBody>
</AxTable>
</div>
{footer}
</div>
</AxCard>
);
}

View File

@@ -19,6 +19,10 @@
import React from 'react';
import { AlertTriangle } from 'lucide-react';
import { Card as AxCard } from '@astryxdesign/core/Card';
import { Button } from '@astryxdesign/core/Button';
import { Heading } from '@astryxdesign/core/Heading';
import { Text } from '@astryxdesign/core/Text';
interface Props {
children: React.ReactNode;
@@ -29,15 +33,11 @@ interface State {
}
export default class PosErrorBoundary extends React.Component<Props, State> {
// This project has no `@types/react` installed, so `React.Component`'s
// inherited members are not typed and `this.props` / `this.setState` do not
// resolve. (It is the same gap that makes `key` unassignable on a custom
// component, which is why the codebase wraps keyed elements in fragments.)
// Re-declared here rather than cast at each use — `declare` emits nothing and
// keeps the bodies below honest. Delete once the types are added.
declare props: Props;
declare setState: (state: State) => void;
// The `declare props` / `declare setState` workaround that used to sit here
// is gone. It existed only because the project had no `@types/react`, so
// React.Component's inherited members did not resolve. Phase 0 installed the
// types, so the real ones now apply and the shim was actively misleading —
// it narrowed setState to a shape React does not actually guarantee.
state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
@@ -54,28 +54,30 @@ export default class PosErrorBoundary extends React.Component<Props, State> {
if (!this.state.error) return this.props.children;
return (
// PHASE 6 — Card + Button. The hardcoded `bg-white` and `#662582` are
// gone, so the fallback follows the theme like everything else. That
// matters more here than elsewhere: this is the screen someone sees when
// things have already gone wrong, and a white slab on a dark page would
// read as a second failure.
<div className="flex items-center justify-center py-16">
<div className="bg-white border border-slate-200/70 p-10 text-center max-w-md shadow-[0_10px_40px_rgba(0,0,0,0.08)] rounded-2xl">
<div className="mx-auto h-16 w-16 rounded-2xl bg-rose-50 text-rose-600 ring-1 ring-rose-100 flex items-center justify-center mb-6">
<AlertTriangle size={30} />
<AxCard maxWidth={448}>
<div className="p-10 text-center">
<div className="mx-auto h-16 w-16 rounded-2xl bg-rose-50 text-rose-600 ring-1 ring-rose-100 flex items-center justify-center mb-6">
<AlertTriangle size={30} />
</div>
<Heading level={2}>The POS screen hit a problem</Heading>
<div className="mt-3 mb-2">
<Text>
Counter sales are unaffected — bills keep syncing from the tills whatever this
page does. Only the display failed.
</Text>
</div>
<p className="text-[12px] font-mono break-words mb-6 opacity-60">
{this.state.error.message}
</p>
<Button label="Try again" onClick={() => this.setState({ error: null })} />
</div>
<h1 className="text-2xl font-bold text-slate-900 tracking-tight mb-3">
The POS screen hit a problem
</h1>
<p className="text-[15px] text-slate-500 leading-relaxed mb-2">
Counter sales are unaffected — bills keep syncing from the tills whatever this page
does. Only the display failed.
</p>
<p className="text-[12px] text-slate-400 font-mono break-words mb-6">
{this.state.error.message}
</p>
<button
onClick={() => this.setState({ error: null })}
className="px-5 py-2.5 bg-[#662582] hover:bg-purple-800 text-white text-sm font-bold rounded-xl cursor-pointer transition-colors shadow-sm"
>
Try again
</button>
</div>
</AxCard>
</div>
);
}

View File

@@ -20,6 +20,12 @@
*/
import React, { useMemo, useState } from 'react';
// PHASE 6 — this page's form is the first converted to Astryx.
import { TextInput } from '@astryxdesign/core/TextInput';
import { Selector } from '@astryxdesign/core/Selector';
import { Button } from '@astryxdesign/core/Button';
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
import { TimeInput } from '@astryxdesign/core/TimeInput';
import {
AlertTriangle,
Check,
@@ -106,6 +112,19 @@ export default function PosStaffPanel({
const [phone, setPhone] = useState('');
const [shiftId, setShiftId] = useState(0);
const [formError, setFormError] = useState('');
/**
* PHASE 6 — per-field errors, replacing the single `formError` string for the
* create form.
*
* The old validation returned on the first failure, so an empty form told you
* about the name, then about the role once you fixed it, then about the PIN.
* Four round trips to learn four things. Astryx's inputs take a `status`, so
* every failure can be shown at once, against the field that caused it.
*
* `formError` stays for the edit and reset dialogs, which are not converted
* yet — Phase 6 moves one form at a time.
*/
const [fieldErrs, setFieldErrs] = useState<Partial<Record<'name' | 'role' | 'pin' | 'phone', string>>>({});
const [issued, setIssued] = useState<PosStaffUser | null>(null);
const [copied, setCopied] = useState(false);
const [confirmOff, setConfirmOff] = useState<PosStaffUser | null>(null);
@@ -138,29 +157,44 @@ export default function PosStaffPanel({
setPhone('');
setShiftId(0);
setFormError('');
// Clear the per-field errors too, or reopening the form shows the errors
// from the last attempt against fields that are now empty.
setFieldErrs({});
};
const submit = () => {
const name = fullName.trim();
const chosen = role || (roles.length === 1 ? roles[0].role : '');
if (!name) return setFormError('Enter the person’s name.');
if (!chosen) return setFormError('Choose a role.');
if (!/^\d{4}$/.test(pin)) return setFormError('The PIN must be exactly 4 digits.');
// Checked here rather than left to the server: two people sharing a PIN at
// one counter both appear on the till's sign-in list and neither can be
// told apart on a bill.
if (staff.some((s) => s.pin === pin && (s.status || '').toLowerCase() !== 'inactive')) {
return setFormError('Another active person at this outlet already uses that PIN.');
// Same rules, same wording, same order as before — the only change is that
// every failure is collected and attached to its field instead of the first
// one short-circuiting the rest.
const errs: typeof fieldErrs = {};
if (!name) errs.name = 'Enter the person’s name.';
if (!chosen) errs.role = 'Choose a role.';
if (!/^\d{4}$/.test(pin)) {
errs.pin = 'The PIN must be exactly 4 digits.';
} else if (staff.some((s) => s.pin === pin && (s.status || '').toLowerCase() !== 'inactive')) {
// Checked here rather than left to the server: two people sharing a PIN at
// one counter both appear on the till's sign-in list and neither can be
// told apart on a bill.
errs.pin = 'Another active person at this outlet already uses that PIN.';
}
// The till signs in with this, so it is required here even though the
// column allows blank — an account without one can only ever log in by
// username, which is what this change exists to stop relying on.
const digits = phone.replace(/\D/g, '').replace(/^91(?=\d{10}$)/, '').replace(/^0(?=\d{10}$)/, '');
if (digits.length !== 10) return setFormError('Enter a 10-digit mobile number.');
if (staff.some((s) => (s.contactno || '') === digits && (s.status || '').toLowerCase() !== 'inactive')) {
return setFormError('Another active person here already signs in with that number.');
if (digits.length !== 10) {
errs.phone = 'Enter a 10-digit mobile number.';
} else if (staff.some((s) => (s.contactno || '') === digits && (s.status || '').toLowerCase() !== 'inactive')) {
errs.phone = 'Another active person here already signs in with that number.';
}
setFieldErrs(errs);
if (Object.keys(errs).length) return;
setFormError('');
createMut.mutate(
{
@@ -302,87 +336,87 @@ export default function PosStaffPanel({
{showForm && (
<div className="mt-4 rounded-xl p-3" style={{ background: SURFACE_ALT, border: `1px solid ${BORDER}` }}>
{/* Every field now carries a real label rather than relying on a
placeholder. A placeholder disappears the moment you type, so
the only cue for what a half-filled field is went away exactly
when it was needed. */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
<input
<TextInput
label="Full name"
isRequired
value={fullName}
onChange={(e) => setFullName(e.target.value)}
placeholder="Full name"
className="rounded-lg px-3 py-2 text-[13px] outline-none bg-white"
style={{ border: `1px solid ${BORDER}`, color: TEXT }}
onChange={(v) => setFullName(v)}
placeholder="e.g. Anitha R"
status={fieldErrs.name ? { type: 'error', message: fieldErrs.name } : undefined}
/>
<select
<Selector
label="Role"
isRequired
isDisabled={roles.length === 1}
value={role || (roles.length === 1 ? roles[0].role : '')}
onChange={(e) => setRole(e.target.value)}
disabled={roles.length === 1}
className="rounded-lg px-3 py-2 text-[13px] outline-none bg-white cursor-pointer disabled:cursor-default"
style={{ border: `1px solid ${BORDER}`, color: TEXT }}
>
{roles.length !== 1 && <option value="">Choose a role…</option>}
{roles.map((r) => (
<option key={r.role_id} value={r.role}>{r.label}</option>
))}
</select>
<input
onChange={(v) => setRole(String(v ?? ''))}
placeholder="Choose a role…"
options={roles.map((r) => ({ value: r.role, label: r.label }))}
status={fieldErrs.role ? { type: 'error', message: fieldErrs.role } : undefined}
/>
<TextInput
label="PIN"
isRequired
// The 4-digit clamp stays on the setter, not on the component —
// it is what stops a fifth keystroke ever reaching state.
value={pin}
onChange={(e) => setPin(e.target.value.replace(/\D/g, '').slice(0, 4))}
placeholder="4-digit PIN"
inputMode="numeric"
className="rounded-lg px-3 py-2 text-[13px] outline-none bg-white font-mono tracking-widest"
style={{ border: `1px solid ${BORDER}`, color: TEXT }}
onChange={(v) => setPin(v.replace(/\D/g, '').slice(0, 4))}
placeholder="4 digits"
status={fieldErrs.pin ? { type: 'error', message: fieldErrs.pin } : undefined}
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 mt-2">
<input
<TextInput
label="Mobile number"
isRequired
description="What they type at the till to sign in."
value={phone}
onChange={(e) => setPhone(e.target.value.replace(/[^\d+\s-]/g, '').slice(0, 16))}
placeholder="Mobile number — used to sign in"
inputMode="tel"
className="rounded-lg px-3 py-2 text-[13px] outline-none bg-white font-mono"
style={{ border: `1px solid ${BORDER}`, color: TEXT }}
onChange={(v) => setPhone(v.replace(/[^\d+\s-]/g, '').slice(0, 16))}
placeholder="10 digits"
status={fieldErrs.phone ? { type: 'error', message: fieldErrs.phone } : undefined}
/>
<Selector
label="Shift"
isOptional
value={String(shiftId)}
onChange={(v) => setShiftId(Number(v ?? 0))}
placeholder={shiftsQ.data?.length ? 'No shift assigned' : 'No shifts set up yet'}
options={[
{ value: '0', label: shiftsQ.data?.length ? 'No shift assigned' : 'No shifts set up yet' },
...(shiftsQ.data ?? []).map((sh) => ({
value: String(sh.staff_shift_id),
label: `${sh.name} · ${sh.start_time}–${sh.end_time}`,
})),
]}
/>
<select
value={shiftId}
onChange={(e) => setShiftId(Number(e.target.value))}
className="rounded-lg px-3 py-2 text-[13px] outline-none bg-white cursor-pointer"
style={{ border: `1px solid ${BORDER}`, color: TEXT }}
>
<option value={0}>
{shiftsQ.data?.length ? 'No shift assigned' : 'No shifts set up yet'}
</option>
{(shiftsQ.data ?? []).map((sh) => (
<option key={sh.staff_shift_id} value={sh.staff_shift_id}>
{sh.name} · {sh.start_time}–{sh.end_time}
</option>
))}
</select>
</div>
<p className="text-[11px] mt-2" style={{ color: TEXT_3 }}>
The mobile number is what they type at the till. A username and password are also
generated; the password is shown once, on the next screen.
A username and password are also generated; the password is shown once, on the
next screen.
</p>
{formError && (
<p className="text-[12px] mt-2 font-semibold" style={{ color: '#ef4444' }}>{formError}</p>
)}
{/* The mobile-number hint moved onto the field itself as a
`description`, so this paragraph no longer repeats it. */}
<div className="flex flex-wrap items-center gap-2 mt-3">
<button
<Button
label={createMut.isPending ? 'Creating…' : 'Create account'}
isDisabled={createMut.isPending}
isLoading={createMut.isPending}
onClick={submit}
disabled={createMut.isPending}
className="rounded-full px-4 py-1.5 text-[12.5px] font-bold text-white cursor-pointer disabled:opacity-50"
style={{ background: BRAND }}
>
{createMut.isPending ? 'Creating…' : 'Create account'}
</button>
<button
onClick={() => { setShowForm(false); resetForm(); }}
className="rounded-full px-4 py-1.5 text-[12.5px] font-bold cursor-pointer"
style={{ background: '#fff', color: TEXT_2, border: `1px solid ${BORDER}` }}
>
Cancel
</button>
/>
<Button
label="Cancel"
variant="secondary"
onClick={() => { setShowForm(false); resetForm(); setFieldErrs({}); }}
/>
</div>
</div>
)}
@@ -503,71 +537,53 @@ export default function PosStaffPanel({
{editing && (
<Modal onClose={() => setEditing(null)} title={`Edit ${editing.full_name}`}>
<div className="space-y-2">
<input
value={editName}
onChange={(e) => setEditName(e.target.value)}
placeholder="Full name"
className="w-full rounded-lg px-3 py-2 text-[13px] outline-none"
style={{ border: `1px solid ${BORDER}`, color: TEXT }}
/>
<select
<TextInput label="Full name" value={editName} onChange={(v) => setEditName(v)} />
<Selector
label="Role"
isDisabled={cashierOnly}
value={editRole}
onChange={(e) => setEditRole(e.target.value)}
disabled={cashierOnly}
className="w-full rounded-lg px-3 py-2 text-[13px] outline-none cursor-pointer disabled:cursor-default"
style={{ border: `1px solid ${BORDER}`, color: TEXT }}
>
{roles.map((r) => (
<option key={r.role_id} value={r.role}>{r.label}</option>
))}
</select>
<input
onChange={(v) => setEditRole(String(v ?? ''))}
options={roles.map((r) => ({ value: r.role, label: r.label }))}
/>
<TextInput
label="PIN"
value={editPin}
onChange={(e) => setEditPin(e.target.value.replace(/\D/g, '').slice(0, 4))}
placeholder="4-digit PIN"
inputMode="numeric"
className="w-full rounded-lg px-3 py-2 text-[13px] outline-none font-mono tracking-widest"
style={{ border: `1px solid ${BORDER}`, color: TEXT }}
onChange={(v) => setEditPin(v.replace(/\D/g, '').slice(0, 4))}
placeholder="4 digits"
/>
<input
<TextInput
label="Mobile number"
description="What they type at the till to sign in."
value={editPhone}
onChange={(e) => setEditPhone(e.target.value.replace(/[^\d+\s-]/g, '').slice(0, 16))}
placeholder="Mobile number — used to sign in"
inputMode="tel"
className="w-full rounded-lg px-3 py-2 text-[13px] outline-none font-mono"
style={{ border: `1px solid ${BORDER}`, color: TEXT }}
onChange={(v) => setEditPhone(v.replace(/[^\d+\s-]/g, '').slice(0, 16))}
/>
<Selector
label="Shift"
isOptional
value={String(editShift)}
onChange={(v) => setEditShift(Number(v ?? 0))}
options={[
{ value: '0', label: 'No shift assigned' },
...(shiftsQ.data ?? []).map((sh) => ({
value: String(sh.staff_shift_id),
label: `${sh.name} · ${sh.start_time}–${sh.end_time}`,
})),
]}
/>
<select
value={editShift}
onChange={(e) => setEditShift(Number(e.target.value))}
className="w-full rounded-lg px-3 py-2 text-[13px] outline-none cursor-pointer"
style={{ border: `1px solid ${BORDER}`, color: TEXT }}
>
<option value={0}>No shift assigned</option>
{(shiftsQ.data ?? []).map((sh) => (
<option key={sh.staff_shift_id} value={sh.staff_shift_id}>
{sh.name} · {sh.start_time}–{sh.end_time}
</option>
))}
</select>
</div>
{/* editError stays a single message here. The edit dialog validates
one rule at a time against fields that are already populated, so
there is no four-errors-at-once problem to solve — unlike the
create form, which starts empty. */}
{editError && <p className="text-[12px] mt-2 font-semibold" style={{ color: '#ef4444' }}>{editError}</p>}
<div className="flex flex-wrap items-center gap-2 mt-4">
<button
<Button
label={updateMut.isPending ? 'Saving…' : 'Save'}
isDisabled={updateMut.isPending}
isLoading={updateMut.isPending}
onClick={saveEdit}
disabled={updateMut.isPending}
className="rounded-full px-4 py-1.5 text-[12.5px] font-bold text-white cursor-pointer disabled:opacity-50"
style={{ background: BRAND }}
>
{updateMut.isPending ? 'Saving…' : 'Save'}
</button>
<button
onClick={() => setEditing(null)}
className="rounded-full px-4 py-1.5 text-[12.5px] font-bold cursor-pointer"
style={{ background: '#fff', color: TEXT_2, border: `1px solid ${BORDER}` }}
>
Cancel
</button>
/>
<Button label="Cancel" variant="secondary" onClick={() => setEditing(null)} />
</div>
</Modal>
)}
@@ -781,26 +797,28 @@ function ShiftManager({
{open && (
<div className="mt-4 rounded-xl p-3" style={{ background: SURFACE_ALT, border: `1px solid ${BORDER}` }}>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
<input
<TextInput
label="Shift name"
isRequired
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Name — e.g. Morning"
className="rounded-lg px-3 py-2 text-[13px] outline-none bg-white"
style={{ border: `1px solid ${BORDER}`, color: TEXT }}
onChange={(v) => setName(v)}
placeholder="e.g. Morning"
/>
<input
type="time"
value={start}
onChange={(e) => setStart(e.target.value)}
className="rounded-lg px-3 py-2 text-[13px] outline-none bg-white"
style={{ border: `1px solid ${BORDER}`, color: TEXT }}
{/* PHASE 7's TimeInput, landing here rather than as a separate
step — these two fields are the reason it was on the list.
It emits an ISO time string or undefined, so the `?? ''`
keeps the existing string state contract unchanged. */}
<TimeInput
label="Starts"
isRequired
value={start as any}
onChange={(v) => setStart((v ?? '') as string)}
/>
<input
type="time"
value={end}
onChange={(e) => setEnd(e.target.value)}
className="rounded-lg px-3 py-2 text-[13px] outline-none bg-white"
style={{ border: `1px solid ${BORDER}`, color: TEXT }}
<TimeInput
label="Ends"
isRequired
value={end as any}
onChange={(v) => setEnd((v ?? '') as string)}
/>
</div>
@@ -947,24 +965,24 @@ function Modal({
onClose: () => void;
children: React.ReactNode;
}) {
// PHASE 6 — on Astryx Dialog. All three dialogs on this page (edit, reset
// password, deactivate) render through here, so converting this one function
// converts all three without touching a single call site.
//
// isOpen is always true because callers mount this conditionally
// (`{editing && <Modal …>}`) rather than passing a flag. Dialog wants the
// flag, so it gets a constant one; the caller's conditional is still what
// controls visibility.
//
// The `minWidth: min(20rem, 100%)` floor is gone with the hand-rolled card.
// It existed to survive `max-w-sm` collapsing to 8px, and Dialog sizes itself
// from a `width` prop rather than a Tailwind utility — so the collision that
// bug came from cannot reach it.
return (
<div className="fixed inset-0 z-[300] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-slate-900/45" onClick={onClose} />
<div
className="relative bg-white rounded-2xl shadow-2xl w-full max-w-sm p-6"
// A floor as well as a ceiling. `max-w-sm` was resolving to 8px until
// the utility was repaired in index.css, which collapsed this card to
// its min-content width; the floor means a future theme change cannot
// reproduce that, and `min(24rem, 100%)` still lets it shrink on a
// narrow phone rather than forcing a horizontal scroll.
style={{ border: `1px solid ${BORDER}`, minWidth: 'min(20rem, 100%)' }}
>
{/* Names come from user input and can be long and unbroken. */}
<h3 className="text-[16px] font-extrabold mb-3 break-words" style={{ color: TEXT }}>
{title}
</h3>
{children}
</div>
</div>
<Dialog isOpen onOpenChange={(open) => { if (!open) onClose(); }} width={400} purpose="info">
{/* Names come from user input and can be long and unbroken. */}
<DialogHeader title={title} onOpenChange={(open) => { if (!open) onClose(); }} />
{children}
</Dialog>
);
}

View File

@@ -2,6 +2,51 @@ import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react'
import { Search, Plus, Minus, Trash2, Receipt, X, Tag, QrCode, CreditCard, PieChart, Barcode, ShoppingCart, User, RefreshCcw, Save, Download, ChevronRight, Zap, Wallet, Smartphone, AlertCircle } from 'lucide-react';
import { useFiestaStockStatement, FIESTA_TENANT_ID } from '../services/fiestaQueries';
import { num as fnum, str as fstr, type Row } from '../services/fiestaApi';
import { useNotify } from './Toasts';
import { Dialog } from '@astryxdesign/core/Dialog';
/**
* PHASE 6 — the one modal shell for this screen.
*
* PosView carried SEVEN hand-rolled `fixed inset-0` overlays, the largest
* concentration anywhere in the app. Every one was the same construction: a
* blurred backdrop, a white card, an inline `maxWidth`, and a header row with a
* close button. None trapped focus, none closed on Escape, and each had to be
* dismissed by finding its own X.
*
* They now share this. Widths are passed per call site so each dialog keeps the
* exact size it had (500 / 450 / 400px), and `maxHeight` is preserved where the
* original set one — the two that scroll internally would otherwise grow past
* the viewport on a long list.
*
* `print:hidden` is intentionally NOT carried over. It was on the backdrop of
* every overlay including the receipt one, which is why the receipt modal had
* to fight it back with `print:max-w-none`. Dialog renders in the top layer,
* where the browser's own print handling applies.
*/
function PosModal({
onClose,
width,
maxHeight,
children,
}: {
onClose: () => void;
width: number;
maxHeight?: string;
children: React.ReactNode;
}) {
return (
<Dialog
isOpen
onOpenChange={(open) => { if (!open) onClose(); }}
width={width}
maxHeight={maxHeight}
purpose="info"
>
{children}
</Dialog>
);
}
interface PosViewProps {
locationid?: number;
@@ -24,6 +69,10 @@ interface CartItem {
}
export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: PosViewProps) {
// PHASE 6 — replaces alert(). A native alert freezes the whole browser
// until dismissed; a toast does not, and errors stay until read.
const { notifyOk, notifyFail } = useNotify();
const stockQ = useFiestaStockStatement({ tenantid: tenantId, locationid: locationid ?? 0, pagesize: 200 });
const [search, setSearch] = useState('');
@@ -236,7 +285,7 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
if (product) {
handleProductScanned(product);
} else {
alert('Product not found in inventory.');
notifyFail('Product not found in inventory.');
}
}
}
@@ -733,8 +782,7 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
{/* ── MODAL: Manual Product Search ──────────────────────────────── */}
{showManualSearch && (
<div className="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 print:hidden">
<div className="bg-white rounded-2xl shadow-2xl overflow-hidden flex flex-col max-h-[85vh] animate-in zoom-in-95 duration-200" style={{ width: '100%', maxWidth: '500px' }}>
<PosModal onClose={() => setShowManualSearch(false)} width={500} maxHeight="85vh">
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-100">
<h3 className="font-bold text-slate-900 flex items-center gap-2">
<Search className="w-5 h-5 text-indigo-500" /> Product Search
@@ -781,14 +829,12 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
</button>
))}
</div>
</div>
</div>
</PosModal>
)}
{/* ── MODAL: Customer CRM ───────────────────────────────────────── */}
{showCustomerPrompt && (
<div className="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 print:hidden">
<div className="bg-white rounded-2xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200" style={{ width: '100%', maxWidth: '400px' }}>
<PosModal onClose={closeCustomerPrompt} width={400}>
<div className="px-6 pt-6 pb-5">
<div className="flex justify-between items-start mb-5">
<div className="w-12 h-12 bg-blue-50 rounded-xl flex items-center justify-center">
@@ -842,14 +888,12 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
</button>
)}
</div>
</div>
</div>
</PosModal>
)}
{/* ── MODAL: Parked Carts ───────────────────────────────────────── */}
{showParkedPrompt && (
<div className="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 print:hidden">
<div className="bg-white rounded-2xl shadow-2xl overflow-hidden flex flex-col max-h-[80vh] animate-in zoom-in-95 duration-200" style={{ width: '100%', maxWidth: '450px' }}>
<PosModal onClose={() => setShowParkedPrompt(false)} width={450} maxHeight="80vh">
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-100">
<h3 className="font-bold text-slate-900 flex items-center gap-2">
<Download className="w-5 h-5 text-blue-500" /> Parked Sales
@@ -883,14 +927,12 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
))
)}
</div>
</div>
</div>
</PosModal>
)}
{/* ── MODAL: Discount ───────────────────────────────────────────── */}
{discountPromptItem && (
<div className="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 print:hidden">
<div className="bg-white rounded-2xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200" style={{ width: '100%', maxWidth: '400px' }}>
<PosModal onClose={() => setDiscountPromptItem(null)} width={400}>
<div className="px-6 pt-6 pb-5">
<div className="flex justify-between items-start mb-5">
<div className="w-12 h-12 bg-emerald-50 rounded-xl flex items-center justify-center">
@@ -938,14 +980,12 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
Confirm Discount
</button>
</div>
</div>
</div>
</PosModal>
)}
{/* ── MODAL: Payment ────────────────────────────────────────────── */}
{showPayment && (
<div className="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 print:hidden">
<div className="bg-white rounded-2xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200" style={{ width: '100%', maxWidth: '450px' }}>
<PosModal onClose={() => setShowPayment(false)} width={450}>
{/* Header */}
<div className="bg-white border-b border-slate-200 px-6 py-5 text-slate-900 flex items-start justify-between">
<div>
@@ -1104,14 +1144,12 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
Confirm {grandTotal < 0 ? 'Refund' : 'Payment'}
</button>
</div>
</div>
</div>
</PosModal>
)}
{/* ── MODAL: Receipt ────────────────────────────────────────────── */}
{showReceipt && (
<div className="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 print:static print:bg-white print:block print:p-0">
<div className="bg-white rounded-2xl shadow-2xl overflow-hidden print:shadow-none print:max-w-none print:w-[300px]" style={{ width: '100%', maxWidth: '400px' }}>
<PosModal onClose={resetPos} width={400}>
<div className="px-5 py-4 border-b border-slate-100 flex justify-between items-center bg-slate-50 print:hidden">
<h3 className="font-bold text-slate-900 flex items-center gap-2">
<Receipt className="w-4 h-4" /> Receipt
@@ -1177,14 +1215,12 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
<Receipt className="w-4 h-4" /> Print
</button>
</div>
</div>
</div>
</PosModal>
)}
{/* ── MODAL: Daily Report ───────────────────────────────────────── */}
{showReport && (
<div className="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 print:hidden">
<div className="bg-white rounded-2xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200" style={{ width: '100%', maxWidth: '450px' }}>
<PosModal onClose={() => setShowReport(false)} width={450}>
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-100">
<h3 className="font-bold text-slate-900 flex items-center gap-2">
<PieChart className="w-5 h-5 text-emerald-500" /> Daily Sales Report
@@ -1274,8 +1310,7 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
)}
</div>
</div>
</div>
</div>
</PosModal>
)}
</div>
);

View File

@@ -40,6 +40,7 @@ import { FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID, num as fnum, str as fstr,
import { stockRowToProduct } from '../services/fiestaMappers';
import AwaitingApi from './AwaitingApi';
import SalesRevenueReport from './SalesRevenueReport';
import { useNotify } from './Toasts';
interface ReportsViewProps {
searchQuery: string;
@@ -52,6 +53,10 @@ const WEEK_KEYS = ['week1', 'week2', 'week3', 'week4', 'week5'];
const WEEK_LABELS = ['Week 1', 'Week 2', 'Week 3', 'Week 4', 'Week 5'];
export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimbatoreView, tenantId = FIESTA_TENANT_ID }: ReportsViewProps) {
// PHASE 6 — replaces alert(). A native alert freezes the whole browser
// until dismissed; a toast does not, and errors stay until read.
const { notifyOk, notifyFail } = useNotify();
const [selectedTimeframe, setSelectedTimeframe] = useState('This Year (YTD)');
const [selectedRegion, setSelectedRegion] = useState<'all' | 'coimbatore' | 'chennai' | 'bangalore'>('all');
const [activeStoreId, setActiveStoreId] = useState<number | 'all'>('all');
@@ -433,7 +438,7 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
clearInterval(interval);
setTimeout(() => {
setExportingFormat(null);
alert(`${format} ledger exported successfully.`);
notifyOk(`${format} ledger exported.`);
}, 450);
return 100;
}

View File

@@ -43,7 +43,10 @@ export default function RiderTelemetryPanel({ logs, riderName, isLoading }: Ride
const lat = latest?.latitude || latest?.lat;
const lon = latest?.longitude || latest?.lng;
const battery = latest?.batterypercentage || latest?.battery;
const signal = latest?.signalstrength || latest?.signal;
// Typed at the declaration rather than cast at each use. The telemetry rows
// are untyped, so without this `signal` is `unknown` and cannot be rendered
// or passed to getSignalBars. The value is a dBm reading or absent.
const signal = (latest?.signalstrength || latest?.signal) as number | undefined;
const speed = latest?.speed || 0;
const timestamp = latest?.logdate || latest?.timestamp;

View File

@@ -25,6 +25,7 @@ import AwaitingApi from './AwaitingApi';
import AdminConsole from './AdminConsole';
import { ConfirmModal } from './ConfirmModal';
import type { AuthUser } from '../services/auth';
import { useNotify } from './Toasts';
type TabKey = 'profile' | 'outlets' | 'users';
@@ -128,6 +129,10 @@ export interface SettingsViewProps {
}
export default function SettingsView({ tenantId = FIESTA_TENANT_ID, user }: SettingsViewProps) {
// PHASE 6 — replaces alert(). A native alert freezes the whole browser
// until dismissed; a toast does not, and errors stay until read.
const { notifyOk, notifyFail } = useNotify();
const [activeTab, setActiveTab] = useState<TabKey>('profile');
// Live tenant profile + outlets.
@@ -166,9 +171,9 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID, user }: Sett
});
setShowEditStoreModal(false);
setEditingStore(null);
alert('Outlet updated successfully!');
notifyOk('Outlet updated.');
} catch (err: any) {
alert(err.message || 'Failed to update outlet.');
notifyFail(err, 'Failed to update outlet.');
}
};
@@ -183,7 +188,7 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID, user }: Sett
await deleteLocationMut.mutateAsync({ tenantid: tenantId, locationid: loc.locationid });
setConfirmModalState({ isOpen: false, loc: null });
} catch (err: any) {
alert(err.message || 'Failed to delete outlet.');
notifyFail(err, 'Failed to delete outlet.');
setConfirmModalState({ isOpen: false, loc: null });
}
};

View File

@@ -33,6 +33,12 @@ import { CardCarousel, ProductGallery } from './CatalogueGallery';
import FMCGHoverOverlay from './FMCGHoverOverlay';
import CatalogueDetailPanel from './CatalogueDetailPanel';
import OfflineSalesUpload from './OfflineSalesUpload';
import { useNotify } from './Toasts';
// PHASE 6 — the store catalogue's own controls.
import { TextInput } from '@astryxdesign/core/TextInput';
import { NumberInput } from '@astryxdesign/core/NumberInput';
import { DateInput } from '@astryxdesign/core/DateInput';
import { CheckboxInput } from '@astryxdesign/core/CheckboxInput';
const PLACEHOLDER = 'https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&q=80&w=200';
@@ -79,6 +85,10 @@ function catBadgeClass(category: string): string {
}
export default function StoreCatalogView({ locationid, storeName = 'your store', tenantId = FIESTA_TENANT_ID, isSidebarOpen = false }: StoreCatalogViewProps) {
// PHASE 6 — replaces alert(). A native alert freezes the whole browser
// until dismissed; a toast does not, and errors stay until read.
const { notifyOk, notifyFail } = useNotify();
const tenantid = tenantId;
const [view, setView] = useState<'catalogue' | 'inventory' | 'requests'>('catalogue');
const [isLocalSidebarOpen, setIsLocalSidebarOpen] = useState(false);
@@ -146,7 +156,11 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
);
const stockRequestsQ = useFiestaGetStockRequests({ tenantid, locationid: storeLocationId, pagesize: 500, date: requestDate });
const [picks, setPicks] = useState<Record<string, { qty: number; status: 'Pending' | 'Approved' | 'Rejected' | 'Cancelled' | 'Received'; requestedAt: string; resolvedAt?: string }>>({});
// `requestid` is written by the effect below and read by the Mark Received
// button; it was simply missing from this type, so the read did not compile
// once React types were installed. The value has always been present at
// runtime — this closes the type gap, it does not change behaviour.
const [picks, setPicks] = useState<Record<string, { qty: number; status: 'Pending' | 'Approved' | 'Rejected' | 'Cancelled' | 'Received'; requestedAt: string; resolvedAt?: string; requestid?: number }>>({});
useEffect(() => {
if (stockRequestsQ.data) {
@@ -187,7 +201,7 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
if (picks[String(data.productid)]?.status === 'Received') return;
if (updateRequestMutation.isPending) return;
if (!storeLocationId) {
alert('Your account isn\'t linked to a store outlet yet, so stock can\'t be received.');
notifyFail('Your account isn\'t linked to a store outlet yet, so stock can\'t be received.');
return;
}
updateRequestMutation.mutate(
@@ -200,7 +214,7 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
},
{
onError: (err: any) => {
alert(err.message || 'Failed to record received stock.');
notifyFail(err, 'Failed to record received stock.');
},
},
);
@@ -213,7 +227,7 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
setPicks(prev => ({ ...prev, [id]: { ...prev[id], status: 'Cancelled', resolvedAt: new Date().toISOString() } }));
} else {
if (!storeLocationId) {
alert('Your account isn\'t linked to a store outlet yet, so stock can\'t be requested.');
notifyFail('Your account isn\'t linked to a store outlet yet, so stock can\'t be requested.');
return;
}
setPicks(prev => ({ ...prev, [id]: { qty: 1, status: 'Pending', requestedAt: new Date().toISOString() } }));
@@ -231,7 +245,7 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
const setPickQty = (id: string, qty: number) => {
const safeQty = Math.max(1, Math.round(qty) || 1);
if (!storeLocationId) {
alert('Your account isn\'t linked to a store outlet yet, so stock can\'t be requested.');
notifyFail('Your account isn\'t linked to a store outlet yet, so stock can\'t be requested.');
return;
}
setPicks((prev) => {
@@ -404,14 +418,16 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
<UploadCloud size={14} /> Upload Offline Sales
</button>
{/* PHASE 6 — DateInput carries its own label, so the separate "Date:"
caption is dropped rather than duplicated beside it. The comment sits
outside the conditional: as its first child it read as a second root
element and broke the whole file. */}
{view === 'requests' && (
<div className="flex items-center gap-2 shrink-0">
<span className="text-xs font-bold text-slate-500 uppercase tracking-wider">Date:</span>
<input
type="date"
value={requestDate}
onChange={(e) => setRequestDate(e.target.value)}
className="px-3 py-1.5 border border-slate-200 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-[#662582] focus:border-[#662582] text-xs font-semibold text-slate-700 bg-white"
<div className="shrink-0 w-44">
<DateInput
label="Date"
value={requestDate as any}
onChange={(v) => setRequestDate((v ?? '') as string)}
/>
</div>
)}
@@ -436,20 +452,13 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
</h3>
<div className="space-y-1.5">
{categories.map((c) => (
<label key={c} className="flex items-center gap-3 p-2 rounded-xl hover:bg-slate-50 cursor-pointer group transition-colors">
<div className="relative flex items-center justify-center">
<input
type="checkbox"
checked={selectedCategories.includes(c)}
onChange={() => toggleCategory(c)}
className="peer appearance-none w-5 h-5 border-2 border-slate-300 rounded-lg checked:border-[#662582] checked:bg-[#662582] transition-colors cursor-pointer"
/>
<Check size={12} className="absolute text-white opacity-0 peer-checked:opacity-100 pointer-events-none" strokeWidth={3} />
</div>
<span className={`text-xs font-semibold select-none transition-colors ${selectedCategories.includes(c) ? 'text-[#662582]' : 'text-slate-600 group-hover:text-slate-900'}`}>
{c}
</span>
</label>
<div key={c} className="px-1 py-0.5">
<CheckboxInput
label={c}
value={selectedCategories.includes(c)}
onChange={() => toggleCategory(c)}
/>
</div>
))}
</div>
</div>
@@ -463,20 +472,13 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
</h3>
<div className="space-y-1.5">
{['Healthy', 'Low', 'Critical', 'Out of stock'].map((h) => (
<label key={h} className="flex items-center gap-3 p-2 rounded-xl hover:bg-slate-50 cursor-pointer group transition-colors">
<div className="relative flex items-center justify-center">
<input
type="checkbox"
checked={stockHealthFilter.includes(h)}
onChange={() => toggleStockHealth(h)}
className="peer appearance-none w-5 h-5 border-2 border-slate-300 rounded-lg checked:border-[#662582] checked:bg-[#662582] transition-colors cursor-pointer"
/>
<Check size={12} className="absolute text-white opacity-0 peer-checked:opacity-100 pointer-events-none" strokeWidth={3} />
</div>
<span className={`text-xs font-semibold select-none transition-colors ${stockHealthFilter.includes(h) ? 'text-[#662582]' : 'text-slate-600 group-hover:text-slate-900'}`}>
{h}
</span>
</label>
<div key={h} className="px-1 py-0.5">
<CheckboxInput
label={h}
value={stockHealthFilter.includes(h)}
onChange={() => toggleStockHealth(h)}
/>
</div>
))}
</div>
</div>
@@ -521,20 +523,20 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
<Layers size={14} /> Filter
</button>
<div className="relative hidden md:block md:w-64 h-[36px]">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
<input
type="text"
placeholder={view === 'catalogue' ? 'Search catalogue...' : 'Search your stock...'}
{/* `hasClear` replaces the absolutely-positioned X button, and
`startIcon` replaces the absolutely-positioned magnifier — so
the wrapper no longer needs `relative` or a fixed height to
anchor them. */}
<div className="hidden md:block md:w-64">
<TextInput
label="Search"
isLabelHidden
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full h-full pl-9 pr-8 py-2 bg-white border border-slate-200 rounded-xl text-xs text-slate-800 placeholder-slate-400 focus:outline-none focus:border-[#662582] transition-all shadow-sm font-medium"
onChange={(v) => setSearch(v)}
placeholder={view === 'catalogue' ? 'Search catalogue…' : 'Search your stock…'}
startIcon={<Search size={14} />}
hasClear
/>
{search && (
<button onClick={() => setSearch('')} className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 cursor-pointer border-none bg-transparent">
<X size={12} />
</button>
)}
</div>
</div>
</div>
@@ -881,24 +883,15 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
</div>
</div>
<div>
<label className="text-[9px] font-bold uppercase tracking-widest text-slate-400 block mb-2">Custom Amount</label>
<div className="relative group">
<div className="absolute inset-y-0 left-3 flex items-center pointer-events-none">
<Boxes size={14} className="text-slate-400 group-focus-within:text-[#662582] transition-colors" />
</div>
<input
type="number"
min="1"
value={tempQty}
onChange={(e) => setTempQty(Math.max(1, parseInt(e.target.value) || 1))}
className="w-full h-10 pl-9 pr-12 text-right text-lg font-bold font-mono text-[#662582] bg-slate-50/50 rounded-lg border border-slate-200 outline-none focus:border-[#662582] focus:bg-white transition-all shadow-inner"
/>
<div className="absolute inset-y-0 right-3 flex items-center pointer-events-none">
<span className="text-[9px] font-bold text-slate-400 uppercase tracking-widest pl-2 border-l border-slate-200">Units</span>
</div>
</div>
</div>
{/* NumberInput enforces the minimum itself, so the Math.max guard
stays only as the floor for a pasted or cleared value. */}
<NumberInput
label="Custom quantity"
min={1}
value={tempQty}
onChange={(v) => setTempQty(Math.max(1, Number(v) || 1))}
startIcon={<Boxes size={14} />}
/>
</div>
{/* Total & Submit */}

View File

@@ -3,16 +3,19 @@
* SPDX-License-Identifier: Apache-2.0
*/
/**
* PHASE 4 — now a wrapper over the shared `NavRail`.
*
* The props and the rendered result are unchanged, so `SuperAdminPage` was not
* touched. The only behavioural difference is that tapping an item on a phone
* now closes the rail, which the user rail already did and this one didn't.
*/
import React from 'react';
import { type LucideIcon } from 'lucide-react';
import { NavLink } from 'react-router-dom';
import NavRail, { type NavRailItem } from './NavRail';
/** One entry in the super admin sidebar. */
export interface SuperAdminNavItem {
id: string;
label: string;
icon: LucideIcon;
}
export type SuperAdminNavItem = NavRailItem;
interface SuperAdminSidebarProps {
items: SuperAdminNavItem[];
@@ -21,53 +24,6 @@ interface SuperAdminSidebarProps {
onClose: () => void;
}
/**
* The Super Admin page's left rail. Visually identical to the admin `Sidebar`.
*/
export default function SuperAdminSidebar({ items, isOpen, onClose }: SuperAdminSidebarProps) {
return (
<>
{/* Mobile Overlay */}
{isOpen && (
<div
className="md:hidden fixed inset-0 bg-black/50 z-40 backdrop-blur-sm"
onClick={onClose}
/>
)}
<aside
className={`fixed left-0 top-0 h-screen bg-[#662582] border-r border-[#662582] text-white flex flex-col py-xl pt-16 z-40 transition-all duration-300 ${
isOpen ? 'translate-x-0 w-64' : '-translate-x-full md:translate-x-0 md:w-16'
}`}
>
{/* Main Navigation Sidebar Links */}
<nav className="flex-1 space-y-1 overflow-y-auto px-xs">
{items.map((item) => {
const IconComponent = item.icon;
return (
<NavLink
key={item.id}
to={`/superadmin/${item.id}`}
title={item.label}
className={({ isActive }) => `w-full flex items-center py-3 rounded-none text-left transition-all duration-200 cursor-pointer ${
isOpen ? 'gap-md px-md border-l-4' : 'justify-center px-0'
} ${
isActive
? 'bg-black/20 text-white font-semibold' + (isOpen ? ' border-white' : '')
: 'text-purple-200 hover:bg-white/10 hover:text-white' + (isOpen ? ' border-transparent' : '')
}`}
>
{({ isActive }) => (
<>
<IconComponent size={18} className={isActive ? 'text-white' : 'text-purple-300'} />
{isOpen && <span className="font-sans text-sm font-medium">{item.label}</span>}
</>
)}
</NavLink>
);
})}
</nav>
</aside>
</>
);
return <NavRail items={items} basePath="/superadmin" isOpen={isOpen} onClose={onClose} itemRadius="none" />;
}

52
src/components/Toasts.tsx Normal file
View File

@@ -0,0 +1,52 @@
/**
* PHASE 5 — toasts.
*
* The console has never had any. Publishing a product, importing from the
* catalogue, deactivating a till user and deleting a product location all
* complete in silence today: the only feedback is that a row eventually
* changes, or doesn't. When something fails, the user usually cannot tell
* whether it failed or simply hasn't finished.
*
* `ToastViewport` is mounted once in main.tsx. Anything below it can call
* `useToast()` and get a function that shows one. This file adds two thin
* helpers over that so call sites read as intent rather than configuration.
*
* Astryx's ToastType is only 'info' | 'error' — there is no success type — so
* `notifyOk` uses 'info'. Do not invent a 'success' string; it will not
* typecheck, and silently falling back would be worse.
*/
import { useToast } from '@astryxdesign/core/Toast';
/**
* Toasts for the result of a mutation.
*
* @example
* const { notifyOk, notifyFail } = useNotify();
* publishMut.mutate(vars, {
* onSuccess: () => notifyOk('Published to all outlets'),
* onError: (e) => notifyFail(e),
* });
*/
export function useNotify() {
const toast = useToast();
return {
notifyOk: (message: string) => toast({ body: message, isAutoHide: true }),
/**
* Accepts the raw error so no call site has to remember how to unwrap one.
* Errors do NOT auto-hide: a failure the user blinked past is a failure
* they will report as "nothing happened".
*/
notifyFail: (err: unknown, fallback = 'Something went wrong. Please try again.') => {
const message =
err instanceof Error ? err.message
: typeof err === 'string' ? err
: fallback;
return toast({ body: message, type: 'error', isAutoHide: false });
},
};
}
export { useToast };

View File

@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
import { Package, MapPin, Truck, ChevronDown, Check, X } from 'lucide-react';
import { useFiestaTenantLocations } from '../services/fiestaQueries';
import { str as fstr } from '../services/fiestaApi';
import { useNotify } from './Toasts';
export interface TrialProduct {
id: string | number;
@@ -21,6 +22,10 @@ interface TrialBatchDrawerProps {
}
export default function TrialBatchDrawer({ isOpen, onClose, product, tenantId, onSuccess }: TrialBatchDrawerProps) {
// PHASE 6 — replaces alert(). A native alert freezes the whole browser
// until dismissed; a toast does not, and errors stay until read.
const { notifyOk, notifyFail } = useNotify();
const [qty, setQty] = useState(5);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
@@ -49,7 +54,7 @@ export default function TrialBatchDrawer({ isOpen, onClose, product, tenantId, o
const handleSubmit = () => {
if (selectedLocationIds.length === 0) {
alert("Please select at least one store delivery hub.");
notifyFail('Select at least one store delivery hub.');
return;
}
setIsSubmitting(true);

View File

@@ -41,6 +41,7 @@ import PosConsoleView from './PosConsoleView';
import PosErrorBoundary from './PosErrorBoundary';
import UserStoreSidebar, { type UserNavItem } from './UserStoreSidebar';
import ComparisonModal from './ComparisonModal';
import { useNotify } from './Toasts';
interface UserStorePageProps {
/** Returns to the login screen. */
onLogout: () => void;
@@ -68,6 +69,10 @@ type StoreShape = React.ComponentProps<typeof StoreDetailView>['store'];
* no other stores. Layout mirrors the admin console (fixed Header + left rail).
*/
export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
// PHASE 6 — replaces alert(). A native alert freezes the whole browser
// until dismissed; a toast does not, and errors stay until read.
const { notifyOk, notifyFail } = useNotify();
const [sidebarOpen, setSidebarOpen] = useState(false);
const [showQrModal, setShowQrModal] = useState(false);
const location = useLocation();
@@ -151,7 +156,7 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
.toUpperCase() || 'NA';
const handleHelp = () =>
alert('nearledaily Store User Help — contact your store administrator for access or data questions.');
notifyOk('Contact your store administrator for access or data questions.');
// Build the store shape StoreDetailView expects, mirroring the App registry
// mapping so the console renders identically to the admin's store view.

View File

@@ -3,16 +3,19 @@
* SPDX-License-Identifier: Apache-2.0
*/
/**
* PHASE 4 — now a wrapper over the shared `NavRail`.
*
* Props and rendered result are unchanged, so `UserStorePage` was not touched.
* `itemRadius="lg"` preserves this rail's rounded items, which is where it
* differed from the super-admin one.
*/
import React from 'react';
import { type LucideIcon } from 'lucide-react';
import { NavLink } from 'react-router-dom';
import NavRail, { type NavRailItem } from './NavRail';
/** One entry in the user store sidebar. Add to `UserStorePage`'s `NAV_ITEMS`. */
export interface UserNavItem {
id: string;
label: string;
icon: LucideIcon;
}
export type UserNavItem = NavRailItem;
interface UserStoreSidebarProps {
items: UserNavItem[];
@@ -21,60 +24,6 @@ interface UserStoreSidebarProps {
onClose: () => void;
}
/**
* The user store page's left rail. Visually identical to the admin `Sidebar`
* (fixed, sits under the fixed Header, collapses to an icon rail) but driven by
* a generic items list so new user sections can be added without touching this.
*/
export default function UserStoreSidebar({ items, isOpen, onClose }: UserStoreSidebarProps) {
return (
<>
{/* Mobile Overlay */}
{isOpen && (
<div
className="md:hidden fixed inset-0 bg-black/50 z-40 backdrop-blur-sm"
onClick={onClose}
/>
)}
<aside
className={`fixed left-0 top-0 h-screen bg-[#662582] border-r border-[#662582] text-white flex flex-col py-xl pt-16 z-40 transition-all duration-300 ${
isOpen ? 'translate-x-0 w-64' : '-translate-x-full md:translate-x-0 md:w-16'
}`}
>
{/* Main Navigation Sidebar Links */}
<nav className="flex-1 space-y-1 overflow-y-auto px-xs">
{items.map((item) => {
const IconComponent = item.icon;
return (
<NavLink
key={item.id}
to={`/store/${item.id}`}
title={item.label}
onClick={() => {
if (window.innerWidth < 768) {
onClose();
}
}}
className={({ isActive }) => `w-full flex items-center py-3 rounded-lg text-left transition-all duration-200 cursor-pointer ${
isOpen ? 'gap-md px-md border-l-4' : 'justify-center px-0'
} ${
isActive
? 'bg-black/20 text-white font-semibold' + (isOpen ? ' border-white' : '')
: 'text-purple-200 hover:bg-white/10 hover:text-white' + (isOpen ? ' border-transparent' : '')
}`}
>
{({ isActive }) => (
<>
<IconComponent size={18} className={isActive ? 'text-white' : 'text-purple-300'} />
{isOpen && <span className="font-sans text-sm font-medium">{item.label}</span>}
</>
)}
</NavLink>
);
})}
</nav>
</aside>
</>
);
return <NavRail items={items} basePath="/store" isOpen={isOpen} onClose={onClose} itemRadius="lg" />;
}

View File

@@ -34,6 +34,7 @@ import { useFiestaUsers, useFiestaCreateUser, useFiestaUpdateUser, useFiestaRide
import { useAppRoles } from '../services/queries';
import { FIESTA_TENANT_ID, str as fstr, num as fnum, roleName } from '../services/fiestaApi';
import AddressAutocomplete, { type AddressResult } from './AddressAutocomplete';
import { useNotify } from './Toasts';
interface UsersPanelProps {
tenantId?: number;
@@ -83,6 +84,10 @@ const FALLBACK_ROLE_CHOICES = [
];
export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUserRole = 4 }: UsersPanelProps) {
// PHASE 6 — replaces alert(). A native alert freezes the whole browser
// until dismissed; a toast does not, and errors stay until read.
const { notifyOk, notifyFail } = useNotify();
const usersQ = useFiestaUsers({ tenantid: tenantId, pagesize: 100 });
const createUserMut = useFiestaCreateUser();
const updateUserMut = useFiestaUpdateUser();
@@ -105,7 +110,7 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
await updateUserMut.mutateAsync({ userid, roleid: -1, locationid: -1, applocationid: -1 });
}
} catch (err) {
alert(`Could not update user role: ${err instanceof Error ? err.message : 'Unknown error'}`);
notifyFail(err, 'Could not update user role.');
}
};
@@ -246,23 +251,23 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
const handleCreateUser = async (e: React.FormEvent) => {
e.preventDefault();
if (!newUser.firstname || !newUser.email || !newUser.contactno) {
alert('Please provide first name, contact number, and email.');
notifyFail('Please provide first name, contact number, and email.');
return;
}
if (!/^\d{10}$/.test(newUser.contactno.trim())) {
alert('Contact number must be exactly 10 digits.');
notifyFail('Contact number must be exactly 10 digits.');
return;
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(newUser.email.trim())) {
alert('Please enter a valid email address.');
notifyFail('Please enter a valid email address.');
return;
}
if (!Number(newUser.roleid)) {
alert('Please select a role.');
notifyFail('Please select a role.');
return;
}
if (!newUser.locationid) {
alert('Please select the store this user belongs to.');
notifyFail('Please select the store this user belongs to.');
return;
}
try {
@@ -288,9 +293,9 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
});
setShowAddUserModal(false);
setNewUser({ firstname: '', email: '', contactno: '', roleid: defaultNewUserRole, locationid: 0, applocationid: 0, address: '', suburb: '', city: '', state: '', postcode: '', latitude: '', longitude: '', shiftid: 0 });
alert(`Team member "${newUser.firstname}" added successfully.`);
notifyOk(`Team member "${newUser.firstname}" added.`);
} catch (err) {
alert(`Could not add team member: ${err instanceof Error ? err.message : 'Unknown error'}`);
notifyFail(err, 'Could not add team member.');
}
};
@@ -715,7 +720,7 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
});
setAssignUserId(null);
} catch (err) {
alert(`Could not assign user: ${err instanceof Error ? err.message : 'Unknown error'}`);
notifyFail(err, 'Could not assign user.');
}
}} className="p-6 space-y-5">
<div className="space-y-1.5">

View File

@@ -18,6 +18,41 @@
import React, { useEffect } from 'react';
import { createPortal } from 'react-dom';
import { X, Info } from 'lucide-react';
import { Card as AxCard } from '@astryxdesign/core/Card';
import { Skeleton as AxSkeleton } from '@astryxdesign/core/Skeleton';
import { Tooltip as AxTooltip } from '@astryxdesign/core/Tooltip';
import { StatusDot } from '@astryxdesign/core/StatusDot';
import {
Table as AxTable,
TableHeader,
TableHeaderCell,
TableRow,
TableBody,
} from '@astryxdesign/core/Table';
/*
* PHASE 3 — Astryx adapters.
*
* Every export below keeps its original name and signature, so the ten files
* that import from here were not touched. What changed is what each one renders
* underneath.
*
* SEVEN of the fourteen primitives now sit on Astryx: Card, FilterBar,
* TableShell, Skeleton, Tooltip, LiveStatus and SlideDrawer. Each was a
* lossless swap.
*
* THE OTHER SEVEN DELIBERATELY DID NOT MOVE, and the reason is the same for
* most of them: `Pill`, `StatusChip` and `MetricPill` take an arbitrary hex via
* a `color` prop, fed from ORDER_STATUS / DELIVERY_STATUS. Astryx's Badge takes
* a named variant from a fixed set of fourteen. Mapping onto it is lossy in a
* way that matters — both `accepted` (#6366f1) and `picked` (#8b5cf6) collapse
* to `purple`, so two distinct states on the delivery board would become
* indistinguishable. That is a functional regression, not a cosmetic one, so
* these keep rendering their own markup until the status maps themselves are
* reworked onto semantic names. GradientHeader (brand gradient), KpiStrip
* (bespoke layout), StampCell and SearchPill are unchanged for want of a
* genuine equivalent.
*/
// ── Design tokens ────────────────────────────────────────────────────────────────
export const BRAND = '#662582';
@@ -114,13 +149,13 @@ function BrandMark() {
/** Live / loading / error status line used under the header title. */
export function LiveStatus({ state, label }: { state: 'live' | 'loading' | 'error'; label: string }) {
const color = state === 'error' ? '#ef4444' : state === 'loading' ? '#94a3b8' : '#10b981';
// The three states map exactly onto three Astryx variants, and `isPulsing`
// replaces the hand-rolled animate-pulse. Semantic tokens mean these now
// track the theme instead of three hardcoded hexes.
const variant = state === 'error' ? 'error' : state === 'loading' ? 'neutral' : 'success';
return (
<span className="inline-flex items-center gap-1.5 text-[11px] font-semibold" style={{ color: TEXT_2 }}>
<span
className={`rounded-full ${state === 'loading' ? 'animate-pulse' : ''}`}
style={{ width: 8, height: 8, background: color, boxShadow: state === 'live' ? `0 0 0 4px ${color}2e` : undefined }}
/>
<StatusDot variant={variant} label={label} isPulsing={state === 'loading'} />
{label}
</span>
);
@@ -153,7 +188,9 @@ export function KpiStrip({ items, loading }: { items: KpiItem[]; loading?: boole
className="p-1.5 rounded-lg transition-transform group-hover:scale-110 flex items-center justify-center shrink-0"
style={{ background: soft(it.color), color: it.color }}
>
{React.cloneElement(it.icon as React.ReactElement, { className: 'w-3.5 h-3.5' })}
{/* The element's props have to be named for cloneElement to accept
className — a bare ReactElement is typed as Partial<unknown>. */}
{React.cloneElement(it.icon as React.ReactElement<{ className?: string }>, { className: 'w-3.5 h-3.5' })}
</div>
</div>
<div className="mt-2 w-full">
@@ -276,21 +313,20 @@ export function SearchPill({ value, onChange, placeholder, color = BRAND, inputR
// ── Card shells & table head cell ────────────────────────────────────────────────
export function Card({ children, className = '', flush }: { children: React.ReactNode; className?: string; flush?: 'top' }) {
return (
<div
className={`bg-white border ${flush === 'top' ? 'rounded-b-2xl' : 'rounded-2xl'} ${className}`}
style={{ borderColor: BORDER }}
>
// padding={0} because every caller already supplies its own padding
// classes; letting Astryx add its default would shift ten screens at once.
<AxCard padding={0} className={`${flush === 'top' ? 'rounded-b-2xl rounded-t-none' : ''} ${className}`}>
{children}
</div>
</AxCard>
);
}
/** Filter/tab bar paper that visually joins the table below it (flat bottom). */
export function FilterBar({ children, className = '' }: { children: React.ReactNode; className?: string }) {
return (
<div className={`bg-white border rounded-2xl p-3 sm:p-4 ${className}`} style={{ borderColor: BORDER, boxShadow: SHADOW_MD }}>
<AxCard padding={0} elevation="low" className={`p-3 sm:p-4 ${className}`}>
{children}
</div>
</AxCard>
);
}
@@ -306,24 +342,31 @@ export const TH_STYLE: React.CSSProperties = {
};
export function TableShell({ head, children, minWidth, footer }: { head: string[]; children: React.ReactNode; minWidth?: number; footer?: React.ReactNode }) {
// `children` is a stream of caller-authored <tr>/<td>, so the body stays a
// plain TableBody passthrough — swapping every row in ten screens is Phase 6's
// job, not this one. Head cells move to Astryx now because they are owned
// here and nobody outside supplies them.
return (
<div className="bg-white rounded-2xl border overflow-hidden shadow-sm flex flex-col" style={{ borderColor: BORDER }}>
<AxCard padding={0} className="overflow-hidden flex flex-col">
<div className="overflow-x-auto flex-1">
<table className="w-full text-sm text-left whitespace-nowrap" style={{ minWidth }}>
<thead className="sticky top-0 z-10" style={TH_STYLE}>
<tr>
<AxTable style={{ minWidth }}>
<TableHeader>
<TableRow>
{head.map((h, i) => (
<th key={i} className={`px-4 py-3 ${i > 1 ? 'text-right' : ''}`}>{h}</th>
// TableHeaderCell has no `align` prop, so the right-alignment
// from column three onwards is kept on an inner span rather
// than lost.
<TableHeaderCell key={i}>
<span className={i > 1 ? 'block text-right' : 'block'}>{h}</span>
</TableHeaderCell>
))}
</tr>
</thead>
<tbody className="divide-y" style={{ borderColor: DIVIDER }}>
{children}
</tbody>
</table>
</TableRow>
</TableHeader>
<TableBody>{children}</TableBody>
</AxTable>
</div>
{footer}
</div>
</AxCard>
);
}
@@ -334,11 +377,13 @@ export function TableShell({ head, children, minWidth, footer }: { head: string[
* Uses a pulsing animation to match shape before data arrives.
*/
export function Skeleton({ className = '', style }: { className?: string; style?: React.CSSProperties }) {
// Callers size this with Tailwind classes (`h-4 w-20`), while Astryx's
// Skeleton wants numeric width/height. The wrapper keeps the caller's sizing
// and lets the Astryx one fill it, so no call site had to change.
return (
<div
className={`animate-pulse bg-slate-200/60 rounded-md ${className}`}
style={style}
/>
<div className={className} style={style}>
<AxSkeleton width="100%" height="100%" />
</div>
);
}
@@ -347,16 +392,14 @@ export function Skeleton({ className = '', style }: { className?: string; style?
* Wraps children and shows a sleek dark explanation box on hover.
*/
export function Tooltip({ children, content, position = 'top' }: { children: React.ReactNode; content: React.ReactNode; position?: 'top' | 'bottom' }) {
// Straight swap. Astryx's version also opens on keyboard focus, which the
// hover-only original never did — a real accessibility gain for free.
return (
<div className="relative flex items-center group cursor-help w-max">
{children}
<div className={`absolute ${position === 'top' ? 'bottom-full mb-2' : 'top-full mt-2'} left-1/2 -translate-x-1/2 hidden group-hover:block w-max max-w-[250px] z-[100] animate-in fade-in zoom-in-95 duration-200`}>
<div className="bg-[#0f172a] text-white text-[10px] font-medium p-2.5 rounded-xl shadow-xl border border-white/10 text-center leading-relaxed">
{content}
</div>
<div className={`absolute ${position === 'top' ? 'top-full border-t-[#0f172a] border-x-transparent border-b-transparent' : 'bottom-full border-b-[#0f172a] border-x-transparent border-t-transparent'} left-1/2 -translate-x-1/2 border-[5px]`} />
</div>
</div>
// Astryx names placements by flow direction, not by physical edge — so
// top/bottom become above/below. The public prop keeps its old names.
<AxTooltip content={content} placement={position === 'top' ? 'above' : 'below'}>
<span className="inline-flex items-center cursor-help w-max">{children}</span>
</AxTooltip>
);
}
@@ -376,20 +419,37 @@ export function SlideDrawer({
children: React.ReactNode;
}) {
// Prevent body scroll when open
// Kept rather than delegated to Astryx's Dialog. Dialog is a centred modal
// built on <dialog>; this is an edge-anchored drawer, and forcing one into
// the other would change the shape of six screens in a phase whose whole
// premise is that call sites do not change. What it does gain is Dialog's
// two behaviours that were missing here: Escape closes it, and focus is
// restored to whatever opened it. Phase 5 is where the overlay family gets
// unified properly.
useEffect(() => {
if (isOpen) document.body.style.overflow = 'hidden';
else document.body.style.overflow = '';
return () => { document.body.style.overflow = ''; };
}, [isOpen]);
useEffect(() => {
if (!isOpen) return;
const opener = document.activeElement as HTMLElement | null;
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('keydown', onKey);
opener?.focus?.();
};
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[200]">
{/* Muted Backdrop */}
<div
className="absolute inset-0 bg-slate-900/40 transition-opacity"
<div
className="absolute inset-0 bg-slate-900/40 transition-opacity"
onClick={onClose}
/>

View File

@@ -0,0 +1,121 @@
/**
* PHASE 3 verification harness — THROWAWAY, delete with the spike folder.
*
* The ten screens that consume consoleUi sit behind a login, so this renders
* the seven rewritten primitives directly with stand-in data. It imports them
* by their original names through the original module path, which is the point:
* if these render, the adapters are signature-compatible.
*/
import React, { useState } from 'react';
import {
Card,
FilterBar,
TableShell,
Skeleton,
Tooltip,
LiveStatus,
SlideDrawer,
Pill,
StatusChip,
MetricPill,
StampCell,
SearchPill,
KpiStrip,
ORDER_STATUS,
DELIVERY_STATUS,
statusColor,
BRAND,
} from '../consoleUi';
import { Package } from 'lucide-react';
import { ConfirmModal } from '../ConfirmModal';
import { useNotify } from '../Toasts';
export default function ConsoleUiProbe() {
const { notifyOk, notifyFail } = useNotify();
const [confirm, setConfirm] = useState(false);
const [drawer, setDrawer] = useState(false);
const [q, setQ] = useState('');
const [tab, setTab] = useState('pending');
return (
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', gap: 20 }}>
<h2 className="text-xl font-extrabold">consoleUi — Phase 3 adapters</h2>
<KpiStrip
items={[
{ label: 'Orders', value: '1,248', color: BRAND, icon: <Package /> },
{ label: 'Delivered', value: '1,102', color: '#10b981', icon: <Package />, badge: '88%' },
{ label: 'Pending', value: '94', color: '#f59e0b', icon: <Package /> },
{ label: 'Cancelled', value: '52', color: '#ef4444', icon: <Package /> },
]}
/>
<FilterBar>
<div className="flex flex-wrap items-center gap-2">
<LiveStatus state="live" label="Live" />
<LiveStatus state="loading" label="Syncing" />
<LiveStatus state="error" label="Failed" />
<div className="w-64"><SearchPill value={q} onChange={setQ} placeholder="Search orders…" /></div>
{['pending', 'confirmed', 'delivered', 'cancelled'].map((s) => (
<Pill key={s} active={tab === s} color={statusColor(ORDER_STATUS, s)} onClick={() => setTab(s)} count={12}>
{s}
</Pill>
))}
<Tooltip content="Astryx tooltip — now opens on keyboard focus too.">
<span className="text-xs font-bold underline decoration-dotted">hover me</span>
</Tooltip>
<button className="text-xs font-bold underline" onClick={() => setDrawer(true)}>open drawer</button>
</div>
</FilterBar>
<TableShell head={['Order', 'Status', 'Distance', 'Amount', 'Placed']} minWidth={720}>
{[
{ id: '#1043', s: 'delivered', km: '4.2', amt: '₹248' },
{ id: '#1041', s: 'cancelled', km: '1.1', amt: '₹89' },
{ id: '#1040', s: 'pending', km: '7.8', amt: '₹156' },
].map((r) => (
<tr key={r.id}>
<td className="px-4 py-3 font-bold">{r.id}</td>
<td className="px-4 py-3"><StatusChip label={r.s} color={statusColor(ORDER_STATUS, r.s)} /></td>
<td className="px-4 py-3 text-right"><MetricPill color={statusColor(DELIVERY_STATUS, 'active')}>{r.km} km</MetricPill></td>
<td className="px-4 py-3 text-right font-mono font-bold">{r.amt}</td>
<td className="px-4 py-3 text-right"><StampCell date="12 Aug" time="10:42" /></td>
</tr>
))}
</TableShell>
<Card className="p-4">
<div className="text-xs font-bold mb-2">Skeletons</div>
<div className="flex gap-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-40" />
<Skeleton className="h-4 w-16" />
</div>
</Card>
<Card className="p-4">
<div className="text-xs font-bold mb-2">Phase 5 — overlays &amp; toasts</div>
<div className="flex gap-3">
<button className="text-xs font-bold underline" onClick={() => setConfirm(true)}>open confirm dialog</button>
<button className="text-xs font-bold underline" onClick={() => notifyOk('Published to all outlets')}>toast: ok</button>
<button className="text-xs font-bold underline" onClick={() => notifyFail(new Error('Set a price before publishing.'))}>toast: error</button>
</div>
</Card>
<ConfirmModal
isOpen={confirm}
title="Unpublish product"
message="This removes it from every outlet under the tenant. Stock and history are kept."
confirmText="Unpublish"
isDestructive
onConfirm={() => { setConfirm(false); notifyOk('Unpublished from 3 outlets'); }}
onCancel={() => setConfirm(false)}
/>
<SlideDrawer isOpen={drawer} onClose={() => setDrawer(false)} title="Order #1043">
<p className="text-sm">Escape should close this, and focus should return to the button that opened it.</p>
</SlideDrawer>
</div>
);
}

View File

@@ -0,0 +1,207 @@
/**
* PHASE 1 SPIKE — THROWAWAY. Delete this whole folder to revert.
*
* A rebuild of the create-staff form and roster table from PosStaffPanel.tsx
* on Astryx, to answer three questions before committing to the migration:
*
* 1. Does the cascade hold? Specifically, do our unlayered `.max-w-*`
* overrides in index.css bleed into Astryx components — they beat every
* cascade layer, including `astryx-base`.
* 2. What does it cost in bundle size?
* 3. Do Field / TextInput / Selector actually cover our validation patterns?
*
* The validation rules below are copied verbatim in behaviour from
* PosStaffPanel.submit() — same order, same messages, same PIN and phone
* normalisation — so the comparison is like-for-like. This component is wired
* to no mutation: it is a rendering and typing probe, not a working screen.
*/
import React, { useState } from 'react';
import { Button } from '@astryxdesign/core/Button';
import { TextInput } from '@astryxdesign/core/TextInput';
import { Selector } from '@astryxdesign/core/Selector';
import { Card } from '@astryxdesign/core/Card';
import { Badge } from '@astryxdesign/core/Badge';
import { Banner } from '@astryxdesign/core/Banner';
import { Text } from '@astryxdesign/core/Text';
import { Heading } from '@astryxdesign/core/Heading';
import {
Table,
TableHeader,
TableHeaderCell,
TableRow,
TableCell,
TableBody,
} from '@astryxdesign/core/Table';
type Row = {
pos_user_id: number;
full_name: string;
role: string;
pin: string;
contactno: string;
status: string;
};
/** Stand-in roster. The real panel reads this from usePosStaff(). */
const SAMPLE: Row[] = [
{ pos_user_id: 1, full_name: 'Anitha R', role: 'Supervisor', pin: '4417', contactno: '9840012345', status: 'active' },
{ pos_user_id: 2, full_name: 'Karthik S', role: 'Cashier', pin: '2280', contactno: '9840067890', status: 'active' },
{ pos_user_id: 3, full_name: 'Meena V', role: 'Cashier', pin: '9032', contactno: '9840055512', status: 'inactive' },
];
const ROLE_OPTIONS = [
{ value: 'Supervisor', label: 'Supervisor' },
{ value: 'Cashier', label: 'Cashier' },
];
/** Per-field errors — the shape Astryx's `status` prop wants. */
type Errs = Partial<Record<'name' | 'role' | 'pin' | 'phone', string>>;
export default function PosStaffPanelAstryx() {
const [fullName, setFullName] = useState('');
const [role, setRole] = useState('');
const [pin, setPin] = useState('');
const [phone, setPhone] = useState('');
const [errs, setErrs] = useState<Errs>({});
const [banner, setBanner] = useState('');
const staff = SAMPLE;
/**
* Same rules and same wording as PosStaffPanel.submit(), but each failure is
* attached to the field that caused it instead of being funnelled into one
* `formError` string above the form. That is the substantive difference the
* spike is measuring: today the second error only appears after the first is
* fixed, because there is one slot to show it in.
*/
const submit = () => {
const next: Errs = {};
const name = fullName.trim();
if (!name) next.name = 'Enter the person’s name.';
if (!role) next.role = 'Choose a role.';
if (!/^\d{4}$/.test(pin)) {
next.pin = 'The PIN must be exactly 4 digits.';
} else if (staff.some((s) => s.pin === pin && (s.status || '').toLowerCase() !== 'inactive')) {
next.pin = 'Another active person at this outlet already uses that PIN.';
}
const digits = phone.replace(/\D/g, '').replace(/^91(?=\d{10}$)/, '').replace(/^0(?=\d{10}$)/, '');
if (digits.length !== 10) {
next.phone = 'Enter a 10-digit mobile number.';
} else if (staff.some((s) => (s.contactno || '') === digits && (s.status || '').toLowerCase() !== 'inactive')) {
next.phone = 'Another active person here already signs in with that number.';
}
setErrs(next);
setBanner(Object.keys(next).length ? '' : `Would create ${name} as ${role}. (Spike — no mutation is wired.)`);
};
return (
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', gap: 24 }}>
<div>
<Heading level={2}>POS staff — Astryx spike</Heading>
<Text>
Throwaway rebuild of the create-staff form and roster. Compare against the live panel
at Settings &rsaquo; POS staff.
</Text>
</div>
{/* Banner takes `title`, not children — one of several components here
that are label-prop driven rather than composition driven. */}
{banner && <Banner status="success" title={banner} />}
{/* Q1 probe: max-w-sm is one of the five utilities our unlayered repair
restates. If the repair bleeds past the layer boundary it will squeeze
this card, exactly as it squeezed the modals before the repair. */}
<Card className="max-w-sm">
<div style={{ padding: 16, display: 'flex', flexDirection: 'column', gap: 14 }}>
<Heading level={4}>Add a person</Heading>
<TextInput
label="Full name"
isRequired
value={fullName}
onChange={(v) => setFullName(v)}
placeholder="e.g. Anitha R"
status={errs.name ? { type: 'error', message: errs.name } : undefined}
/>
<Selector
label="Role"
isRequired
options={ROLE_OPTIONS}
value={role}
onChange={(v) => setRole(String(v ?? ''))}
placeholder="Choose a role"
status={errs.role ? { type: 'error', message: errs.role } : undefined}
/>
<TextInput
label="PIN"
isRequired
value={pin}
onChange={(v) => setPin(v)}
placeholder="4 digits"
description="Used to sign in at the till."
status={errs.pin ? { type: 'error', message: errs.pin } : undefined}
/>
<TextInput
label="Mobile number"
isRequired
value={phone}
onChange={(v) => setPhone(v)}
placeholder="10 digits"
status={errs.phone ? { type: 'error', message: errs.phone } : undefined}
/>
<div style={{ display: 'flex', gap: 8 }}>
<Button label="Create" onClick={submit} />
<Button
label="Clear"
variant="secondary"
onClick={() => {
setFullName('');
setRole('');
setPin('');
setPhone('');
setErrs({});
setBanner('');
}}
/>
</div>
</div>
</Card>
<div>
<Heading level={4}>Roster</Heading>
<Table>
<TableHeader>
<TableRow>
<TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>Role</TableHeaderCell>
<TableHeaderCell>PIN</TableHeaderCell>
<TableHeaderCell>Mobile</TableHeaderCell>
<TableHeaderCell>Status</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{staff.map((s) => (
<TableRow key={s.pos_user_id}>
<TableCell>{s.full_name}</TableCell>
<TableCell>{s.role}</TableCell>
<TableCell>{s.pin}</TableCell>
<TableCell>{s.contactno}</TableCell>
<TableCell>
<Badge label={s.status === 'inactive' ? 'Inactive' : 'Active'} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,30 @@
/* ── PHASE 1 SPIKE — Astryx ─────────────────────────────────────────────────
Experimental. Revert by restoring `@import "tailwindcss";` in place of the
block below and dropping the three @astryxdesign/@stylexjs dependencies.
The layer order and the split of `@import "tailwindcss"` into its three
parts are both prescribed verbatim by the Usage block at the top of
@astryxdesign/core/src/tailwind-theme.css — this is not our invention. The
ordering matters: `utilities` sits last, so Tailwind utilities keep winning
over `astryx-base`, which is what makes a half-migrated file behave.
Note `astryx.css` loads BEFORE the theme package: the theme supplies the
token values that the component CSS references. */
@layer reset, theme, base, astryx-base, astryx-theme, components, utilities;
@import url('https://fonts.googleapis.com/css2?family=Public+Sans:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;700&display=swap');
@import "tailwindcss";
@import "tailwindcss/theme.css" layer(theme);
@import "tailwindcss/preflight.css" layer(base);
@import "@astryxdesign/core/reset.css";
@import "@astryxdesign/core/astryx.css";
/* Our own theme, compiled by `astryx theme build ./src/theme/nearle.ts`.
Regenerate with that command after editing nearle.ts — nearle.css is
generated and must not be hand-edited. theme-neutral was scaffolding for
the Phase 1 spike and is no longer imported. */
@import "./theme/nearle.css";
@import "@astryxdesign/core/tailwind-theme.css";
@import "tailwindcss/utilities.css" layer(utilities);
@theme {
--font-sans: "Public Sans", ui-sans-serif, system-ui, sans-serif;

View File

@@ -34,15 +34,47 @@ const queryClient = new QueryClient({
});
import { BrowserRouter } from 'react-router-dom';
import { Theme } from '@astryxdesign/core';
import { ToastViewport } from '@astryxdesign/core/Toast';
import { nearleTheme } from './theme/nearle';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<CompareProvider>
<BrowserRouter>
<App />
</BrowserRouter>
</CompareProvider>
</QueryClientProvider>
{/*
Theme has to wrap the app, not sit beside it: it stamps the
`data-astryx-theme` / `data-theme` attributes that every token in
nearle.css is scoped to. Without it the stylesheet loads and applies to
nothing.
mode is deliberately "light", NOT "system", and it must stay that way
until the migration finishes.
The theme carries a full dark palette and `mode="system"` does work — but
only Astryx-backed components respond to it. Everything still on raw
Tailwind is pinned to the light scheme by hardcoded classes: KpiStrip
renders `bg-white text-slate-900`, SearchPill sets `color: TEXT`
(#0f172a). Switching to "system" today means any user whose OS is dark
gets white cards on a dark page and a search box with black text on a
black field. Verified in the browser, not assumed.
Flip this to "system" once Phase 6 has removed the hardcoded colours.
That single word is the dark-mode launch switch.
Outermost so nothing above it renders unthemed, but inside StrictMode so
it gets the same double-render checks as everything else.
*/}
<Theme theme={nearleTheme} mode="light">
{/* One viewport for the whole app. useToast() anywhere below this renders
into it — see components/Toasts.tsx. */}
<ToastViewport position="topEnd">
<QueryClientProvider client={queryClient}>
<CompareProvider>
<BrowserRouter>
<App />
</BrowserRouter>
</CompareProvider>
</QueryClientProvider>
</ToastViewport>
</Theme>
</StrictMode>,
);

View File

@@ -42,7 +42,10 @@ export function buildStoreQrPayload(opts: { tenantid: number; locationid: number
return JSON.stringify({ tenantid: opts.tenantid, locationid: opts.locationid });
}
async function fiestaGet<T = unknown>(endpoint: string, params: QueryParams = {}): Promise<T> {
// Exported so ad-hoc probes (test-api.ts) can hit an endpoint that has no
// wrapper yet. Every function below is the supported way in; reach for this
// only when checking something against the live API.
export async function fiestaGet<T = unknown>(endpoint: string, params: QueryParams = {}): Promise<T> {
const qs = new URLSearchParams();
Object.entries(params).forEach(([k, v]) => {
// Fiesta requires some params to be present-but-empty (e.g. keyword=), so we

132
src/theme/nearle.css Normal file
View File

@@ -0,0 +1,132 @@
/*
* @generated by `astryx theme build` — do not edit manually.
* Source: src\theme\nearle.ts
* Command: astryx theme build src\theme\nearle.ts
* CLI: @astryxdesign/cli@0.3.0
* Core: @astryxdesign/core@0.3.0
*/
@layer reset {
@scope ([data-astryx-theme="nearle"]) to ([data-astryx-theme]) {
:where(h1, h2, h3, h4, h5, h6) {
font-family: var(--font-family-heading);
color: var(--color-text-primary);
}
:where(h1) {
font-size: var(--text-heading-1-size);
font-weight: var(--text-heading-1-weight);
line-height: var(--text-heading-1-leading);
}
:where(h2) {
font-size: var(--text-heading-2-size);
font-weight: var(--text-heading-2-weight);
line-height: var(--text-heading-2-leading);
}
:where(h3) {
font-size: var(--text-heading-3-size);
font-weight: var(--text-heading-3-weight);
line-height: var(--text-heading-3-leading);
}
:where(h4) {
font-size: var(--text-heading-4-size);
font-weight: var(--text-heading-4-weight);
line-height: var(--text-heading-4-leading);
}
:where(h5) {
font-size: var(--text-heading-5-size);
font-weight: var(--text-heading-5-weight);
line-height: var(--text-heading-5-leading);
}
:where(h6) {
font-size: var(--text-heading-6-size);
font-weight: var(--text-heading-6-weight);
line-height: var(--text-heading-6-leading);
}
:where(p) {
font-family: var(--font-family-body);
font-size: var(--text-body-size);
font-weight: var(--text-body-weight);
line-height: var(--text-body-leading);
color: var(--color-text-primary);
}
:where(small) {
font-size: var(--text-supporting-size);
font-weight: var(--text-supporting-weight);
line-height: var(--text-supporting-leading);
color: var(--color-text-secondary);
}
:where(code, pre) {
font-family: var(--font-family-code);
font-size: var(--text-code-size);
line-height: var(--text-code-leading);
}
:where(hr) {
border: none;
border-top: 1px solid var(--color-border);
}
}
}
@layer astryx-theme {
:root { color-scheme: light dark; }
html[data-theme="light"] { color-scheme: light; }
html[data-theme="dark"] { color-scheme: dark; }
@scope ([data-astryx-theme="nearle"]) to ([data-astryx-theme]) {
:scope {
--font-family-body: "Public Sans", ui-sans-serif, system-ui, sans-serif;
--font-family-heading: "Public Sans", ui-sans-serif, system-ui, sans-serif;
--font-family-code: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
--color-accent: light-dark(#662582, #B57FD0);
--color-on-accent: light-dark(#FFFFFF, #1B1420);
--color-accent-muted: light-dark(#66258218, #B57FD024);
--color-text-accent: light-dark(#662582, #B57FD0);
--color-icon-accent: light-dark(#662582, #B57FD0);
--color-text-primary: light-dark(#0f172a, #ECE6F1);
--color-text-secondary: light-dark(#64748b, #B3A8BD);
--color-text-disabled: light-dark(#94a3b8, #85798F);
--color-background-surface: light-dark(#FFFFFF, #1C1724);
--color-background-body: light-dark(#f8fafc, #14101A);
--color-background-card: light-dark(#FFFFFF, #1C1724);
--color-background-popover: light-dark(#FFFFFF, #241D2E);
--color-background-muted: light-dark(#f1f5f9, #241D2E);
--color-border: light-dark(#e2e8f0, #352C40);
--color-border-emphasized: light-dark(#cbd5e1, #4A3E58);
--color-skeleton: light-dark(#f1f5f9, #241D2E);
--color-success: light-dark(#10b981, #34D399);
--color-error: light-dark(#ef4444, #F87171);
--color-warning: light-dark(#f59e0b, #FBBF24);
--shadow-low: light-dark(0 8px 24px rgba(15, 23, 42, 0.08), 0 8px 24px rgba(0, 0, 0, 0.45));
--shadow-med: light-dark(0 14px 40px rgba(15, 23, 42, 0.10), 0 14px 40px rgba(0, 0, 0, 0.55));
--shadow-high: light-dark(0 18px 50px rgba(15, 23, 42, 0.18), 0 18px 50px rgba(0, 0, 0, 0.65));
}
}
}
@layer astryx-theme {
@scope ([data-astryx-theme="nearle"]) to ([data-astryx-theme]) {
[data-astryx-media="dark"] {
color-scheme: dark;
--color-text-primary: var(--color-on-dark);
--color-icon-primary: var(--color-on-dark);
--color-accent: var(--color-on-dark);
}
[data-astryx-media="light"] {
color-scheme: light;
--color-text-primary: var(--color-on-light);
--color-icon-primary: var(--color-on-light);
--color-accent: var(--color-on-light);
}
}
}

10
src/theme/nearle.d.ts vendored Normal file
View File

@@ -0,0 +1,10 @@
/**
* @generated by `astryx theme build` — do not edit manually.
* Source: src\theme\nearle.ts
* Command: astryx theme build src\theme\nearle.ts
* CLI: @astryxdesign/cli@0.3.0
* Core: @astryxdesign/core@0.3.0
*/
import type { DefinedTheme } from '@astryxdesign/core/theme';
export declare const nearleTheme: DefinedTheme;

47
src/theme/nearle.js Normal file
View File

@@ -0,0 +1,47 @@
/**
* @generated by `astryx theme build` — do not edit manually.
* Source: src\theme\nearle.ts
* Command: astryx theme build src\theme\nearle.ts
* CLI: @astryxdesign/cli@0.3.0
* Core: @astryxdesign/core@0.3.0
*/
/**
* nearle theme — built by `npx astryx theme build`
* Import the CSS file alongside this module:
*
* import { nearleTheme } from './nearle';
* import './nearle.css';
*/
export const nearleTheme = {
name: 'nearle',
__built: true,
tokens: {
"--font-family-body": "\"Public Sans\", ui-sans-serif, system-ui, sans-serif",
"--font-family-heading": "\"Public Sans\", ui-sans-serif, system-ui, sans-serif",
"--font-family-code": "\"JetBrains Mono\", ui-monospace, SFMono-Regular, monospace",
"--color-accent": "light-dark(#662582, #B57FD0)",
"--color-on-accent": "light-dark(#FFFFFF, #1B1420)",
"--color-accent-muted": "light-dark(#66258218, #B57FD024)",
"--color-text-accent": "light-dark(#662582, #B57FD0)",
"--color-icon-accent": "light-dark(#662582, #B57FD0)",
"--color-text-primary": "light-dark(#0f172a, #ECE6F1)",
"--color-text-secondary": "light-dark(#64748b, #B3A8BD)",
"--color-text-disabled": "light-dark(#94a3b8, #85798F)",
"--color-background-surface": "light-dark(#FFFFFF, #1C1724)",
"--color-background-body": "light-dark(#f8fafc, #14101A)",
"--color-background-card": "light-dark(#FFFFFF, #1C1724)",
"--color-background-popover": "light-dark(#FFFFFF, #241D2E)",
"--color-background-muted": "light-dark(#f1f5f9, #241D2E)",
"--color-border": "light-dark(#e2e8f0, #352C40)",
"--color-border-emphasized": "light-dark(#cbd5e1, #4A3E58)",
"--color-skeleton": "light-dark(#f1f5f9, #241D2E)",
"--color-success": "light-dark(#10b981, #34D399)",
"--color-error": "light-dark(#ef4444, #F87171)",
"--color-warning": "light-dark(#f59e0b, #FBBF24)",
"--shadow-low": "light-dark(0 8px 24px rgba(15, 23, 42, 0.08), 0 8px 24px rgba(0, 0, 0, 0.45))",
"--shadow-med": "light-dark(0 14px 40px rgba(15, 23, 42, 0.10), 0 14px 40px rgba(0, 0, 0, 0.55))",
"--shadow-high": "light-dark(0 18px 50px rgba(15, 23, 42, 0.18), 0 18px 50px rgba(0, 0, 0, 0.65))"
},
};

102
src/theme/nearle.ts Normal file
View File

@@ -0,0 +1,102 @@
/**
* The Nearle theme for Astryx — PHASE 2.
*
* Every value here already existed in the codebase; nothing was invented for
* aesthetics. The light column is lifted verbatim from the eleven hardcoded
* constants at the top of `components/consoleUi.tsx` and from the two status
* colour maps beneath them. The dark column is new, because the console has
* never had a dark mode and there was nothing to lift.
*
* Written by hand rather than by `astryx theme`: that CLI command is an
* interactive wizard, and a generated file would still have needed every value
* below typed into it.
*
* TOKEN VALUES take one of two forms (see defineTheme's contract):
* 'string' — same in both modes
* ['light', 'dark'] — compiled to CSS light-dark()
*
* HOW MODE IS CHOSEN. `theme-neutral` ships no `prefers-color-scheme` block at
* all; Astryx switches on a `data-theme` attribute that the `<Theme>` provider
* stamps. So `mode="system"` on the provider is what connects this to the OS
* setting — the stylesheet alone will not do it.
*/
import { defineTheme } from '@astryxdesign/core';
/**
* The dark accent is the one value in this file that is not simply lifted, and
* it is the one that needs a human's approval.
*
* `BRAND` #662582 scores 9.76:1 on white — comfortably AA. On Astryx's dark
* surface #1F1F22 it is unreadable, and `BRAND_LIGHT` #9255AB only reaches
* **3.22:1**, which is below the 4.5:1 threshold. Measured candidates:
*
* #9255AB 3.22:1 BRAND_LIGHT as-is fails AA
* #A96FC4 4.51:1 passes by 0.01 no headroom
* #B57FD0 5.41:1 passes with room ← chosen
* #C48FDD 6.51:1 passes, drifts pastel
*
* #B57FD0 is the lightest value that still reads as the Nearle purple rather
* than as lilac. **Brand owner to confirm before dark mode ships.**
*/
const BRAND_DARK = '#B57FD0';
export const nearleTheme = defineTheme({
name: 'nearle',
typography: {
// Both families are already pulled in by the Google Fonts @import at the
// top of index.css, so this re-points Astryx at them rather than adding a
// request. Astryx does not load fonts itself — the consumer must.
body: { family: 'Public Sans', fallbacks: 'ui-sans-serif, system-ui, sans-serif' },
heading: { family: 'Public Sans', fallbacks: 'ui-sans-serif, system-ui, sans-serif' },
code: { family: 'JetBrains Mono', fallbacks: 'ui-monospace, SFMono-Regular, monospace' },
},
tokens: {
// ── Brand ───────────────────────────────────────────────────────────────
'--color-accent': ['#662582', BRAND_DARK],
'--color-on-accent': ['#FFFFFF', '#1B1420'],
// The alpha suffixes mirror consoleUi's `soft()` helper (18 ≈ 9%), which is
// what every tinted brand surface in the app is currently built from.
'--color-accent-muted': ['#66258218', `${BRAND_DARK}24`],
'--color-text-accent': ['#662582', BRAND_DARK],
'--color-icon-accent': ['#662582', BRAND_DARK],
// ── Text ── TEXT / TEXT_2 / TEXT_3 ──────────────────────────────────────
'--color-text-primary': ['#0f172a', '#ECE6F1'],
'--color-text-secondary': ['#64748b', '#B3A8BD'],
'--color-text-disabled': ['#94a3b8', '#85798F'],
// ── Surfaces ── SURFACE_ALT / DIVIDER ───────────────────────────────────
// The dark greys carry a slight violet bias so they sit under the accent
// as a chosen neutral rather than a stock grey.
'--color-background-surface': ['#FFFFFF', '#1C1724'],
'--color-background-body': ['#f8fafc', '#14101A'],
'--color-background-card': ['#FFFFFF', '#1C1724'],
'--color-background-popover': ['#FFFFFF', '#241D2E'],
'--color-background-muted': ['#f1f5f9', '#241D2E'],
// ── Lines ── BORDER / DIVIDER ───────────────────────────────────────────
'--color-border': ['#e2e8f0', '#352C40'],
'--color-border-emphasized': ['#cbd5e1', '#4A3E58'],
'--color-skeleton': ['#f1f5f9', '#241D2E'],
// ── Semantic ────────────────────────────────────────────────────────────
// Taken from ORDER_STATUS / DELIVERY_STATUS so a Badge and a status pill
// agree. Dark variants are lightened for the same contrast reason as the
// accent — the source values are tuned for white.
'--color-success': ['#10b981', '#34D399'],
'--color-error': ['#ef4444', '#F87171'],
'--color-warning': ['#f59e0b', '#FBBF24'],
// ── Elevation ── SHADOW_MD / SOFT / POP ─────────────────────────────────
// Dark mode gets its own shadows rather than the same rgba: a shadow tuned
// for a white ground is invisible on a dark one, so the alpha is raised.
'--shadow-low': ['0 8px 24px rgba(15, 23, 42, 0.08)', '0 8px 24px rgba(0, 0, 0, 0.45)'],
'--shadow-med': ['0 14px 40px rgba(15, 23, 42, 0.10)', '0 14px 40px rgba(0, 0, 0, 0.55)'],
'--shadow-high': ['0 18px 50px rgba(15, 23, 42, 0.18)', '0 18px 50px rgba(0, 0, 0, 0.65)'],
},
});
export default nearleTheme;