page wise astryx conversion completed

This commit is contained in:
2026-08-14 12:33:42 +05:30
parent 0daec5311d
commit cd079950eb
18 changed files with 1154 additions and 696 deletions

View File

@@ -13,8 +13,9 @@
* Nominatim usage policy: light, debounced, one request per keystroke-pause. * Nominatim usage policy: light, debounced, one request per keystroke-pause.
*/ */
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useId, useRef, useState } from 'react';
import { MapPin, Loader2, Search } from 'lucide-react'; import { MapPin, Loader2, Search } from 'lucide-react';
import { TextInput } from '@astryxdesign/core/TextInput';
export interface AddressResult { export interface AddressResult {
address: string; address: string;
@@ -62,6 +63,9 @@ export default function AddressAutocomplete({
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [highlight, setHighlight] = useState(-1); const [highlight, setHighlight] = useState(-1);
const boxRef = useRef<HTMLDivElement>(null); const boxRef = useRef<HTMLDivElement>(null);
const listId = useId();
/** Set when `query` is changed by us rather than by typing (see the effect). */
const skipSearchRef = useRef(false);
// Keep the input in sync if the parent resets the value (e.g. after submit). // Keep the input in sync if the parent resets the value (e.g. after submit).
useEffect(() => { setQuery(value); }, [value]); useEffect(() => { setQuery(value); }, [value]);
@@ -69,6 +73,13 @@ export default function AddressAutocomplete({
// Debounced Nominatim lookup. // Debounced Nominatim lookup.
useEffect(() => { useEffect(() => {
const q = query.trim(); const q = query.trim();
// Picking a suggestion writes the chosen address back into `query`, which
// lands here as if the user had typed it — so the list re-opened over the
// answer they had just chosen, and fired another Nominatim call to do it.
// Pre-existing, and invisible enough to live with while the open state was
// only a background colour; now that it is `aria-expanded`, it would be
// telling a screen reader the list is open every time it closes.
if (skipSearchRef.current) { skipSearchRef.current = false; return; }
if (q.length < 3) { setOptions([]); setOpen(false); return; } if (q.length < 3) { setOptions([]); setOpen(false); return; }
let active = true; let active = true;
setLoading(true); setLoading(true);
@@ -100,6 +111,7 @@ export default function AddressAutocomplete({
const pick = (row: NominatimRow) => { const pick = (row: NominatimRow) => {
const result = parseRow(row); const result = parseRow(row);
skipSearchRef.current = true;
setQuery(result.address); setQuery(result.address);
setOptions([]); setOptions([]);
setOpen(false); setOpen(false);
@@ -116,27 +128,56 @@ export default function AddressAutocomplete({
return ( return (
<div className="relative" ref={boxRef}> <div className="relative" ref={boxRef}>
<span className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-slate-400"> {/*
<Search size={14} /> PHASE 6 — this was a combobox in behaviour with none of it declared.
</span> The input carried no label and no role; the suggestion list was a plain
<input <ul> of <button>s. Arrow keys moved a purple highlight that existed only
type="text" as a background colour, so a screen reader user typed an address, was
told nothing had happened, and had no way to know six suggestions had
appeared beneath them.
It is now the real pattern: the input is a combobox owning a listbox,
each row is an option, and `aria-activedescendant` follows the same
highlight the arrow keys already moved. The rows stop being <button>s
because an option must not contain one — keyboard interaction belongs
to the input, which is where it already was.
NOT Astryx's Typeahead, which is otherwise the exact component: it holds
a selected *item*, and all three call sites pre-fill `value` with a saved
address string when editing an existing store (AdminConsole 157/170).
Under Typeahead that address would render as an empty box.
*/}
<TextInput
label="Address"
isLabelHidden
value={query} value={query}
placeholder={placeholder} placeholder={placeholder}
autoComplete="off" startIcon={<Search size={14} />}
onChange={(e) => { setQuery(e.target.value); if (!e.target.value) onSelect(null); }} onChange={(v) => { setQuery(v); if (!v) onSelect(null); }}
onFocus={() => { if (options.length) setOpen(true); }} onFocus={() => { if (options.length) setOpen(true); }}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
className="w-full border border-slate-200 rounded-xl pl-10 pr-9 py-3 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 focus:ring-4 focus:ring-purple-500/10 transition-all text-slate-800 font-semibold text-sm shadow-sm" role="combobox"
aria-expanded={open && options.length > 0}
aria-controls={listId}
aria-autocomplete="list"
aria-activedescendant={open && highlight >= 0 ? `${listId}-${highlight}` : undefined}
className="w-full border border-slate-200 rounded-xl bg-slate-50/40 hover:bg-slate-100 text-slate-800 font-semibold text-sm shadow-sm"
/> />
{loading && <span className="absolute inset-y-0 right-0 pr-3 flex items-center text-slate-400"><Loader2 size={14} className="animate-spin" /></span>} {loading && <span className="absolute inset-y-0 right-0 pr-3 flex items-center text-slate-400"><Loader2 size={14} className="animate-spin" /></span>}
{open && options.length > 0 && ( {open && options.length > 0 && (
<ul className="absolute z-20 mt-1 w-full bg-white border border-slate-200 rounded-xl shadow-lg max-h-60 overflow-y-auto py-1"> <ul
id={listId}
role="listbox"
aria-label="Address suggestions"
className="absolute z-20 mt-1 w-full bg-white border border-slate-200 rounded-xl shadow-lg max-h-60 overflow-y-auto py-1"
>
{options.map((o, i) => ( {options.map((o, i) => (
<li key={`${o.lat},${o.lon},${i}`}> <li
<button key={`${o.lat},${o.lon},${i}`}
type="button" id={`${listId}-${i}`}
role="option"
aria-selected={highlight === i}
onMouseEnter={() => setHighlight(i)} onMouseEnter={() => setHighlight(i)}
onClick={() => pick(o)} onClick={() => pick(o)}
className={`w-full text-left px-3 py-2 flex items-start gap-2 cursor-pointer transition-colors ${ className={`w-full text-left px-3 py-2 flex items-start gap-2 cursor-pointer transition-colors ${
@@ -145,7 +186,6 @@ export default function AddressAutocomplete({
> >
<MapPin size={13} className="text-purple-500 shrink-0 mt-0.5" /> <MapPin size={13} className="text-purple-500 shrink-0 mt-0.5" />
<span className="text-[12.5px] text-slate-700 leading-snug">{o.display_name}</span> <span className="text-[12.5px] text-slate-700 leading-snug">{o.display_name}</span>
</button>
</li> </li>
))} ))}
</ul> </ul>

View File

@@ -19,6 +19,8 @@ import { CardCarousel } from './CatalogueGallery';
// lands on two logins at once. // lands on two logins at once.
import { TextInput } from '@astryxdesign/core/TextInput'; import { TextInput } from '@astryxdesign/core/TextInput';
import { CheckboxInput } from '@astryxdesign/core/CheckboxInput'; import { CheckboxInput } from '@astryxdesign/core/CheckboxInput';
import { Button } from '@astryxdesign/core/Button';
import { IconButton } from '@astryxdesign/core/IconButton';
interface CatalogueBrowserProps { interface CatalogueBrowserProps {
tenantid: number; tenantid: number;
@@ -158,12 +160,12 @@ export default function CatalogueBrowser({ tenantid, locationid, onClose }: Cata
</div> </div>
{(brand || keyword) && ( {(brand || keyword) && (
<button <Button
label="Clear All Filters"
onClick={() => { setBrand(undefined); setKeyword(''); }} onClick={() => { setBrand(undefined); setKeyword(''); }}
style={{ height: 'auto' }}
className="w-full py-2 bg-slate-100 text-slate-600 rounded-none text-[10px] font-bold uppercase tracking-wider hover:bg-slate-200 transition-colors cursor-pointer border-none mt-4" className="w-full py-2 bg-slate-100 text-slate-600 rounded-none text-[10px] font-bold uppercase tracking-wider hover:bg-slate-200 transition-colors cursor-pointer border-none mt-4"
> />
Clear All Filters
</button>
)} )}
</div> </div>
</div> </div>
@@ -175,21 +177,28 @@ export default function CatalogueBrowser({ tenantid, locationid, onClose }: Cata
<div className={`flex justify-between gap-4 mb-5 shrink-0 ${isLocalSidebarOpen ? 'flex-col lg:flex-row lg:items-center items-start' : 'flex-col lg:flex-row lg:items-center items-start'}`}> <div className={`flex justify-between gap-4 mb-5 shrink-0 ${isLocalSidebarOpen ? 'flex-col lg:flex-row lg:items-center items-start' : 'flex-col lg:flex-row lg:items-center items-start'}`}>
<div className="flex-1 min-w-0 pr-4 flex items-start gap-2"> <div className="flex-1 min-w-0 pr-4 flex items-start gap-2">
{onClose && ( {onClose && (
<button <IconButton
icon={<ArrowLeft size={16} />}
label="Back to Products"
tooltip="Back to Products"
variant="ghost"
onClick={onClose} onClick={onClose}
style={{ height: 'auto', width: 'auto', minWidth: 0 }}
className="p-1.5 rounded-lg border border-slate-200 text-slate-500 hover:bg-slate-100 hover:text-[#662582] transition-colors mt-0.5 cursor-pointer shrink-0" className="p-1.5 rounded-lg border border-slate-200 text-slate-500 hover:bg-slate-100 hover:text-[#662582] transition-colors mt-0.5 cursor-pointer shrink-0"
title="Back to Products" />
>
<ArrowLeft size={16} />
</button>
)} )}
<button {/* Was a `title`-only toggle with no pressed state — the filter
rail's open/closed status was visible but never announced. */}
<IconButton
icon={<Filter size={16} />}
label={isLocalSidebarOpen ? 'Hide filters' : 'Show filters'}
tooltip="Toggle Filters"
variant="ghost"
aria-expanded={isLocalSidebarOpen}
onClick={() => setIsLocalSidebarOpen(!isLocalSidebarOpen)} onClick={() => setIsLocalSidebarOpen(!isLocalSidebarOpen)}
style={{ height: 'auto', width: 'auto', minWidth: 0 }}
className="p-1.5 rounded-lg border border-slate-200 text-slate-500 hover:bg-slate-100 hover:text-[#662582] transition-colors mt-0.5 cursor-pointer shrink-0" className="p-1.5 rounded-lg border border-slate-200 text-slate-500 hover:bg-slate-100 hover:text-[#662582] transition-colors mt-0.5 cursor-pointer shrink-0"
title="Toggle Filters" />
>
<Filter size={16} />
</button>
<div> <div>
<h3 className="font-sans font-bold text-sm text-[#0f172a] flex items-center gap-1.5 flex-wrap"> <h3 className="font-sans font-bold text-sm text-[#0f172a] flex items-center gap-1.5 flex-wrap">
<Box size={15} className="text-[#662582] shrink-0" /> <span className="truncate">Global Product Catalogue</span> <Box size={15} className="text-[#662582] shrink-0" /> <span className="truncate">Global Product Catalogue</span>
@@ -263,15 +272,24 @@ export default function CatalogueBrowser({ tenantid, locationid, onClose }: Cata
</div> </div>
{isImported ? ( {isImported ? (
<button <Button
label={`${p.product_name} is already imported — open its details`}
icon={<CheckCircle2 size={12} />}
onClick={(e) => { e.stopPropagation(); setImportingProduct(p); }} onClick={(e) => { e.stopPropagation(); setImportingProduct(p); }}
style={{ height: 'auto' }}
className="w-full bg-emerald-50 text-emerald-700 border border-emerald-200 px-3 py-2 rounded-none text-[10px] font-bold uppercase tracking-wider transition-all shadow-sm flex items-center justify-center gap-1.5 cursor-pointer" className="w-full bg-emerald-50 text-emerald-700 border border-emerald-200 px-3 py-2 rounded-none text-[10px] font-bold uppercase tracking-wider transition-all shadow-sm flex items-center justify-center gap-1.5 cursor-pointer"
> >
<CheckCircle2 size={12} />
Imported Imported
</button> </Button>
) : ( ) : (
<button <Button
// Every card repeated the same two words, so the
// grid read as "Import Product" N times over with
// no way to tell which product was about to be
// imported. The label names it; the visible text
// is unchanged.
label={`Import ${p.product_name}`}
icon={<DownloadCloud size={12} />}
// One click, straight into the admin catalogue. // One click, straight into the admin catalogue.
// //
// No price, no category prompt: importing does not // No price, no category prompt: importing does not
@@ -301,11 +319,11 @@ export default function CatalogueBrowser({ tenantid, locationid, onClose }: Cata
taxpercent: 0, taxpercent: 0,
}); });
}} }}
style={{ height: 'auto' }}
className="w-full bg-[#f8fafc] hover:bg-[#662582] text-slate-600 hover:text-white border border-slate-200 hover:border-[#662582] px-3 py-2 rounded-none text-[10px] font-bold uppercase tracking-wider transition-all shadow-sm flex items-center justify-center gap-1.5 cursor-pointer" className="w-full bg-[#f8fafc] hover:bg-[#662582] text-slate-600 hover:text-white border border-slate-200 hover:border-[#662582] px-3 py-2 rounded-none text-[10px] font-bold uppercase tracking-wider transition-all shadow-sm flex items-center justify-center gap-1.5 cursor-pointer"
> >
<DownloadCloud size={12} />
Import Product Import Product
</button> </Button>
)} )}
</div> </div>
</div> </div>

View File

@@ -19,6 +19,7 @@
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { Box, ChevronLeft, ChevronRight, X } from 'lucide-react'; import { Box, ChevronLeft, ChevronRight, X } from 'lucide-react';
import { IconButton } from '@astryxdesign/core/IconButton';
/** /**
* The image on a browse card, with the rest reachable. * The image on a browse card, with the rest reachable.
@@ -62,20 +63,22 @@ export function CardCarousel({
{/* Faint at rest and solid on hover rather than hidden until hover: {/* Faint at rest and solid on hover rather than hidden until hover:
a touch device never hovers, and controls that only exist on a a touch device never hovers, and controls that only exist on a
mouse are controls half the users never find. */} mouse are controls half the users never find. */}
<button <IconButton
icon={<ChevronLeft size={15} />}
label={`Previous image of ${alt}`}
variant="ghost"
onClick={(e) => step(e, -1)} onClick={(e) => step(e, -1)}
aria-label="Previous image" style={{ height: 28, width: 28, minWidth: 0 }}
className="absolute left-1 top-1/2 -translate-y-1/2 z-10 h-7 w-7 rounded-full bg-white/70 hover:bg-white text-slate-700 flex items-center justify-center shadow-sm opacity-40 group-hover:opacity-100 transition-opacity cursor-pointer" className="absolute left-1 top-1/2 -translate-y-1/2 z-10 h-7 w-7 rounded-full bg-white/70 hover:bg-white text-slate-700 flex items-center justify-center shadow-sm opacity-40 group-hover:opacity-100 transition-opacity cursor-pointer"
> />
<ChevronLeft size={15} /> <IconButton
</button> icon={<ChevronRight size={15} />}
<button label={`Next image of ${alt}`}
variant="ghost"
onClick={(e) => step(e, 1)} onClick={(e) => step(e, 1)}
aria-label="Next image" style={{ height: 28, width: 28, minWidth: 0 }}
className="absolute right-1 top-1/2 -translate-y-1/2 z-10 h-7 w-7 rounded-full bg-white/70 hover:bg-white text-slate-700 flex items-center justify-center shadow-sm opacity-40 group-hover:opacity-100 transition-opacity cursor-pointer" className="absolute right-1 top-1/2 -translate-y-1/2 z-10 h-7 w-7 rounded-full bg-white/70 hover:bg-white text-slate-700 flex items-center justify-center shadow-sm opacity-40 group-hover:opacity-100 transition-opacity cursor-pointer"
> />
<ChevronRight size={15} />
</button>
<div className="absolute bottom-1.5 left-0 right-0 z-10 flex items-center justify-center gap-1 pointer-events-none"> <div className="absolute bottom-1.5 left-0 right-0 z-10 flex items-center justify-center gap-1 pointer-events-none">
{list.slice(0, 8).map((_, n) => ( {list.slice(0, 8).map((_, n) => (
@@ -157,13 +160,14 @@ export function Lightbox({
<div className="fixed inset-0 z-[400] flex items-center justify-center p-4"> <div className="fixed inset-0 z-[400] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-slate-950/85" onClick={onClose} /> <div className="absolute inset-0 bg-slate-950/85" onClick={onClose} />
<button <IconButton
icon={<X size={18} />}
label="Close image viewer"
variant="ghost"
onClick={onClose} onClick={onClose}
aria-label="Close" style={{ height: 36, width: 36, minWidth: 0 }}
className="absolute top-4 right-4 z-10 h-9 w-9 rounded-full bg-white/10 hover:bg-white/20 text-white flex items-center justify-center cursor-pointer" className="absolute top-4 right-4 z-10 h-9 w-9 rounded-full bg-white/10 hover:bg-white/20 text-white flex items-center justify-center cursor-pointer"
> />
<X size={18} />
</button>
<div className="relative z-10 flex flex-col items-center gap-3 max-w-full"> <div className="relative z-10 flex flex-col items-center gap-3 max-w-full">
<img <img
@@ -175,21 +179,23 @@ export function Lightbox({
{images.length > 1 && ( {images.length > 1 && (
<> <>
<div className="flex items-center gap-3 text-white/80 text-[12px] font-semibold"> <div className="flex items-center gap-3 text-white/80 text-[12px] font-semibold">
<button <IconButton
icon={<ChevronLeft size={16} />}
label="Previous image"
variant="ghost"
onClick={() => move(-1)} onClick={() => move(-1)}
aria-label="Previous image" style={{ height: 32, width: 32, minWidth: 0 }}
className="h-8 w-8 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center cursor-pointer" className="h-8 w-8 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center cursor-pointer"
> />
<ChevronLeft size={16} />
</button>
{index + 1} of {images.length} {index + 1} of {images.length}
<button <IconButton
icon={<ChevronRight size={16} />}
label="Next image"
variant="ghost"
onClick={() => move(1)} onClick={() => move(1)}
aria-label="Next image" style={{ height: 32, width: 32, minWidth: 0 }}
className="h-8 w-8 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center cursor-pointer" className="h-8 w-8 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center cursor-pointer"
> />
<ChevronRight size={16} />
</button>
</div> </div>
<div className="flex gap-2 overflow-x-auto max-w-[90vw] pb-1"> <div className="flex gap-2 overflow-x-auto max-w-[90vw] pb-1">

View File

@@ -1,6 +1,8 @@
import React from 'react'; import React from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { X, CheckCircle2, ArrowRight } from 'lucide-react'; import { X, CheckCircle2, ArrowRight } from 'lucide-react';
import { Button } from '@astryxdesign/core/Button';
import { IconButton } from '@astryxdesign/core/IconButton';
import { useCompare } from '../contexts/CompareContext'; import { useCompare } from '../contexts/CompareContext';
export default function ComparisonModal() { export default function ComparisonModal() {
@@ -27,18 +29,24 @@ export default function ComparisonModal() {
</div> </div>
<div className="flex items-center gap-3 pl-2"> <div className="flex items-center gap-3 pl-2">
<button <Button
label="Clear selected products"
variant="ghost"
onClick={clearSelection} onClick={clearSelection}
className="text-xs font-bold text-slate-500 hover:text-slate-800 transition-colors" style={{ height: 'auto' }}
className="text-xs font-bold text-slate-500 hover:text-slate-800 bg-transparent transition-colors"
> >
Clear Clear
</button> </Button>
<button <Button
label={`Compare ${selectedProducts.length} selected products`}
endContent={<ArrowRight size={14} />}
onClick={() => setIsComparing(true)} onClick={() => setIsComparing(true)}
className="bg-[#662582] hover:bg-purple-800 text-white px-5 py-2 rounded-full font-bold text-sm shadow-md transition-colors flex items-center gap-2" style={{ height: 'auto' }}
className="bg-[#662582] hover:bg-purple-800 text-white px-5 py-2 rounded-full font-bold text-sm border-none shadow-md transition-colors flex items-center gap-2"
> >
Compare Now <ArrowRight size={14} /> Compare Now
</button> </Button>
</div> </div>
</div> </div>
)} )}
@@ -58,25 +66,33 @@ export default function ComparisonModal() {
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{selectedProducts.length < 5 && ( {selectedProducts.length < 5 && (
<button <Button
label="Add another product to the comparison"
onClick={() => setIsComparing(false)} onClick={() => setIsComparing(false)}
style={{ height: 'auto' }}
className="text-xs font-bold text-[#662582] hover:bg-purple-50 bg-white px-3 py-1.5 rounded-lg border border-purple-200 shadow-sm transition-colors flex items-center gap-1.5" className="text-xs font-bold text-[#662582] hover:bg-purple-50 bg-white px-3 py-1.5 rounded-lg border border-purple-200 shadow-sm transition-colors flex items-center gap-1.5"
> >
<span className="text-lg leading-none">+</span> Add Product <span className="flex items-center gap-1.5"><span className="text-lg leading-none">+</span> Add Product</span>
</button> </Button>
)} )}
<button <Button
label="Clear all compared products"
onClick={clearSelection} onClick={clearSelection}
style={{ height: 'auto' }}
className="text-xs font-bold text-rose-500 hover:text-rose-600 bg-rose-50 px-3 py-1.5 rounded-lg border border-rose-100 transition-colors" className="text-xs font-bold text-rose-500 hover:text-rose-600 bg-rose-50 px-3 py-1.5 rounded-lg border border-rose-100 transition-colors"
> >
Clear All Clear All
</button> </Button>
<button {/* Had no text and no aria-label — the only close on this
full-screen drawer read as "button". */}
<IconButton
icon={<X size={20} />}
label="Close product comparison"
variant="ghost"
onClick={() => setIsComparing(false)} onClick={() => setIsComparing(false)}
style={{ height: 32, width: 32, minWidth: 0 }}
className="w-8 h-8 flex items-center justify-center rounded-full hover:bg-slate-200 text-slate-500 transition-colors" className="w-8 h-8 flex items-center justify-center rounded-full hover:bg-slate-200 text-slate-500 transition-colors"
> />
<X size={20} />
</button>
</div> </div>
</div> </div>
@@ -115,12 +131,18 @@ export default function ComparisonModal() {
<div key={prod.id} className="w-64 shrink-0 flex flex-col gap-4 relative group"> <div key={prod.id} className="w-64 shrink-0 flex flex-col gap-4 relative group">
{/* Remove button overlay */} {/* Remove button overlay */}
<button {/* One of these sits on every column and each was
nameless, so the drawer offered N identical
unlabelled buttons with no way to tell which
product any of them would drop. */}
<IconButton
icon={<X size={14} />}
label={`Remove ${prod.name} from comparison`}
variant="ghost"
onClick={() => removeProduct(prod.id)} onClick={() => removeProduct(prod.id)}
style={{ height: 28, width: 28, minWidth: 0 }}
className="absolute -top-3 -right-3 w-7 h-7 bg-white border border-slate-200 rounded-full flex items-center justify-center text-slate-400 hover:text-rose-500 hover:border-rose-200 hover:bg-rose-50 shadow-sm z-20 opacity-0 group-hover:opacity-100 transition-all" className="absolute -top-3 -right-3 w-7 h-7 bg-white border border-slate-200 rounded-full flex items-center justify-center text-slate-400 hover:text-rose-500 hover:border-rose-200 hover:bg-rose-50 shadow-sm z-20 opacity-0 group-hover:opacity-100 transition-all"
> />
<X size={14} />
</button>
{/* Product Header Card */} {/* Product Header Card */}
<div className="h-[140px] bg-white rounded-3xl border border-[#e2e8f0] shadow-sm p-4 flex flex-col justify-center relative hover:shadow-md transition-shadow"> <div className="h-[140px] bg-white rounded-3xl border border-[#e2e8f0] shadow-sm p-4 flex flex-col justify-center relative hover:shadow-md transition-shadow">

View File

@@ -2,6 +2,7 @@ import React, { useMemo, useState } from 'react';
import { useFiestaCustomerOrders } from '../services/fiestaQueries'; import { useFiestaCustomerOrders } from '../services/fiestaQueries';
import { num as fnum, str as fstr, customerName, customerStoreId, type Row } from '../services/fiestaApi'; import { num as fnum, str as fstr, customerName, customerStoreId, type Row } from '../services/fiestaApi';
import { Phone, MapPin, Mail, Receipt, X, Calendar, ShoppingBag, Wallet, TrendingUp, IndianRupee, Store } from 'lucide-react'; import { Phone, MapPin, Mail, Receipt, X, Calendar, ShoppingBag, Wallet, TrendingUp, IndianRupee, Store } from 'lucide-react';
import { IconButton } from '@astryxdesign/core/IconButton';
import OrderDetailsModal from './OrderDetailsModal'; import OrderDetailsModal from './OrderDetailsModal';
import './CustomerDetailPanel.css'; import './CustomerDetailPanel.css';
@@ -98,9 +99,14 @@ export default function CustomerDetailPanel({ customer, onClose }: CustomerDetai
<div className="cdp-top-pinned"> <div className="cdp-top-pinned">
<div className="cdp-header"> <div className="cdp-header">
{onClose && ( {onClose && (
<button className="cdp-close-btn" onClick={onClose} aria-label="Close panel"> <IconButton
<X size={18} /> icon={<X size={18} />}
</button> label={`Close ${name} details`}
variant="ghost"
onClick={onClose}
className="cdp-close-btn"
style={{ height: 'auto', width: 'auto', minWidth: 0 }}
/>
)} )}
<div className="cdp-header-row"> <div className="cdp-header-row">

View File

@@ -18,6 +18,7 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { MapContainer, TileLayer, Marker, Polyline, useMap } from 'react-leaflet'; import { MapContainer, TileLayer, Marker, Polyline, useMap } from 'react-leaflet';
import { Bike, Mailbox, Utensils, MapPin, Map as MapIcon, Ruler, X } from 'lucide-react'; import { Bike, Mailbox, Utensils, MapPin, Map as MapIcon, Ruler, X } from 'lucide-react';
import { IconButton } from '@astryxdesign/core/IconButton';
import L from 'leaflet'; import L from 'leaflet';
import 'leaflet/dist/leaflet.css'; import 'leaflet/dist/leaflet.css';
@@ -89,9 +90,16 @@ function OrderPopup({ o, onClose }: { o: Record<string, unknown>; onClose: () =>
return ( return (
<div className="dispatch-popup-center" onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}> <div className="dispatch-popup-center" onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div className="dispatch-popup-card dispatch-popup"> <div className="dispatch-popup-card dispatch-popup">
<button className="dispatch-popup-center-close" onClick={onClose} aria-label="Close"> {/* "Close" alone did not say what closes — this popup sits over a map
<X size={15} /> that has its own controls. */}
</button> <IconButton
icon={<X size={15} />}
label={`Close order ${S(o.orderid) || S(o.deliveryid)} details`}
variant="ghost"
onClick={onClose}
className="dispatch-popup-center-close"
style={{ height: 'auto', width: 'auto', minWidth: 0 }}
/>
<div style={{ height: '100%', width: '100%' }}> <div style={{ height: '100%', width: '100%' }}>
<div className="pu-header"> <div className="pu-header">
<div className="pu-header-top"> <div className="pu-header-top">

View File

@@ -53,6 +53,9 @@ import {
customerStoreId, customerStoreId,
type Row, type Row,
} from '../services/fiestaApi'; } from '../services/fiestaApi';
import { Button } from '@astryxdesign/core/Button';
import { IconButton } from '@astryxdesign/core/IconButton';
import { Selector } from '@astryxdesign/core/Selector';
import { colorFor } from '../services/dispatchShared'; import { colorFor } from '../services/dispatchShared';
import DispatchMap, { type MapPoint } from './DispatchMap'; import DispatchMap, { type MapPoint } from './DispatchMap';
import CustomerDetailPanel from './CustomerDetailPanel'; import CustomerDetailPanel from './CustomerDetailPanel';
@@ -355,13 +358,18 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
<div className="dispatch-container embedded"> <div className="dispatch-container embedded">
{/* ── Body ── */} {/* ── Body ── */}
<div id="body" className={sidebarCollapsed ? 'sidebar-collapsed' : ''}> <div id="body" className={sidebarCollapsed ? 'sidebar-collapsed' : ''}>
<button {/* Had only a `title`, which is not an accessible name, and no
className={`sidebar-toggle-tab${sidebarCollapsed ? ' is-collapsed' : ''}`} expanded state. */}
<IconButton
icon={sidebarCollapsed ? <ChevronRight size={26} /> : <ChevronLeft size={18} />}
label={sidebarCollapsed ? 'Show panel' : 'Hide panel'}
tooltip={sidebarCollapsed ? 'Show panel' : 'Hide panel'}
variant="ghost"
aria-expanded={!sidebarCollapsed}
onClick={() => setSidebarCollapsed((c) => !c)} onClick={() => setSidebarCollapsed((c) => !c)}
title={sidebarCollapsed ? 'Show panel' : 'Hide panel'} className={`sidebar-toggle-tab${sidebarCollapsed ? ' is-collapsed' : ''}`}
> style={{ height: 'auto', width: 'auto', minWidth: 0 }}
{sidebarCollapsed ? <ChevronRight size={26} /> : <ChevronLeft size={18} />} />
</button>
{/* Sidebar */} {/* Sidebar */}
<div id="sidebar"> <div id="sidebar">
@@ -423,26 +431,31 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
<div className="ph relative flex items-center justify-between w-full h-8"> <div className="ph relative flex items-center justify-between w-full h-8">
<span>{viewMode === 'customers' ? 'Customers' : 'Stores'} ({groups.length})</span> <span>{viewMode === 'customers' ? 'Customers' : 'Stores'} ({groups.length})</span>
{viewMode === 'customers' && !locationid && locationsQ.data && ( {viewMode === 'customers' && !locationid && locationsQ.data && (
<div className="absolute right-0 flex items-center group/filter cursor-pointer"> // The hand-drawn chevron that used to sit beside this goes
<select // with the <select>: Selector draws its own, and keeping
className="appearance-none w-36 text-[11px] font-extrabold tracking-wide rounded-md pl-3 pr-8 py-1.5 bg-white text-slate-800 shadow-sm ring-1 ring-slate-900/5 hover:bg-slate-50 outline-none focus:ring-2 focus:ring-[#662582]/30 transition-all duration-300 cursor-pointer relative z-0 truncate" // both would show two.
style={{ textOverflow: 'ellipsis' }} <div className="absolute right-0 flex items-center w-36">
<Selector
label="Filter customers by store"
isLabelHidden
size="sm"
// The <select> was a fixed w-36 that truncated long
// outlet names; Selector sizes to content instead and
// would jump width on every choice.
width="100%"
value={customerStoreFilter} value={customerStoreFilter}
onChange={(e) => { onChange={(v) => {
setCustomerStoreFilter(e.target.value); setCustomerStoreFilter(v);
setFocusedId(null); setFocusedId(null);
}} }}
> options={[
<option value="all">All Stores</option> { value: 'all', label: 'All Stores' },
{locationsQ.data.map(loc => ( ...locationsQ.data.map((loc) => ({
<option key={fnum(loc.locationid)} value={String(fnum(loc.locationid))}> value: String(fnum(loc.locationid)),
{fstr(loc.locationname) || `Store ${fnum(loc.locationid)}`} label: fstr(loc.locationname) || `Store ${fnum(loc.locationid)}`,
</option> })),
))} ]}
</select> />
<div className="absolute right-2 text-slate-400 pointer-events-none group-hover/filter:text-[#662582] transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><path d="m6 9 6 6 6-6"/></svg>
</div>
</div> </div>
)} )}
</div> </div>
@@ -509,22 +522,34 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
{/* bottom-right overlay controls (gated) */} {/* bottom-right overlay controls (gated) */}
<div id="ov-br"> <div id="ov-br">
<button <Button
className={`sbt ${animating ? 'active' : ''}`} label={animating ? 'Animating…' : 'Animate Routes'}
disabled={!focused || mapPoints.length < 2} icon={<Play size={14} />}
isDisabled={!focused || mapPoints.length < 2}
tooltip={focused ? 'Replay the route draw' : 'Select a rider to animate its route'}
onClick={() => { onClick={() => {
if (!focused || mapPoints.length < 2) return; if (!focused || mapPoints.length < 2) return;
setAnimating(true); setAnimating(true);
setAnimateNonce((n) => n + 1); setAnimateNonce((n) => n + 1);
window.setTimeout(() => setAnimating(false), 2300); window.setTimeout(() => setAnimating(false), 2300);
}} }}
title={focused ? 'Replay the route draw' : 'Select a rider to animate its route'} className={`sbt ${animating ? 'active' : ''}`}
style={{ height: 'auto' }}
/>
{/* Permanently disabled — planned-vs-actual compare needs rider
GPS telemetry the backend does not send yet. The reason was
in a `title`, which a disabled button never surfaces to a
screen reader; it is in the label now. */}
<Button
label="Compare (needs rider GPS telemetry, not available yet)"
icon={<ArrowLeftRight size={14} />}
isDisabled
tooltip="Planned-vs-actual compare needs rider GPS telemetry (awaiting backend)"
className="sbt"
style={{ height: 'auto' }}
> >
<span className="sbt-icon"><Play size={14} /></span> {animating ? 'Animating…' : 'Animate Routes'} Compare
</button> </Button>
<button className="sbt" disabled title="Planned-vs-actual compare needs rider GPS telemetry (awaiting backend)">
<span className="sbt-icon"><ArrowLeftRight size={14} /></span> Compare
</button>
</div> </div>
</div> </div>
)} )}
@@ -622,9 +647,13 @@ function FocusedDetail({
return ( return (
<> <>
{onBack && ( {onBack && (
<button className="sbt" onClick={onBack} style={{ marginBottom: 12 }}> <Button
<span className="sbt-icon"><ChevronLeft size={15} /></span> Back to list label="Back to list"
</button> icon={<ChevronLeft size={15} />}
onClick={onBack}
className="sbt"
style={{ height: 'auto', marginBottom: 12 }}
/>
)} )}
{tripBlocks.length === 0 && ( {tripBlocks.length === 0 && (
@@ -645,13 +674,31 @@ function FocusedDetail({
<span><MapPin size={11} /> {blk.orders.length} stops</span> <span><MapPin size={11} /> {blk.orders.length} stops</span>
<span><Ruler size={11} /> {blk.orders.reduce((a, o) => a + fnum(o.kms), 0).toFixed(1)} km</span> <span><Ruler size={11} /> {blk.orders.reduce((a, o) => a + fnum(o.kms), 0).toFixed(1)} km</span>
</span> </span>
<div className="trip-sort-toggle" role="group"> {/* Single-select pair whose choice was carried by an `is-active`
<button className={`trip-sort-pill ${tripSort === 'planned' ? 'is-active' : ''}`} onClick={() => setTripSort('planned')}> class alone — same pattern as the other filter groups. */}
<List size={12} /> <span>Planned</span> <div className="trip-sort-toggle" role="group" aria-label="Sort stops">
</button> <Button
<button className={`trip-sort-pill ${tripSort === 'time' ? 'is-active' : ''}`} onClick={() => setTripSort('time')}> label="Sort stops in planned order"
<Clock size={12} /> <span>By time</span> icon={<List size={12} />}
</button> aria-pressed={tripSort === 'planned'}
variant="ghost"
onClick={() => setTripSort('planned')}
className={`trip-sort-pill ${tripSort === 'planned' ? 'is-active' : ''}`}
style={{ height: 'auto' }}
>
<span>Planned</span>
</Button>
<Button
label="Sort stops by time"
icon={<Clock size={12} />}
aria-pressed={tripSort === 'time'}
variant="ghost"
onClick={() => setTripSort('time')}
className={`trip-sort-pill ${tripSort === 'time' ? 'is-active' : ''}`}
style={{ height: 'auto' }}
>
<span>By time</span>
</Button>
</div> </div>
</div> </div>

View File

@@ -5,6 +5,8 @@
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { Menu, HelpCircle, LogOut, ChevronDown, Mail, QrCode, User } from 'lucide-react'; import { Menu, HelpCircle, LogOut, ChevronDown, Mail, QrCode, User } from 'lucide-react';
import { Button } from '@astryxdesign/core/Button';
import { IconButton } from '@astryxdesign/core/IconButton';
import { MainSection } from '../types'; import { MainSection } from '../types';
interface HeaderProps { interface HeaderProps {
@@ -44,19 +46,34 @@ export default function Header({
const [showProfileDropdown, setShowProfileDropdown] = useState(false); const [showProfileDropdown, setShowProfileDropdown] = useState(false);
const profileRef = useRef<HTMLDivElement>(null); const profileRef = useRef<HTMLDivElement>(null);
// Close the profile dropdown on any click/tap outside of it. // Close the profile dropdown on any click/tap outside of it, or on Escape.
//
// Escape and the focus return are new in Phase 6. The panel was dismissable
// by pointer only, which left anyone driving the header from the keyboard
// with no way out of it, and on close focus was dropped wherever it lay
// rather than handed back to the trigger. Both are the behaviours Astryx's
// own overlays provide; this panel is hand-rolled (it is a profile card, not
// a menu — putting a card inside role="menu" would be worse ARIA than
// leaving it alone), so it has to provide them itself.
useEffect(() => { useEffect(() => {
if (!showProfileDropdown) return; if (!showProfileDropdown) return;
const opener = document.activeElement as HTMLElement | null;
const handlePointerDown = (e: MouseEvent | TouchEvent) => { const handlePointerDown = (e: MouseEvent | TouchEvent) => {
if (profileRef.current && !profileRef.current.contains(e.target as Node)) { if (profileRef.current && !profileRef.current.contains(e.target as Node)) {
setShowProfileDropdown(false); setShowProfileDropdown(false);
} }
}; };
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setShowProfileDropdown(false);
};
document.addEventListener('mousedown', handlePointerDown); document.addEventListener('mousedown', handlePointerDown);
document.addEventListener('touchstart', handlePointerDown); document.addEventListener('touchstart', handlePointerDown);
document.addEventListener('keydown', handleKey);
return () => { return () => {
document.removeEventListener('mousedown', handlePointerDown); document.removeEventListener('mousedown', handlePointerDown);
document.removeEventListener('touchstart', handlePointerDown); document.removeEventListener('touchstart', handlePointerDown);
document.removeEventListener('keydown', handleKey);
opener?.focus?.();
}; };
}, [showProfileDropdown]); }, [showProfileDropdown]);
@@ -101,17 +118,25 @@ export default function Header({
Collapsed the cell is only 64px and centred, so the natural Collapsed the cell is only 64px and centred, so the natural
inline position already sits beside the icon logo, which is itself inline position already sits beside the icon logo, which is itself
aligned over the sidebar's icon rail. */} aligned over the sidebar's icon rail. */}
<button {/* PHASE 6 — IconButton. `label` is the accessible name (Astryx omits
`title` from its props, so `tooltip` carries the hover text). The
sizing overrides below are not decoration: Astryx pins a Button to
28/32/36px and paints its own ground, and this control sits on the
brand purple bar whose alignment with the sidebar seam is load
bearing (see the note above). Height/width auto hand spacing back
to the Tailwind padding classes that were already tuned for it. */}
<IconButton
icon={<Menu size={18} />}
label={isSidebarOpen ? 'Close sidebar' : 'Open sidebar'}
tooltip={isSidebarOpen ? 'Close sidebar' : 'Open sidebar'}
variant="ghost"
onClick={onToggleSidebar} onClick={onToggleSidebar}
title={isSidebarOpen ? 'Close sidebar' : 'Open sidebar'}
aria-label={isSidebarOpen ? 'Close sidebar' : 'Open sidebar'}
aria-expanded={isSidebarOpen} aria-expanded={isSidebarOpen}
style={{ height: 'auto', width: 'auto', minWidth: 0, background: 'transparent', color: '#fff', border: 'none' }}
className={`rounded-full hover:bg-purple-800 transition-colors cursor-pointer text-white shrink-0 ${ className={`rounded-full hover:bg-purple-800 transition-colors cursor-pointer text-white shrink-0 ${
isSidebarOpen ? 'md:ml-auto p-1.5 sm:p-2' : 'p-1.5' isSidebarOpen ? 'md:ml-auto p-1.5 sm:p-2' : 'p-1.5'
}`} }`}
> />
<Menu size={18} />
</button>
</div> </div>
{/* Dynamic Store Name Context */} {/* Dynamic Store Name Context */}
@@ -134,22 +159,40 @@ export default function Header({
<div className="flex items-center gap-md"> <div className="flex items-center gap-md">
{/* Store QR — opens the QR modal (user store page only) */} {/* Store QR — opens the QR modal (user store page only) */}
{onQrClick && ( {onQrClick && (
<button <IconButton
icon={<QrCode size={18} />}
label="Store QR code"
tooltip="Store QR code"
variant="ghost"
onClick={onQrClick} onClick={onQrClick}
title="Store QR code" style={{ height: 'auto', width: 'auto', minWidth: 0, background: 'transparent', color: '#fff', border: 'none' }}
aria-label="Store QR code"
className="p-2 rounded-full hover:bg-purple-800 transition-colors cursor-pointer text-white" className="p-2 rounded-full hover:bg-purple-800 transition-colors cursor-pointer text-white"
> />
<QrCode size={18} />
</button>
)} )}
{/* User profile with dropdown */} {/* User profile with dropdown */}
<div className="relative" ref={profileRef}> <div className="relative" ref={profileRef}>
<button {/* PHASE 6 — Astryx Button carrying a bespoke visual. Button renders
`children ?? label` visibly and, when the two differ, promotes
`label` to the accessible name. That is the fix this control
needed: the name was previously assembled from the raw text
content, so a screen reader announced the initials glued onto the
name — "RS RMart Store User Manager". Now it reads
"RMart Store User, Manager. Account menu".
The children are wrapped in their own flex row because Button
nests them inside a span of its own; laying them out on the
button root would leave them stacked. */}
<Button
label={`${profile.name}, ${profile.role}. Account menu`}
variant="ghost"
onClick={() => setShowProfileDropdown(!showProfileDropdown)} onClick={() => setShowProfileDropdown(!showProfileDropdown)}
className="group flex items-center gap-2.5 pl-1 pr-1 sm:pr-2.5 py-1 rounded-full bg-white/10 hover:bg-white/15 border border-white/15 backdrop-blur-sm focus:ring-2 focus:ring-purple-300/60 outline-none cursor-pointer transition-all duration-150 active:scale-[0.98]" aria-expanded={showProfileDropdown}
aria-haspopup="dialog"
style={{ height: 'auto', background: 'transparent', border: 'none' }}
className="group flex items-center pl-1 pr-1 sm:pr-2.5 py-1 rounded-full bg-white/10 hover:bg-white/15 border border-white/15 backdrop-blur-sm focus:ring-2 focus:ring-purple-300/60 outline-none cursor-pointer transition-all duration-150 active:scale-[0.98]"
> >
<span className="flex items-center gap-2.5">
{/* Initials avatar with live status dot */} {/* Initials avatar with live status dot */}
<span className="relative shrink-0"> <span className="relative shrink-0">
<span className="w-9 h-9 rounded-full bg-white/15 ring-2 ring-white/30 flex items-center justify-center text-xs font-bold text-white tracking-wide"> <span className="w-9 h-9 rounded-full bg-white/15 ring-2 ring-white/30 flex items-center justify-center text-xs font-bold text-white tracking-wide">
@@ -168,7 +211,8 @@ export default function Header({
size={15} size={15}
className={`hidden sm:block text-purple-200 transition-transform duration-200 ${showProfileDropdown ? 'rotate-180' : ''}`} className={`hidden sm:block text-purple-200 transition-transform duration-200 ${showProfileDropdown ? 'rotate-180' : ''}`}
/> />
</button> </span>
</Button>
{showProfileDropdown && ( {showProfileDropdown && (
<div className="absolute right-0 mt-2.5 w-72 bg-white border border-slate-200/80 rounded-2xl shadow-2xl shadow-purple-950/15 z-50 text-slate-700 animate-in fade-in slide-in-from-top-2 duration-200 overflow-hidden"> <div className="absolute right-0 mt-2.5 w-72 bg-white border border-slate-200/80 rounded-2xl shadow-2xl shadow-purple-950/15 z-50 text-slate-700 animate-in fade-in slide-in-from-top-2 duration-200 overflow-hidden">
@@ -197,36 +241,57 @@ export default function Header({
{/* Account actions (moved here from the sidebar) */} {/* Account actions (moved here from the sidebar) */}
<div className="p-2 flex flex-col gap-0.5"> <div className="p-2 flex flex-col gap-0.5">
{/* PHASE 6 — the three account actions on Astryx Button. Each
keeps its icon tile, so `children` carries the visual and
`label` the accessible name. Note what is NOT in `style`:
padding. Inline style beats a Tailwind class, so pinning it
here silently killed the px/py classes and collapsed every
row — the classes are left to win on their own instead. */}
{onAccountClick && ( {onAccountClick && (
<button <Button
label="My Account"
variant="ghost"
onClick={() => { setShowProfileDropdown(false); onAccountClick(); }} onClick={() => { setShowProfileDropdown(false); onAccountClick(); }}
className="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-xl text-xs font-semibold text-slate-700 hover:bg-slate-50 cursor-pointer transition-colors group/item" style={{ height: 'auto', background: 'transparent', border: 'none', justifyContent: 'flex-start' }}
className="w-full flex items-center px-2.5 py-2 rounded-xl text-xs font-semibold text-slate-700 hover:bg-slate-50 cursor-pointer transition-colors group/item"
> >
<span className="h-7 w-7 rounded-lg bg-purple-50 text-[#662582] ring-1 ring-purple-100 flex items-center justify-center group-hover/item:scale-110 transition-transform"> <span className="w-full flex items-center gap-2.5">
<span className="h-7 w-7 rounded-lg bg-purple-50 text-[#662582] ring-1 ring-purple-100 flex items-center justify-center shrink-0 group-hover/item:scale-110 transition-transform">
<User size={14} /> <User size={14} />
</span> </span>
My Account My Account
</button> </span>
</Button>
)} )}
<button <Button
label="Help Center"
variant="ghost"
onClick={() => { setShowProfileDropdown(false); onHelpClick(); }} onClick={() => { setShowProfileDropdown(false); onHelpClick(); }}
className="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-xl text-xs font-semibold text-slate-700 hover:bg-slate-50 cursor-pointer transition-colors group/item" style={{ height: 'auto', background: 'transparent', border: 'none', justifyContent: 'flex-start' }}
className="w-full flex items-center px-2.5 py-2 rounded-xl text-xs font-semibold text-slate-700 hover:bg-slate-50 cursor-pointer transition-colors group/item"
> >
<span className="h-7 w-7 rounded-lg bg-slate-100 text-slate-500 ring-1 ring-slate-200 flex items-center justify-center group-hover/item:scale-110 transition-transform"> <span className="w-full flex items-center gap-2.5">
<span className="h-7 w-7 rounded-lg bg-slate-100 text-slate-500 ring-1 ring-slate-200 flex items-center justify-center shrink-0 group-hover/item:scale-110 transition-transform">
<HelpCircle size={14} /> <HelpCircle size={14} />
</span> </span>
Help Center Help Center
</button> </span>
</Button>
<div className="my-1 h-px bg-slate-100" /> <div className="my-1 h-px bg-slate-100" />
<button <Button
label="Log Out"
variant="ghost"
onClick={() => { setShowProfileDropdown(false); onLogoutClick(); }} onClick={() => { setShowProfileDropdown(false); onLogoutClick(); }}
className="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-xl text-xs font-semibold text-rose-600 hover:bg-rose-50 cursor-pointer transition-colors group/item" style={{ height: 'auto', background: 'transparent', border: 'none', justifyContent: 'flex-start' }}
className="w-full flex items-center px-2.5 py-2 rounded-xl text-xs font-semibold text-rose-600 hover:bg-rose-50 cursor-pointer transition-colors group/item"
> >
<span className="h-7 w-7 rounded-lg bg-rose-50 text-rose-500 ring-1 ring-rose-100 flex items-center justify-center group-hover/item:scale-110 transition-transform"> <span className="w-full flex items-center gap-2.5">
<span className="h-7 w-7 rounded-lg bg-rose-50 text-rose-500 ring-1 ring-rose-100 flex items-center justify-center shrink-0 group-hover/item:scale-110 transition-transform">
<LogOut size={14} /> <LogOut size={14} />
</span> </span>
Log Out Log Out
</button> </span>
</Button>
</div> </div>
</div> </div>
)} )}

View File

@@ -1,5 +1,6 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { X, Save, AlertCircle, CheckCircle, DownloadCloud, Plus, Box } from 'lucide-react'; import { X, Save, AlertCircle, CheckCircle, DownloadCloud, Plus, Box } from 'lucide-react';
import { Button } from '@astryxdesign/core/Button';
import { CatalogueProduct, ImportCatalogueProductRequest } from '../services/catalogueApi'; import { CatalogueProduct, ImportCatalogueProductRequest } from '../services/catalogueApi';
import { useProductSubcategories, useTenantCategories } from '../hooks/useCatalogueImport'; import { useProductSubcategories, useTenantCategories } from '../hooks/useCatalogueImport';
import { Lightbox } from './CatalogueGallery'; import { Lightbox } from './CatalogueGallery';
@@ -319,14 +320,15 @@ export default function ImportProductModal({
It is not visible to any store until you set a price and publish it there. It is not visible to any store until you set a price and publish it there.
</p> </p>
<button <Button
type="button" label={`Import ${product.product_name} to your Admin Catalogue`}
icon={<Plus size={18} strokeWidth={2.5} />}
onClick={handleSubmit} onClick={handleSubmit}
className="w-full flex items-center justify-center gap-2 py-3.5 rounded-xl text-sm font-extrabold transition-all bg-[#662582] hover:bg-[#531e6a] text-white shadow-md cursor-pointer" style={{ height: 'auto' }}
className="w-full flex items-center justify-center gap-2 py-3.5 rounded-xl text-sm font-extrabold transition-all bg-[#662582] hover:bg-[#531e6a] text-white border-none shadow-md cursor-pointer"
> >
<Plus size={18} strokeWidth={2.5} />
Import Product Import Product
</button> </Button>
</div> </div>
)} )}
</div> </div>

View File

@@ -11,12 +11,26 @@ import {
ArrowRight, ArrowRight,
Eye, Eye,
EyeOff, EyeOff,
Loader2,
} from 'lucide-react'; } from 'lucide-react';
import { Button } from '@astryxdesign/core/Button';
import { IconButton } from '@astryxdesign/core/IconButton';
import { TextInput } from '@astryxdesign/core/TextInput';
import { CheckboxInput } from '@astryxdesign/core/CheckboxInput';
import { Banner } from '@astryxdesign/core/Banner';
import { checkEmailRequest, PasswordSetupRequiredError } from '../services/auth'; import { checkEmailRequest, PasswordSetupRequiredError } from '../services/auth';
import type { AuthUser } from '../services/auth'; import type { AuthUser } from '../services/auth';
import { useLogin, useSetPassword } from '../services/fiestaQueries'; import { useLogin, useSetPassword } from '../services/fiestaQueries';
/**
* Astryx's BaseProps builds on `React.HTMLAttributes`, which carries no
* `autoComplete` — that attribute is declared on `InputHTMLAttributes`. TextInput
* spreads whatever it does not recognise straight onto the underlying `<input>`,
* so the attribute lands correctly at runtime and only the type stands in the
* way. Worth the escape hatch: dropping it would silently break password-manager
* autofill on the one form in the app that depends on it.
*/
const autofill = (value: string): Record<string, string> => ({ autoComplete: value });
interface LoginViewProps { interface LoginViewProps {
/** Called with the authenticated user once credentials are verified. */ /** Called with the authenticated user once credentials are verified. */
onLogin: (user: AuthUser) => void; onLogin: (user: AuthUser) => void;
@@ -41,6 +55,13 @@ export default function LoginView({ onLogin }: LoginViewProps) {
const [confirmPassword, setConfirmPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState('');
const [showNewPassword, setShowNewPassword] = useState(false); const [showNewPassword, setShowNewPassword] = useState(false);
// NOTE: nothing reads this. The "Remember me" box was uncontrolled and inert
// before Phase 6 too — CheckboxInput is a controlled component, so it needs
// state to tick at all, but ticking it still changes nothing about session
// lifetime. Left visible rather than removed because that is a product call,
// not a conversion one.
const [rememberMe, setRememberMe] = useState(false);
const login = useLogin(); const login = useLogin();
const setPasswordMut = useSetPassword(); const setPasswordMut = useSetPassword();
const loading = login.isPending || setPasswordMut.isPending; const loading = login.isPending || setPasswordMut.isPending;
@@ -197,13 +218,19 @@ export default function LoginView({ onLogin }: LoginViewProps) {
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
{/* Email or username (the API's `authname` accepts either) */} {/* Email or username (the API's `authname` accepts either) */}
<div className="space-y-2"> <div className="space-y-2">
{/* PHASE 6 — the caption is a <span>, not a <label>, because the
real label now lives inside the TextInput (hidden, same text).
Two labels pointing at one input is worse than one; this keeps
the design's caption row, which has to hold "Change Email"
beside it, and leaves the programmatic label to Astryx. */}
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<label htmlFor="login-email" className="block text-xs font-bold text-slate-600 uppercase tracking-wider"> <span className="block text-xs font-bold text-slate-600 uppercase tracking-wider">
Email or Username Email or Username
</label> </span>
{(step === 'password' || step === 'setup') && ( {(step === 'password' || step === 'setup') && (
<button <Button
type="button" label="Change Email"
variant="ghost"
onClick={() => { onClick={() => {
setStep('email'); setStep('email');
setError(''); setError('');
@@ -212,138 +239,162 @@ export default function LoginView({ onLogin }: LoginViewProps) {
setNewPassword(''); setNewPassword('');
setConfirmPassword(''); setConfirmPassword('');
}} }}
className="text-xs font-semibold text-purple-600 hover:text-purple-800 bg-transparent border-none cursor-pointer p-0" style={{ height: 'auto', background: 'transparent', border: 'none', padding: 0 }}
> className="text-xs font-semibold text-purple-600 hover:text-purple-800 cursor-pointer"
Change Email />
</button>
)} )}
</div> </div>
<div className="relative"> <TextInput
<Mail size={16} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" /> label="Email or Username"
<input isLabelHidden
id="login-email"
type="text" type="text"
autoComplete="username" {...autofill("username")}
disabled={step === 'password' || step === 'setup'} isDisabled={step === 'password' || step === 'setup'}
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(v) => setEmail(v)}
placeholder="you@nearledaily.com" placeholder="you@nearledaily.com"
className="w-full h-12 pl-10 pr-4 bg-slate-50 border border-slate-200 rounded-xl text-sm text-slate-800 placeholder-slate-400 focus:outline-none focus:border-purple-500 focus:bg-white focus:ring-4 focus:ring-purple-500/10 transition-all disabled:opacity-75 disabled:cursor-not-allowed" startIcon={<Mail size={16} className="text-slate-400" />}
className="w-full h-12 px-3.5 bg-slate-50 border border-slate-200 rounded-xl text-sm text-slate-800"
/> />
</div> </div>
</div>
{/* First-login password setup */} {/* First-login password setup */}
{step === 'setup' && ( {step === 'setup' && (
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<label htmlFor="new-password" className="block text-xs font-bold text-slate-600 uppercase tracking-wider"> <span className="block text-xs font-bold text-slate-600 uppercase tracking-wider">
New Password New Password
</label> </span>
<div className="relative"> <div className="relative">
<Lock size={16} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" /> <TextInput
<input label="New Password"
id="new-password" isLabelHidden
type={showNewPassword ? 'text' : 'password'} type={showNewPassword ? 'text' : 'password'}
{...autofill("new-password")}
value={newPassword} value={newPassword}
onChange={(e) => setNewPassword(e.target.value)} onChange={(v) => setNewPassword(v)}
placeholder="At least 6 characters" placeholder="At least 6 characters"
className="w-full h-12 pl-10 pr-11 bg-slate-50 border border-slate-200 rounded-xl text-sm text-slate-800 placeholder-slate-400 focus:outline-none focus:border-purple-500 focus:bg-white focus:ring-4 focus:ring-purple-500/10 transition-all" startIcon={<Lock size={16} className="text-slate-400" />}
className="w-full h-12 pl-3.5 pr-11 bg-slate-50 border border-slate-200 rounded-xl text-sm text-slate-800"
/> />
<button <IconButton
type="button" icon={showNewPassword ? <EyeOff size={16} /> : <Eye size={16} />}
label={showNewPassword ? 'Hide password' : 'Show password'}
tooltip={showNewPassword ? 'Hide password' : 'Show password'}
variant="ghost"
onClick={() => setShowNewPassword((s) => !s)} onClick={() => setShowNewPassword((s) => !s)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 bg-transparent border-none cursor-pointer p-1" className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 cursor-pointer z-10"
title={showNewPassword ? 'Hide password' : 'Show password'} style={{ height: 'auto', width: 'auto', minWidth: 0, padding: 4, background: 'transparent', border: 'none' }}
> />
{showNewPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label htmlFor="confirm-password" className="block text-xs font-bold text-slate-600 uppercase tracking-wider"> <span className="block text-xs font-bold text-slate-600 uppercase tracking-wider">
Confirm Password Confirm Password
</label> </span>
<div className="relative"> <TextInput
<Lock size={16} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" /> label="Confirm Password"
<input isLabelHidden
id="confirm-password"
type={showNewPassword ? 'text' : 'password'} type={showNewPassword ? 'text' : 'password'}
{...autofill("new-password")}
value={confirmPassword} value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)} onChange={(v) => setConfirmPassword(v)}
placeholder="Re-enter password" placeholder="Re-enter password"
className="w-full h-12 pl-10 pr-4 bg-slate-50 border border-slate-200 rounded-xl text-sm text-slate-800 placeholder-slate-400 focus:outline-none focus:border-purple-500 focus:bg-white focus:ring-4 focus:ring-purple-500/10 transition-all" startIcon={<Lock size={16} className="text-slate-400" />}
className="w-full h-12 px-3.5 bg-slate-50 border border-slate-200 rounded-xl text-sm text-slate-800"
/> />
</div> </div>
</div> </div>
</div>
)} )}
{/* Password */} {/* Password */}
{step === 'password' && ( {step === 'password' && (
<div className="space-y-2"> <div className="space-y-2">
<label htmlFor="login-password" className="block text-xs font-bold text-slate-600 uppercase tracking-wider"> <span className="block text-xs font-bold text-slate-600 uppercase tracking-wider">
Password Password
</label> </span>
<div className="relative"> <div className="relative">
<Lock size={16} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" /> <TextInput
<input label="Password"
id="login-password" isLabelHidden
type={showPassword ? 'text' : 'password'} type={showPassword ? 'text' : 'password'}
{...autofill("current-password")}
hasAutoFocus
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(v) => setPassword(v)}
placeholder="••••••••" placeholder="••••••••"
className="w-full h-12 pl-10 pr-11 bg-slate-50 border border-slate-200 rounded-xl text-sm text-slate-800 placeholder-slate-400 focus:outline-none focus:border-purple-500 focus:bg-white focus:ring-4 focus:ring-purple-500/10 transition-all" startIcon={<Lock size={16} className="text-slate-400" />}
className="w-full h-12 pl-3.5 pr-11 bg-slate-50 border border-slate-200 rounded-xl text-sm text-slate-800"
/> />
<button <IconButton
type="button" icon={showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
label={showPassword ? 'Hide password' : 'Show password'}
tooltip={showPassword ? 'Hide password' : 'Show password'}
variant="ghost"
onClick={() => setShowPassword((s) => !s)} onClick={() => setShowPassword((s) => !s)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 bg-transparent border-none cursor-pointer p-1" className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 cursor-pointer z-10"
title={showPassword ? 'Hide password' : 'Show password'} style={{ height: 'auto', width: 'auto', minWidth: 0, padding: 4, background: 'transparent', border: 'none' }}
> />
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div> </div>
</div> </div>
)} )}
{/* PHASE 6 — Banner, not a bare <p>. Every failure on this form —
wrong password, unknown email, mismatched setup — was written
into a paragraph with no role, so it was painted on screen and
announced to nobody. Banner carries role="alert", which is the
whole point on the one form where the user cannot proceed until
they hear what went wrong. */}
{error && ( {error && (
<p className="text-xs font-semibold text-rose-600 bg-rose-50 border border-rose-100 rounded-lg px-3 py-2.5"> <Banner status="error" title={error} />
{error}
</p>
)} )}
{/* Options row */} {/* Options row */}
{step === 'password' && ( {step === 'password' && (
<div className="flex items-center justify-between text-xs"> <div className="flex items-center justify-between text-xs">
<label className="flex items-center gap-2 text-slate-500 font-medium cursor-pointer select-none"> <CheckboxInput
<input type="checkbox" className="h-3.5 w-3.5 rounded border-slate-300 text-purple-600 accent-purple-600 focus:ring-purple-500/30" /> label="Remember me"
Remember me size="sm"
</label> value={rememberMe}
<button type="button" className="font-semibold text-purple-650 hover:text-purple-800 bg-transparent border-none cursor-pointer"> onChange={(checked) => setRememberMe(checked)}
Forgot password? className="text-slate-500 font-medium"
</button> />
{/* Also inert — this button had no onClick before the
conversion and still has none. There is no password-reset
flow behind it. */}
<Button
label="Forgot password?"
variant="ghost"
style={{ height: 'auto', background: 'transparent', border: 'none', padding: 0 }}
className="text-xs font-semibold text-purple-600 hover:text-purple-800 cursor-pointer"
/>
</div> </div>
)} )}
{/* Submit */} {/* Submit — Astryx supplies its own spinner and the aria-busy /
<button live-region wiring behind `isLoading`, so the hand-rolled
Loader2 is gone with it. */}
<Button
type="submit" type="submit"
disabled={loading || checkingEmail} label={
className="w-full h-12 flex items-center justify-center gap-2 bg-[#662582] hover:bg-purple-800 text-white font-bold text-sm rounded-xl shadow-sm hover:shadow-lg transition-all active:scale-[0.98] cursor-pointer border-none disabled:opacity-70 disabled:cursor-not-allowed disabled:active:scale-100" checkingEmail
> ? 'Checking Email…'
{loading || checkingEmail ? ( : setPasswordMut.isPending
<> ? 'Setting Password…'
<Loader2 size={16} className="animate-spin" /> : loading
{checkingEmail ? 'Checking Email…' : setPasswordMut.isPending ? 'Setting Password…' : 'Verifying…'} ? 'Verifying…'
</> : step === 'email'
) : ( ? 'Continue'
<> : step === 'setup'
{step === 'email' ? 'Continue' : step === 'setup' ? 'Set Password & Sign In' : 'Sign in'} ? 'Set Password & Sign In'
<ArrowRight size={16} /> : 'Sign in'
</> }
)} isLoading={loading || checkingEmail}
</button> isDisabled={loading || checkingEmail}
endContent={loading || checkingEmail ? undefined : <ArrowRight size={16} />}
style={{ height: 48, background: '#662582', border: 'none', color: '#fff' }}
className="w-full h-12 flex items-center justify-center gap-2 hover:bg-purple-800 text-white font-bold text-sm rounded-xl shadow-sm hover:shadow-lg transition-all active:scale-[0.98] cursor-pointer"
/>
</form> </form>
{/* Helper note */} {/* Helper note */}

View File

@@ -27,14 +27,13 @@
* their branch and rejects rows for any other — enforced again server-side. * their branch and rejects rows for any other — enforced again server-side.
*/ */
import { useCallback, useMemo, useRef, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { import {
AlertTriangle, AlertTriangle,
CheckCircle2, CheckCircle2,
Download, Download,
FileSpreadsheet, FileSpreadsheet,
Loader2,
RotateCcw, RotateCcw,
Store, Store,
Upload, Upload,
@@ -53,6 +52,12 @@ import {
toBills, toBills,
type ParsedSheet, type ParsedSheet,
} from '../services/offlineSalesSheet'; } from '../services/offlineSalesSheet';
import { Button } from '@astryxdesign/core/Button';
import { IconButton } from '@astryxdesign/core/IconButton';
import { FileInput } from '@astryxdesign/core/FileInput';
import {
Table, TableHeader, TableHeaderCell, TableBody, TableRow, TableCell,
} from '@astryxdesign/core/Table';
interface OfflineSalesUploadProps { interface OfflineSalesUploadProps {
tenantId: number; tenantId: number;
@@ -79,11 +84,15 @@ export default function OfflineSalesUpload({
onClose, onClose,
}: OfflineSalesUploadProps) { }: OfflineSalesUploadProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null);
// Remount key for the FileInput. The old dropzone cleared its hidden input's
// `.value` on reset, which is what let the operator fix a spreadsheet and
// pick the very same filename again. FileInput owns that element now, so the
// equivalent is to give it a fresh one.
const [fileInputNonce, setFileInputNonce] = useState(0);
const [parsed, setParsed] = useState<ParsedSheet | null>(null); const [parsed, setParsed] = useState<ParsedSheet | null>(null);
const [fileName, setFileName] = useState(''); const [fileName, setFileName] = useState('');
const [dragging, setDragging] = useState(false);
const [result, setResult] = useState<OfflineSalesUploadResponse | null>(null); const [result, setResult] = useState<OfflineSalesUploadResponse | null>(null);
const [uploadError, setUploadError] = useState(''); const [uploadError, setUploadError] = useState('');
@@ -157,7 +166,7 @@ export default function OfflineSalesUpload({
setFileName(''); setFileName('');
setResult(null); setResult(null);
setUploadError(''); setUploadError('');
if (fileInputRef.current) fileInputRef.current.value = ''; setFileInputNonce((n) => n + 1);
}; };
const storeCount = template?.locations.length ?? 0; const storeCount = template?.locations.length ?? 0;
@@ -182,13 +191,14 @@ export default function OfflineSalesUpload({
</p> </p>
</div> </div>
</div> </div>
<button <IconButton
icon={<X size={20} />}
label="Close offline sales upload"
variant="ghost"
onClick={onClose} onClick={onClose}
style={{ height: 32, width: 32, minWidth: 0 }}
className="flex h-8 w-8 items-center justify-center rounded-full text-white/80 transition-colors hover:bg-white/10 hover:text-white" className="flex h-8 w-8 items-center justify-center rounded-full text-white/80 transition-colors hover:bg-white/10 hover:text-white"
aria-label="Close" />
>
<X size={20} />
</button>
</div> </div>
<div className="flex-1 overflow-y-auto p-6"> <div className="flex-1 overflow-y-auto p-6">
@@ -217,18 +227,17 @@ export default function OfflineSalesUpload({
: `${template?.products.length ?? 0} rows covering all ${storeCount} stores. Every row already says which store it belongs to — fill in qtysold wherever you sold something and upload the one file.`} : `${template?.products.length ?? 0} rows covering all ${storeCount} stores. Every row already says which store it belongs to — fill in qtysold wherever you sold something and upload the one file.`}
</p> </p>
</div> </div>
<button {/* Astryx supplies the spinner behind `isLoading`, so the
hand-rolled Loader2 goes with it. */}
<Button
label="Download Template"
icon={<Download size={16} />}
isLoading={templateQuery.isLoading}
isDisabled={!template || templateQuery.isLoading}
onClick={() => template && downloadSaleTemplate(template)} onClick={() => template && downloadSaleTemplate(template)}
disabled={!template || templateQuery.isLoading} style={{ height: 'auto' }}
className="flex items-center gap-2 rounded-lg bg-[#662582] px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-[#551f6d] disabled:cursor-not-allowed disabled:opacity-50" className="flex items-center gap-2 rounded-lg bg-[#662582] px-4 py-2.5 text-sm font-semibold text-white border-none transition-colors hover:bg-[#551f6d]"
> />
{templateQuery.isLoading ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Download size={16} />
)}
Download Template
</button>
</div> </div>
{/* What the one file covers. Shown so it is obvious up front {/* What the one file covers. Shown so it is obvious up front
@@ -265,41 +274,36 @@ export default function OfflineSalesUpload({
Upload the filled-in file Upload the filled-in file
</h3> </h3>
<div {/* PHASE 6 — FileInput in dropzone mode, replacing a hand-rolled
onDragOver={(e) => { drop target that nobody could reach from a keyboard: it was a
e.preventDefault(); plain <div> with an onClick, no role, no tabindex, driving a
setDragging(true); `hidden` file input. Mouse users got a drop zone; everyone
}} else got nothing at all, on the only step of this flow that
onDragLeave={() => setDragging(false)} accepts input. FileInput brings the drag handling, the accept
onDrop={(e) => { filter and a real focusable control with it, so `dragging`
e.preventDefault(); and the input ref are no longer needed here. */}
setDragging(false); <FileInput
const file = e.dataTransfer.files?.[0]; key={fileInputNonce}
if (file) void loadFile(file); label="Filled-in sales file"
}} isLabelHidden
onClick={() => fileInputRef.current?.click()} mode="dropzone"
className={`cursor-pointer rounded-lg border-2 border-dashed p-8 text-center transition-colors ${
dragging
? 'border-[#662582] bg-purple-50'
: 'border-slate-300 bg-white hover:border-slate-400 hover:bg-slate-50'
}`}
>
<Upload size={26} className="mx-auto mb-2 text-slate-400" />
<p className="text-sm font-medium text-slate-700">
{fileName || 'Drop the .xlsx file here, or click to choose'}
</p>
<p className="mt-1 text-xs text-slate-500">Only the template downloaded above will import correctly.</p>
<input
ref={fileInputRef}
type="file"
accept=".xlsx,.xls,.csv" accept=".xlsx,.xls,.csv"
className="hidden" value={null}
onChange={(e) => { placeholder={fileName || 'Drop the .xlsx file here, or click to choose'}
const file = e.target.files?.[0]; description="Only the template downloaded above will import correctly."
onChange={(f) => {
const file = Array.isArray(f) ? f[0] : f;
if (file) void loadFile(file); if (file) void loadFile(file);
}} }}
/> />
</div> {/* `description` above is what ties this sentence to the input
for a screen reader — but Astryx hides the description
together with the label, so with `isLabelHidden` set it
collapses to a 1x1px span and sighted users simply lose it.
Repeated here so it stays on screen, as it was before. */}
<p className="mt-2 text-xs text-slate-500">
Only the template downloaded above will import correctly.
</p>
</section> </section>
{/* Step 3 — the preview. Every problem is shown at once, against {/* Step 3 — the preview. Every problem is shown at once, against
@@ -345,37 +349,37 @@ export default function OfflineSalesUpload({
sanity-check what is about to be deducted where. */} sanity-check what is about to be deducted where. */}
{summary.stores > 1 && ( {summary.stores > 1 && (
<div className="mb-4 overflow-hidden rounded-lg border border-slate-200"> <div className="mb-4 overflow-hidden rounded-lg border border-slate-200">
<table className="w-full text-left text-xs"> <Table className="w-full text-left text-xs">
<thead className="bg-slate-100 text-[11px] uppercase tracking-wide text-slate-600"> <TableHeader>
<tr> <TableRow>
<th className="px-3 py-2 font-semibold">Store</th> <TableHeaderCell>Store</TableHeaderCell>
<th className="px-3 py-2 text-right font-semibold">Lines</th> <TableHeaderCell className="text-right">Lines</TableHeaderCell>
<th className="px-3 py-2 text-right font-semibold">Units</th> <TableHeaderCell className="text-right">Units</TableHeaderCell>
<th className="px-3 py-2 text-right font-semibold">Amount</th> <TableHeaderCell className="text-right">Amount</TableHeaderCell>
<th className="px-3 py-2 text-right font-semibold">Problems</th> <TableHeaderCell className="text-right">Problems</TableHeaderCell>
</tr> </TableRow>
</thead> </TableHeader>
<tbody className="divide-y divide-slate-100"> <TableBody>
{summary.byStore.map((s) => ( {summary.byStore.map((s) => (
<tr key={s.locationid} className={s.errors > 0 ? 'bg-red-50' : 'bg-white'}> <TableRow key={s.locationid} className={s.errors > 0 ? 'bg-red-50' : 'bg-white'}>
<td className="px-3 py-2 font-medium text-slate-800"> <TableCell className="font-medium text-slate-800">
{s.locationname || `Outlet ${s.locationid}`} {s.locationname || `Outlet ${s.locationid}`}
<span className="ml-1.5 font-mono text-[10px] text-slate-400">#{s.locationid}</span> <span className="ml-1.5 font-mono text-[10px] text-slate-400">#{s.locationid}</span>
</td> </TableCell>
<td className="px-3 py-2 text-right text-slate-600">{s.lines}</td> <TableCell className="text-right text-slate-600">{s.lines}</TableCell>
<td className="px-3 py-2 text-right text-slate-600">{s.units}</td> <TableCell className="text-right text-slate-600">{s.units}</TableCell>
<td className="px-3 py-2 text-right font-semibold text-slate-800">{money(s.amount)}</td> <TableCell className="text-right font-semibold text-slate-800">{money(s.amount)}</TableCell>
<td <TableCell
className={`px-3 py-2 text-right font-semibold ${ className={`text-right font-semibold ${
s.errors > 0 ? 'text-red-700' : 'text-emerald-700' s.errors > 0 ? 'text-red-700' : 'text-emerald-700'
}`} }`}
> >
{s.errors} {s.errors}
</td> </TableCell>
</tr> </TableRow>
))} ))}
</tbody> </TableBody>
</table> </Table>
</div> </div>
)} )}
@@ -399,45 +403,45 @@ export default function OfflineSalesUpload({
)} )}
<div className="max-h-72 overflow-auto rounded-lg border border-slate-200"> <div className="max-h-72 overflow-auto rounded-lg border border-slate-200">
<table className="w-full min-w-[920px] text-left text-xs"> <Table className="w-full min-w-[920px] text-left text-xs">
<thead className="sticky top-0 bg-slate-100 text-[11px] uppercase tracking-wide text-slate-600"> <TableHeader>
<tr> <TableRow>
<th className="px-3 py-2 font-semibold">Row</th> <TableHeaderCell>Row</TableHeaderCell>
<th className="px-3 py-2 font-semibold">Store</th> <TableHeaderCell>Store</TableHeaderCell>
<th className="px-3 py-2 font-semibold">Product</th> <TableHeaderCell>Product</TableHeaderCell>
<th className="px-3 py-2 text-right font-semibold">Qty</th> <TableHeaderCell className="text-right">Qty</TableHeaderCell>
<th className="px-3 py-2 text-right font-semibold">Price</th> <TableHeaderCell className="text-right">Price</TableHeaderCell>
<th className="px-3 py-2 text-right font-semibold">Amount</th> <TableHeaderCell className="text-right">Amount</TableHeaderCell>
<th className="px-3 py-2 font-semibold">Bill</th> <TableHeaderCell>Bill</TableHeaderCell>
<th className="px-3 py-2 font-semibold">Status</th> <TableHeaderCell>Status</TableHeaderCell>
</tr> </TableRow>
</thead> </TableHeader>
<tbody className="divide-y divide-slate-100"> <TableBody>
{parsed.rows.map((r) => { {parsed.rows.map((r) => {
const bad = r.errors.length > 0; const bad = r.errors.length > 0;
const warn = !bad && r.warnings.length > 0; const warn = !bad && r.warnings.length > 0;
return ( return (
<tr <TableRow
key={r.excelRow} key={r.excelRow}
className={bad ? 'bg-red-50' : warn ? 'bg-amber-50' : 'bg-white'} className={bad ? 'bg-red-50' : warn ? 'bg-amber-50' : 'bg-white'}
> >
<td className="px-3 py-2 font-mono text-slate-500">{r.excelRow}</td> <TableCell className="font-mono text-slate-500">{r.excelRow}</TableCell>
<td className="px-3 py-2 text-slate-700"> <TableCell className="text-slate-700">
{r.locationname || `#${r.locationid}`} {r.locationname || `#${r.locationid}`}
</td> </TableCell>
<td className="px-3 py-2"> <TableCell>
<span className="font-medium text-slate-800">{r.productname || '—'}</span> <span className="font-medium text-slate-800">{r.productname || '—'}</span>
<span className="ml-1.5 font-mono text-[10px] text-slate-400">#{r.productid}</span> <span className="ml-1.5 font-mono text-[10px] text-slate-400">#{r.productid}</span>
</td> </TableCell>
<td className="px-3 py-2 text-right font-semibold text-slate-800">{r.qtysold}</td> <TableCell className="text-right font-semibold text-slate-800">{r.qtysold}</TableCell>
<td className="px-3 py-2 text-right text-slate-600"> <TableCell className="text-right text-slate-600">
{r.unitprice ? money(r.unitprice) : <span className="text-amber-600">—</span>} {r.unitprice ? money(r.unitprice) : <span className="text-amber-600">—</span>}
</td> </TableCell>
<td className="px-3 py-2 text-right text-slate-600"> <TableCell className="text-right text-slate-600">
{money(Math.max(0, (r.unitprice ?? 0) * r.qtysold - r.discountamount))} {money(Math.max(0, (r.unitprice ?? 0) * r.qtysold - r.discountamount))}
</td> </TableCell>
<td className="px-3 py-2 font-mono text-slate-500">{r.billno || '—'}</td> <TableCell className="font-mono text-slate-500">{r.billno || '—'}</TableCell>
<td className="px-3 py-2"> <TableCell>
{bad ? ( {bad ? (
<span className="text-red-700">{r.errors.join('; ')}</span> <span className="text-red-700">{r.errors.join('; ')}</span>
) : warn ? ( ) : warn ? (
@@ -447,12 +451,12 @@ export default function OfflineSalesUpload({
<CheckCircle2 size={12} /> ready <CheckCircle2 size={12} /> ready
</span> </span>
)} )}
</td> </TableCell>
</tr> </TableRow>
); );
})} })}
</tbody> </TableBody>
</table> </Table>
</div> </div>
</> </>
)} )}
@@ -476,29 +480,29 @@ export default function OfflineSalesUpload({
</p> </p>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{parsed && ( {parsed && (
<button <Button
label="Clear the loaded file"
icon={<RotateCcw size={14} />}
onClick={reset} onClick={reset}
style={{ height: 'auto' }}
className="flex items-center gap-1.5 rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-100" className="flex items-center gap-1.5 rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-100"
> >
<RotateCcw size={14} /> Clear Clear
</button> </Button>
)} )}
<button <Button
label={
uploadMutation.isPending
? 'Importing…'
: `Import ${summary?.bills ? `${summary.bills} Bill${summary.bills === 1 ? '' : 's'}` : 'Sales'}`
}
icon={uploadMutation.isPending ? undefined : <Upload size={16} />}
isLoading={uploadMutation.isPending}
isDisabled={!parsed || blocked || uploadMutation.isPending}
onClick={() => uploadMutation.mutate()} onClick={() => uploadMutation.mutate()}
disabled={!parsed || blocked || uploadMutation.isPending} style={{ height: 'auto' }}
className="flex items-center gap-2 rounded-lg bg-[#662582] px-5 py-2 text-sm font-semibold text-white transition-colors hover:bg-[#551f6d] disabled:cursor-not-allowed disabled:opacity-40" className="flex items-center gap-2 rounded-lg bg-[#662582] px-5 py-2 text-sm font-semibold text-white border-none transition-colors hover:bg-[#551f6d]"
> />
{uploadMutation.isPending ? (
<>
<Loader2 size={16} className="animate-spin" /> Importing…
</>
) : (
<>
<Upload size={16} /> Import{' '}
{summary?.bills ? `${summary.bills} Bill${summary.bills === 1 ? '' : 's'}` : 'Sales'}
</>
)}
</button>
</div> </div>
</div> </div>
)} )}
@@ -563,29 +567,29 @@ function ResultPanel({
</div> </div>
<div className="overflow-hidden rounded-lg border border-slate-200"> <div className="overflow-hidden rounded-lg border border-slate-200">
<table className="w-full text-left text-xs"> <Table className="w-full text-left text-xs">
<thead className="bg-slate-100 text-[11px] uppercase tracking-wide text-slate-600"> <TableHeader>
<tr> <TableRow>
<th className="px-3 py-2 font-semibold">Store</th> <TableHeaderCell>Store</TableHeaderCell>
<th className="px-3 py-2 font-semibold">Bill</th> <TableHeaderCell>Bill</TableHeaderCell>
<th className="px-3 py-2 font-semibold">Result</th> <TableHeaderCell>Result</TableHeaderCell>
<th className="px-3 py-2 font-semibold">Order</th> <TableHeaderCell>Order</TableHeaderCell>
<th className="px-3 py-2 text-right font-semibold">Items</th> <TableHeaderCell className="text-right">Items</TableHeaderCell>
<th className="px-3 py-2 text-right font-semibold">Amount</th> <TableHeaderCell className="text-right">Amount</TableHeaderCell>
<th className="px-3 py-2 font-semibold">Detail</th> <TableHeaderCell>Detail</TableHeaderCell>
</tr> </TableRow>
</thead> </TableHeader>
<tbody className="divide-y divide-slate-100"> <TableBody>
{result.results.map((r, i) => ( {result.results.map((r, i) => (
<tr <TableRow
key={`${r.locationid}-${r.billno}-${i}`} key={`${r.locationid}-${r.billno}-${i}`}
className={ className={
r.status === 'imported' ? 'bg-white' : r.status === 'duplicate' ? 'bg-slate-50' : 'bg-red-50' r.status === 'imported' ? 'bg-white' : r.status === 'duplicate' ? 'bg-slate-50' : 'bg-red-50'
} }
> >
<td className="px-3 py-2 text-slate-700">{r.locationname || (r.locationid ? `#${r.locationid}` : '—')}</td> <TableCell className="text-slate-700">{r.locationname || (r.locationid ? `#${r.locationid}` : '—')}</TableCell>
<td className="px-3 py-2 font-mono text-slate-700">{r.billno || '—'}</td> <TableCell className="font-mono text-slate-700">{r.billno || '—'}</TableCell>
<td className="px-3 py-2"> <TableCell>
{r.status === 'imported' && ( {r.status === 'imported' && (
<span className="rounded bg-emerald-100 px-2 py-0.5 font-semibold text-emerald-800">imported</span> <span className="rounded bg-emerald-100 px-2 py-0.5 font-semibold text-emerald-800">imported</span>
)} )}
@@ -595,30 +599,30 @@ function ResultPanel({
{r.status === 'failed' && ( {r.status === 'failed' && (
<span className="rounded bg-red-100 px-2 py-0.5 font-semibold text-red-800">failed</span> <span className="rounded bg-red-100 px-2 py-0.5 font-semibold text-red-800">failed</span>
)} )}
</td> </TableCell>
<td className="px-3 py-2 font-mono text-slate-600">{r.orderid || '—'}</td> <TableCell className="font-mono text-slate-600">{r.orderid || '—'}</TableCell>
<td className="px-3 py-2 text-right text-slate-600">{r.itemcount || '—'}</td> <TableCell className="text-right text-slate-600">{r.itemcount || '—'}</TableCell>
<td className="px-3 py-2 text-right text-slate-600">{r.amount ? money(r.amount) : '—'}</td> <TableCell className="text-right text-slate-600">{r.amount ? money(r.amount) : '—'}</TableCell>
<td className="px-3 py-2 text-slate-600">{r.message}</td> <TableCell className="text-slate-600">{r.message}</TableCell>
</tr> </TableRow>
))} ))}
</tbody> </TableBody>
</table> </Table>
</div> </div>
<div className="mt-6 flex justify-end gap-3"> <div className="mt-6 flex justify-end gap-3">
<button <Button
label="Upload Another File"
onClick={onAnother} onClick={onAnother}
style={{ height: 'auto' }}
className="rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-100" className="rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-100"
> />
Upload Another File <Button
</button> label="Done"
<button
onClick={onClose} onClick={onClose}
className="rounded-lg bg-[#662582] px-5 py-2 text-sm font-semibold text-white transition-colors hover:bg-[#551f6d]" style={{ height: 'auto' }}
> className="rounded-lg bg-[#662582] px-5 py-2 text-sm font-semibold text-white border-none transition-colors hover:bg-[#551f6d]"
Done />
</button>
</div> </div>
</div> </div>
); );

View File

@@ -3,6 +3,10 @@ import { createPortal } from 'react-dom';
import { useFiestaOrderDetails } from '../services/fiestaQueries'; import { useFiestaOrderDetails } from '../services/fiestaQueries';
import { num as fnum, str as fstr, type Row } from '../services/fiestaApi'; import { num as fnum, str as fstr, type Row } from '../services/fiestaApi';
import { X, Calendar, MapPin, Loader2, CreditCard } from 'lucide-react'; import { X, Calendar, MapPin, Loader2, CreditCard } from 'lucide-react';
import { IconButton } from '@astryxdesign/core/IconButton';
import {
Table, TableHeader, TableHeaderCell, TableBody, TableRow, TableCell,
} from '@astryxdesign/core/Table';
import './OrderDetailsModal.css'; import './OrderDetailsModal.css';
interface OrderDetailsModalProps { interface OrderDetailsModalProps {
@@ -76,9 +80,16 @@ export default function OrderDetailsModal({ order, onClose }: OrderDetailsModalP
{orderDate} {orderDate}
</div> </div>
</div> </div>
<button className="odm-close-btn" onClick={onClose}> {/* Had no text and no aria-label at all — the only way out of this
<X size={18} /> modal announced itself as "button". */}
</button> <IconButton
icon={<X size={18} />}
label={`Close order ${orderId} details`}
variant="ghost"
onClick={onClose}
className="odm-close-btn"
style={{ height: 'auto', width: 'auto', minWidth: 0 }}
/>
</div> </div>
<div className="odm-status-row"> <div className="odm-status-row">
@@ -98,16 +109,16 @@ export default function OrderDetailsModal({ order, onClose }: OrderDetailsModalP
) : ( ) : (
<> <>
<div className="odm-section-title">Order Items</div> <div className="odm-section-title">Order Items</div>
<table className="odm-items-table"> <Table className="odm-items-table">
<thead> <TableHeader>
<tr> <TableRow>
<th>Item</th> <TableHeaderCell>Item</TableHeaderCell>
<th className="text-right">Price</th> <TableHeaderCell className="text-right">Price</TableHeaderCell>
<th className="text-right">Qty</th> <TableHeaderCell className="text-right">Qty</TableHeaderCell>
<th className="text-right">Total</th> <TableHeaderCell className="text-right">Total</TableHeaderCell>
</tr> </TableRow>
</thead> </TableHeader>
<tbody> <TableBody>
{details && details.length > 0 ? ( {details && details.length > 0 ? (
details.map((item, i) => { details.map((item, i) => {
const name = fstr(item.productname) || fstr(item.itemname) || 'Item'; const name = fstr(item.productname) || fstr(item.itemname) || 'Item';
@@ -115,23 +126,23 @@ export default function OrderDetailsModal({ order, onClose }: OrderDetailsModalP
const price = fnum(item.price) || fnum(item.unitprice) || fnum(item.retailprice) || 0; const price = fnum(item.price) || fnum(item.unitprice) || fnum(item.retailprice) || 0;
const lineTotal = fnum(item.amount) || fnum(item.productsumprice) || (qty * price); const lineTotal = fnum(item.amount) || fnum(item.productsumprice) || (qty * price);
return ( return (
<tr key={i}> <TableRow key={i}>
<td> <TableCell>
<div className="odm-item-name">{name}</div> <div className="odm-item-name">{name}</div>
</td> </TableCell>
<td className="text-right text-slate-500">₹{price.toLocaleString('en-IN', { maximumFractionDigits: 0 })}</td> <TableCell className="text-right text-slate-500">₹{price.toLocaleString('en-IN', { maximumFractionDigits: 0 })}</TableCell>
<td className="text-right text-slate-500">x{qty}</td> <TableCell className="text-right text-slate-500">x{qty}</TableCell>
<td className="text-right font-bold text-slate-800">₹{lineTotal.toLocaleString('en-IN', { maximumFractionDigits: 0 })}</td> <TableCell className="text-right font-bold text-slate-800">₹{lineTotal.toLocaleString('en-IN', { maximumFractionDigits: 0 })}</TableCell>
</tr> </TableRow>
); );
}) })
) : ( ) : (
<tr> <TableRow>
<td colSpan={4} className="text-center text-slate-400 py-4">No line items found.</td> <TableCell colSpan={4} className="text-center text-slate-400 py-4">No line items found.</TableCell>
</tr> </TableRow>
)} )}
</tbody> </TableBody>
</table> </Table>
<div className="odm-totals-section"> <div className="odm-totals-section">
<div className="odm-total-row"> <div className="odm-total-row">

View File

@@ -772,8 +772,10 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
{/* PAY keeps its own emerald face and full width: it is the one {/* PAY keeps its own emerald face and full width: it is the one
control the whole screen exists to reach, and it is hit hundreds of control the whole screen exists to reach, and it is hit hundreds of
times a shift. Everything around it is Astryx; this stays a times a shift. Keeping that face never required keeping a raw
deliberate exception, marked as one. */} <button> — the classes ride on an Astryx Button unchanged. What it
gains is a name that says what is about to be charged: the visible
word stays "PAY", while a screen reader hears the amount. */}
<div className="flex gap-2 pos-pay-row"> <div className="flex gap-2 pos-pay-row">
<IconButton <IconButton
label="Clear cart" label="Clear cart"
@@ -783,13 +785,16 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
isDisabled={cart.length === 0} isDisabled={cart.length === 0}
onClick={clearCart} onClick={clearCart}
/> />
<button <Button
label={grandTotal < 0 ? `Refund ₹${Math.abs(grandTotal).toFixed(2)}` : `Pay ₹${grandTotal.toFixed(2)}`}
endContent={<ChevronRight className="w-5 h-5" />}
isDisabled={cart.length === 0 || !customer}
onClick={handleCheckoutBtn} onClick={handleCheckoutBtn}
disabled={cart.length === 0 || !customer} style={{ height: 48 }}
className="flex-1 h-12 bg-emerald-600 hover:bg-emerald-700 disabled:bg-slate-200 disabled:text-slate-400 text-white rounded-xl font-black text-lg transition-all shadow-lg shadow-emerald-600/25 disabled:shadow-none flex items-center justify-center gap-2 active:scale-[0.98]" className="flex-1 h-12 bg-emerald-600 hover:bg-emerald-700 disabled:bg-slate-200 disabled:text-slate-400 text-white border-none rounded-xl font-black text-lg transition-all shadow-lg shadow-emerald-600/25 disabled:shadow-none flex items-center justify-center gap-2 active:scale-[0.98]"
> >
PAY <ChevronRight className="w-5 h-5" /> PAY
</button> </Button>
</div> </div>
</div> </div>
</div> </div>
@@ -828,12 +833,22 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
// whole surface is the hit target, which Button's fixed // whole surface is the hit target, which Button's fixed
// padding and centred label cannot express. It already had a // padding and centred label cannot express. It already had a
// disabled state and a text label, so nothing is lost. // disabled state and a text label, so nothing is lost.
<button // Each row's name was assembled from the four scraps of text
// inside it, so a reader heard the SKU, price and stock line
// run together before knowing what the product was.
<Button
key={product.id} key={product.id}
disabled={product.closing <= 0} label={
product.closing <= 0
? `${product.name} — out of stock`
: `Add ${product.name}, ₹${product.price.toFixed(2)} per ${product.unit}`
}
isDisabled={product.closing <= 0}
onClick={() => { handleProductScanned(product); setSearch(''); }} onClick={() => { handleProductScanned(product); setSearch(''); }}
className="w-full text-left px-5 py-3.5 flex justify-between items-center border-b border-slate-100 hover:bg-slate-50 transition-colors disabled:opacity-40 disabled:cursor-not-allowed group" style={{ height: 'auto' }}
className="w-full text-left px-5 py-3.5 flex justify-between items-center border-b border-slate-100 bg-transparent hover:bg-slate-50 transition-colors group"
> >
<span className="w-full flex justify-between items-center">
<div> <div>
<p className="font-bold text-slate-900 text-sm">{product.name}</p> <p className="font-bold text-slate-900 text-sm">{product.name}</p>
<p className="text-[11px] text-slate-400 font-mono mt-0.5">SKU: {product.sku}</p> <p className="text-[11px] text-slate-400 font-mono mt-0.5">SKU: {product.sku}</p>
@@ -844,7 +859,8 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
{product.closing <= 0 ? 'Out of stock' : `${product.closing} in stock`} {product.closing <= 0 ? 'Out of stock' : `${product.closing} in stock`}
</p> </p>
</div> </div>
</button> </span>
</Button>
))} ))}
</div> </div>
</PosModal> </PosModal>
@@ -1118,13 +1134,19 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
{/* Same exception as PAY, and for the same reason: this is the {/* Same exception as PAY, and for the same reason: this is the
commit point of the transaction. */} commit point of the transaction. */}
<button <Button
label={
grandTotal < 0
? `Confirm refund of ₹${Math.abs(grandTotal).toFixed(2)}`
: `Confirm payment of ₹${grandTotal.toFixed(2)}`
}
isDisabled={payMethod === 'store_credit' && !customer}
onClick={confirmPayment} onClick={confirmPayment}
disabled={payMethod === 'store_credit' && !customer} style={{ height: 'auto' }}
className="w-full py-4 bg-emerald-600 hover:bg-emerald-700 disabled:bg-slate-200 disabled:text-slate-400 text-white rounded-xl font-black text-lg transition-all shadow-lg shadow-emerald-600/25 disabled:shadow-none" className="w-full py-4 bg-emerald-600 hover:bg-emerald-700 disabled:bg-slate-200 disabled:text-slate-400 text-white border-none rounded-xl font-black text-lg transition-all shadow-lg shadow-emerald-600/25 disabled:shadow-none"
> >
Confirm {grandTotal < 0 ? 'Refund' : 'Payment'} Confirm {grandTotal < 0 ? 'Refund' : 'Payment'}
</button> </Button>
</div> </div>
</PosModal> </PosModal>
)} )}

View File

@@ -38,6 +38,12 @@ import {
} from '../services/fiestaQueries'; } from '../services/fiestaQueries';
import { FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID, num as fnum, str as fstr, ymd } from '../services/fiestaApi'; import { FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID, num as fnum, str as fstr, ymd } from '../services/fiestaApi';
import { stockRowToProduct } from '../services/fiestaMappers'; import { stockRowToProduct } from '../services/fiestaMappers';
import { Button } from '@astryxdesign/core/Button';
import { IconButton } from '@astryxdesign/core/IconButton';
import { Selector } from '@astryxdesign/core/Selector';
import {
Table, TableHeader, TableHeaderCell, TableBody, TableRow, TableCell,
} from '@astryxdesign/core/Table';
import AwaitingApi from './AwaitingApi'; import AwaitingApi from './AwaitingApi';
import SalesRevenueReport from './SalesRevenueReport'; import SalesRevenueReport from './SalesRevenueReport';
import { useNotify } from './Toasts'; import { useNotify } from './Toasts';
@@ -507,37 +513,49 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
return ( return (
<div className="space-y-lg animate-in fade-in duration-500 font-sans"> <div className="space-y-lg animate-in fade-in duration-500 font-sans">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 relative z-20 -mt-2 pb-2 border-b border-slate-200/70"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 relative z-20 -mt-2 pb-2 border-b border-slate-200/70">
{/* PHASE 6 — Button + aria-pressed, NOT Astryx's TabList.
TabList looks like the obvious fit and is not: it renders a <nav>
and marks the active entry `aria-current="page"`, which is the
navigation pattern. These two switch an in-page panel and never
touch the URL, so claiming a page changed would be plainly untrue.
Astryx ships no ARIA tablist, so this uses the same pressed-button
pattern as the other two single-select groups on this page, and
keeps the original underline styling exactly. */}
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<button {([
onClick={() => setActiveTab('overview')} { id: 'overview', label: 'Overview' },
className={`pb-1 text-sm font-bold border-b-2 transition-colors ${activeTab === 'overview' ? 'border-[#662582] text-[#662582]' : 'border-transparent text-slate-500 hover:text-slate-700'}`} { id: 'sales_revenue', label: 'Sales & Revenue' },
> ] as const).map((t) => (
Overview <Button
</button> key={t.id}
<button label={t.label}
onClick={() => setActiveTab('sales_revenue')} aria-pressed={activeTab === t.id}
className={`pb-1 text-sm font-bold border-b-2 transition-colors ${activeTab === 'sales_revenue' ? 'border-[#662582] text-[#662582]' : 'border-transparent text-slate-500 hover:text-slate-700'}`} variant="ghost"
> onClick={() => setActiveTab(t.id)}
Sales & Revenue style={{ height: 'auto' }}
</button> className={`pb-1 text-sm font-bold border-b-2 rounded-none bg-transparent transition-colors ${activeTab === t.id ? 'border-[#662582] text-[#662582]' : 'border-transparent text-slate-500 hover:text-slate-700'}`}
/>
))}
</div> </div>
{/* Global Store Filter */} {/* Global Store Filter */}
{activeTab === 'sales_revenue' && ( {activeTab === 'sales_revenue' && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Store size={16} className="text-slate-500" /> <Store size={16} className="text-slate-500" />
<select <Selector
value={activeStoreId} label="Filter reports by store"
onChange={(e) => setActiveStoreId(e.target.value === 'all' ? 'all' : Number(e.target.value))} isLabelHidden
className="text-sm font-bold outline-none rounded-lg px-3 py-1 border border-slate-200 bg-white text-slate-700 focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all cursor-pointer" size="sm"
> value={String(activeStoreId)}
<option value="all">All Stores</option> onChange={(v) => setActiveStoreId(v === 'all' ? 'all' : Number(v))}
{regionLocations.map(loc => ( options={[
<option key={loc.locationid} value={Number(loc.locationid)}> { value: 'all', label: 'All Stores' },
{loc.locationname || `Store ${loc.locationid}`} ...regionLocations.map((loc) => ({
</option> value: String(Number(loc.locationid)),
))} label: loc.locationname || `Store ${loc.locationid}`,
</select> })),
]}
/>
</div> </div>
)} )}
</div> </div>
@@ -587,11 +605,21 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
} }
return ( return (
<button // PHASE 6 — these four cards choose which metric the chart below
// plots, but said so only by growing a coloured ring. `aria-pressed`
// makes the choice audible, and `label` names what the card does
// rather than leaving the reader to assemble a name out of the
// title, trend, value and status text crammed inside it.
<Button
key={kpi.id} key={kpi.id}
label={`Chart ${kpi.title}`}
aria-pressed={isActive}
variant="ghost"
onClick={() => setChartMetric(kpi.id)} onClick={() => setChartMetric(kpi.id)}
style={{ height: 'auto' }}
className={`rounded-xl p-3 flex flex-col justify-between transition-all duration-300 text-left cursor-pointer outline-none relative group border ${activeStyles}`} className={`rounded-xl p-3 flex flex-col justify-between transition-all duration-300 text-left cursor-pointer outline-none relative group border ${activeStyles}`}
> >
<span className="w-full flex flex-col justify-between">
<div className="flex justify-between items-start w-full"> <div className="flex justify-between items-start w-full">
<span className="text-[10px] text-slate-500 uppercase tracking-widest font-bold">{kpi.title}</span> <span className="text-[10px] text-slate-500 uppercase tracking-widest font-bold">{kpi.title}</span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -633,7 +661,8 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
</svg> </svg>
</div> </div>
</div> </div>
</button> </span>
</Button>
); );
})} })}
</div> </div>
@@ -668,12 +697,13 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
{selectedCell.day} · {selectedCell.hour} : {selectedCell.val} orders {selectedCell.day} · {selectedCell.hour} : {selectedCell.val} orders
</span> </span>
</div> </div>
<button <Button
label="Dismiss"
variant="ghost"
onClick={() => setSelectedCell(null)} onClick={() => setSelectedCell(null)}
className="text-xs font-bold text-purple-700 hover:text-purple-900 cursor-pointer border-none bg-transparent hover:underline px-2" style={{ height: 'auto' }}
> className="text-xs font-bold text-purple-700 hover:text-purple-900 cursor-pointer hover:underline px-2"
Dismiss />
</button>
</div> </div>
)} )}
@@ -682,40 +712,48 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
{insightQ.isLoading ? 'Loading weekly order distribution…' : 'No order insight available for this region.'} {insightQ.isLoading ? 'Loading weekly order distribution…' : 'No order insight available for this region.'}
</div> </div>
) : ( ) : (
<table className="w-full text-center border-collapse text-xs font-sans"> <Table className="w-full text-center text-xs font-sans">
<thead> <TableHeader>
<tr> <TableRow>
<th className="p-2 text-[9px] font-bold text-slate-400 w-32 uppercase italic text-left pl-4">Outlet Name</th> <TableHeaderCell className="w-32 text-left">Outlet Name</TableHeaderCell>
{WEEK_LABELS.map((w) => ( {WEEK_LABELS.map((w) => (
<th key={w} className="p-2 text-[10px] font-bold text-slate-800 border-b border-slate-100"> <TableHeaderCell key={w} className="text-center">{w}</TableHeaderCell>
{w}
</th>
))} ))}
</tr> </TableRow>
</thead> </TableHeader>
<tbody> <TableBody>
{insightRows.map((row, idx) => ( {insightRows.map((row, idx) => (
<tr key={idx}> <TableRow key={idx}>
<td className="p-2 text-left font-bold text-slate-800 tracking-wide truncate max-w-[8rem] border-r border-slate-100 pl-4"> <TableCell className="text-left font-bold text-slate-800 tracking-wide truncate max-w-[8rem] border-r border-slate-100">
{row.name} {row.name}
</td> </TableCell>
{WEEK_KEYS.map((key, mIdx) => { {WEEK_KEYS.map((key, mIdx) => {
const val = fnum(row.weeks[key]); const val = fnum(row.weeks[key]);
const isPicked =
selectedCell?.day === row.name && selectedCell?.hour === WEEK_LABELS[mIdx];
return ( return (
<td key={key} className="p-1 border border-white"> <TableCell key={key} className="p-1">
<button {/* Every one of these announced itself as nothing
but its own number — "12", with no hint of which
outlet or which week produced it. The label now
carries the whole cell. */}
<Button
label={`${row.name}, ${WEEK_LABELS[mIdx]}: ${val} orders`}
aria-pressed={isPicked}
variant="ghost"
onClick={() => setSelectedCell({ day: row.name, hour: WEEK_LABELS[mIdx], val })} onClick={() => setSelectedCell({ day: row.name, hour: WEEK_LABELS[mIdx], val })}
className={`w-full py-2.5 rounded-lg font-semibold transition-all duration-200 cursor-pointer border-none ${getHeatmapColorClass(val)}`} style={{ height: 'auto' }}
className={`w-full py-2.5 rounded-lg font-semibold transition-all duration-200 cursor-pointer ${getHeatmapColorClass(val)}`}
> >
{val} {val}
</button> </Button>
</td> </TableCell>
); );
})} })}
</tr> </TableRow>
))} ))}
</tbody> </TableBody>
</table> </Table>
)} )}
</div> </div>
</div> </div>
@@ -907,56 +945,60 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
{/* Quick interactive filter pills */} {/* Quick interactive filter pills */}
<div className="flex gap-2 text-[10px] font-bold uppercase tracking-wider"> <div className="flex gap-2 text-[10px] font-bold uppercase tracking-wider">
{(['All', 'Healthy', 'Low Stock', 'Critical'] as const).map((filter) => ( {(['All', 'Healthy', 'Low Stock', 'Critical'] as const).map((filter) => (
<button <Button
key={filter} key={filter}
label={filter === 'All' ? 'Show all products' : `Show ${filter} products`}
aria-pressed={stockFilter === filter}
variant="ghost"
onClick={() => setStockFilter(filter)} onClick={() => setStockFilter(filter)}
style={{ height: 'auto' }}
className={`px-3 py-1.5 rounded-lg cursor-pointer transition-colors border ${stockFilter === filter className={`px-3 py-1.5 rounded-lg cursor-pointer transition-colors border ${stockFilter === filter
? 'bg-[#662582] text-white border-[#662582] shadow-sm' ? 'bg-[#662582] text-white border-[#662582] shadow-sm'
: 'bg-white border-[#e2e8f0] text-zinc-655 hover:bg-zinc-55' : 'bg-white border-[#e2e8f0] text-zinc-655 hover:bg-zinc-55'
}`} }`}
> >
{filter} {filter}
</button> </Button>
))} ))}
</div> </div>
</div> </div>
{/* Matrix Data table */} {/* Matrix Data table */}
<div className="overflow-x-auto text-xs font-sans"> <div className="overflow-x-auto text-xs font-sans">
<table className="w-full text-left"> <Table className="w-full text-left">
<thead className="bg-[#f8fafc] border-b border-[#e2e8f0] text-zinc-500 text-[9px] uppercase font-bold tracking-wider"> <TableHeader>
<tr> <TableRow>
<th className="w-10 px-md py-sm"></th> <TableHeaderCell className="w-10"><span className="sr-only">Expand row</span></TableHeaderCell>
<th className="px-md py-sm">Product Name</th> <TableHeaderCell>Product Name</TableHeaderCell>
<th className="px-md py-sm">SKU ID</th> <TableHeaderCell>SKU ID</TableHeaderCell>
<th className="px-md py-sm text-right">Units Sold</th> <TableHeaderCell className="text-right">Units Sold</TableHeaderCell>
<th className="px-md py-sm text-right">Overall Revenue</th> <TableHeaderCell className="text-right">Overall Revenue</TableHeaderCell>
<th className="px-md py-sm text-right">Stock Status</th> <TableHeaderCell className="text-right">Stock Status</TableHeaderCell>
</tr> </TableRow>
</thead> </TableHeader>
<tbody className="divide-y divide-[#f1f5f9]"> <TableBody>
{paginatedProducts.length === 0 ? ( {paginatedProducts.length === 0 ? (
<tr> <TableRow>
<td colSpan={6} className="text-center py-8 text-zinc-400 font-medium"> <TableCell colSpan={6} className="text-center py-8 text-zinc-400 font-medium">
No matching items matching stock filter criteria. No matching items matching stock filter criteria.
</td> </TableCell>
</tr> </TableRow>
) : ( ) : (
paginatedProducts.map((prod) => { paginatedProducts.map((prod) => {
const isExpanded = expandedProductId === prod.id; const isExpanded = expandedProductId === prod.id;
return ( return (
<React.Fragment key={prod.id}> <React.Fragment key={prod.id}>
<tr <TableRow
onClick={() => setExpandedProductId(isExpanded ? null : prod.id)} onClick={() => setExpandedProductId(isExpanded ? null : prod.id)}
className={`hover:bg-[#f2f4f6]/40 transition-all font-medium text-zinc-700 cursor-pointer ${isExpanded ? 'bg-slate-50/50' : ''}`} className={`transition-all font-medium text-zinc-700 cursor-pointer ${isExpanded ? 'bg-slate-50/50' : ''}`}
> >
<td className="px-md py-md text-center text-zinc-400"> <TableCell className="text-center text-zinc-400">
<ChevronDown <ChevronDown
size={14} size={14}
className={`transition-transform duration-200 ${isExpanded ? 'rotate-180 text-purple-650' : 'text-zinc-400'}`} className={`transition-transform duration-200 ${isExpanded ? 'rotate-180 text-purple-650' : 'text-zinc-400'}`}
/> />
</td> </TableCell>
<td className="px-md py-md flex items-center gap-sm"> <TableCell className="flex items-center gap-sm">
<div className="w-8 h-8 rounded-lg shrink-0 border border-[#e2e8f0] overflow-hidden bg-zinc-50"> <div className="w-8 h-8 rounded-lg shrink-0 border border-[#e2e8f0] overflow-hidden bg-zinc-50">
<img <img
src={prod.image} src={prod.image}
@@ -969,17 +1011,17 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
<span className="font-bold text-[#0f172a] block">{prod.name}</span> <span className="font-bold text-[#0f172a] block">{prod.name}</span>
<span className="text-[9px] text-zinc-455 block font-semibold uppercase mt-0.5">{prod.category}</span> <span className="text-[9px] text-zinc-455 block font-semibold uppercase mt-0.5">{prod.category}</span>
</div> </div>
</td> </TableCell>
<td className="px-md py-md font-mono text-zinc-500 font-semibold"> <TableCell className="font-mono text-zinc-500 font-semibold">
{prod.sku} {prod.sku}
</td> </TableCell>
<td className="px-md py-md text-right font-mono text-zinc-655 font-bold"> <TableCell className="text-right font-mono text-zinc-655 font-bold">
{prod.unitsSold.toLocaleString()} {prod.unitsSold.toLocaleString()}
</td> </TableCell>
<td className="px-md py-md text-right font-mono text-zinc-800 font-extrabold"> <TableCell className="text-right font-mono text-zinc-800 font-extrabold">
₹{prod.revenue.toLocaleString()} ₹{prod.revenue.toLocaleString()}
</td> </TableCell>
<td className="px-md py-md text-right"> <TableCell className="text-right">
<span className={`px-2 py-0.5 rounded-full text-[9px] font-bold uppercase tracking-wide inline-block ${prod.stockStatus === 'Healthy' <span className={`px-2 py-0.5 rounded-full text-[9px] font-bold uppercase tracking-wide inline-block ${prod.stockStatus === 'Healthy'
? 'bg-emerald-50 text-emerald-600 border border-emerald-100' ? 'bg-emerald-50 text-emerald-600 border border-emerald-100'
: prod.stockStatus === 'Low Stock' : prod.stockStatus === 'Low Stock'
@@ -988,8 +1030,8 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
}`}> }`}>
{prod.stockStatus} {prod.stockStatus}
</span> </span>
</td> </TableCell>
</tr> </TableRow>
{/* Expanded details row */} {/* Expanded details row */}
{isExpanded && (() => { {isExpanded && (() => {
@@ -1022,8 +1064,8 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
}).filter(Boolean); }).filter(Boolean);
return ( return (
<tr className="bg-slate-50/50 border-b border-[#e2e8f0]"> <TableRow className="bg-slate-50/50 border-b border-[#e2e8f0]">
<td colSpan={6} className="p-0"> <TableCell colSpan={6} className="p-0">
<div className="px-8 py-6 bg-white border-y border-slate-100 shadow-[inset_0_4px_6px_-4px_rgba(0,0,0,0.05)] animate-in slide-in-from-top-2 duration-300"> <div className="px-8 py-6 bg-white border-y border-slate-100 shadow-[inset_0_4px_6px_-4px_rgba(0,0,0,0.05)] animate-in slide-in-from-top-2 duration-300">
<h4 className="text-xs font-bold text-slate-800 mb-4 flex items-center gap-2"> <h4 className="text-xs font-bold text-slate-800 mb-4 flex items-center gap-2">
<div className="w-1.5 h-1.5 rounded-full bg-purple-600 animate-pulse"></div> <div className="w-1.5 h-1.5 rounded-full bg-purple-600 animate-pulse"></div>
@@ -1067,16 +1109,16 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
</div> </div>
)} )}
</div> </div>
</td> </TableCell>
</tr> </TableRow>
); );
})()} })()}
</React.Fragment> </React.Fragment>
); );
}) })
)} )}
</tbody> </TableBody>
</table> </Table>
</div> </div>
{/* Matrix table pagination */} {/* Matrix table pagination */}
@@ -1084,25 +1126,32 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
<span>Showing {Math.min((currentPage - 1) * ITEMS_PER_PAGE + 1, filteredProducts.length)}-{Math.min(currentPage * ITEMS_PER_PAGE, filteredProducts.length)} of {filteredProducts.length} filtered products (from {liveProducts.length} total)</span> <span>Showing {Math.min((currentPage - 1) * ITEMS_PER_PAGE + 1, filteredProducts.length)}-{Math.min(currentPage * ITEMS_PER_PAGE, filteredProducts.length)} of {filteredProducts.length} filtered products (from {liveProducts.length} total)</span>
<div className="flex gap-xs"> <div className="flex gap-xs">
<button {/* Both were icon-only <button>s with no text and no aria-label,
so the pager read as "button, button" either side of the page
count. */}
<IconButton
icon={<ChevronLeft size={12} />}
label="Previous page"
tooltip="Previous page"
isDisabled={currentPage === 1}
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))} onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
disabled={currentPage === 1} style={{ height: 32, width: 32, minWidth: 0, background: 'transparent' }}
className={`w-8 h-8 border border-[#e2e8f0] rounded-lg flex items-center justify-center cursor-pointer transition-colors ${currentPage === 1 ? 'opacity-50 cursor-not-allowed' : 'hover:bg-white active:bg-[#f8fafc]'}`} className="w-8 h-8 border border-[#e2e8f0] rounded-lg flex items-center justify-center cursor-pointer transition-colors hover:bg-white active:bg-[#f8fafc]"
> />
<ChevronLeft size={12} />
</button>
<div className="flex items-center px-2 font-bold text-[10px] text-zinc-600"> <div className="flex items-center px-2 font-bold text-[10px] text-zinc-600">
Page {currentPage} of {totalPages} Page {currentPage} of {totalPages}
</div> </div>
<button <IconButton
icon={<ChevronRight size={12} />}
label="Next page"
tooltip="Next page"
isDisabled={currentPage === totalPages}
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))} onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
disabled={currentPage === totalPages} style={{ height: 32, width: 32, minWidth: 0, background: 'transparent' }}
className={`w-8 h-8 border border-[#e2e8f0] rounded-lg flex items-center justify-center cursor-pointer transition-colors ${currentPage === totalPages ? 'opacity-50 cursor-not-allowed' : 'hover:bg-white active:bg-[#f8fafc]'}`} className="w-8 h-8 border border-[#e2e8f0] rounded-lg flex items-center justify-center cursor-pointer transition-colors hover:bg-white active:bg-[#f8fafc]"
> />
<ChevronRight size={12} />
</button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -25,10 +25,17 @@ import { FIESTA_TENANT_ID, num as fnum, str as fstr, ymd, type Row } from '../se
// ... inside the component ... // ... inside the component ...
import { shortTime } from '../services/fiestaMappers'; import { shortTime } from '../services/fiestaMappers';
import { Button } from '@astryxdesign/core/Button';
import { DateInput } from '@astryxdesign/core/DateInput';
import type { ISODateString } from '@astryxdesign/core/utils';
import { Selector } from '@astryxdesign/core/Selector';
import { import {
KpiStrip, Pill, FilterBar, TH_STYLE, Table, TableHeader, TableHeaderCell, TableBody, TableRow, TableCell,
BRAND, BRAND_LIGHT, TEXT, TEXT_2, TEXT_3, BORDER, DIVIDER, SURFACE_ALT, } from '@astryxdesign/core/Table';
tint, soft, edge, ring import {
KpiStrip, Pill, FilterBar,
BRAND, BRAND_LIGHT, TEXT, TEXT_2, TEXT_3, BORDER, SURFACE_ALT,
ring
} from './consoleUi'; } from './consoleUi';
import { ResponsiveContainer, AreaChart, Area, BarChart, Bar, PieChart, Pie, Cell, XAxis, YAxis, Tooltip as RechartsTooltip, CartesianGrid } from 'recharts'; import { ResponsiveContainer, AreaChart, Area, BarChart, Bar, PieChart, Pie, Cell, XAxis, YAxis, Tooltip as RechartsTooltip, CartesianGrid } from 'recharts';
@@ -242,20 +249,39 @@ export default function SalesRevenueReport({
</div> </div>
<div className="flex items-center gap-3 flex-wrap lg:justify-end min-w-0"> <div className="flex items-center gap-3 flex-wrap lg:justify-end min-w-0">
{/* PHASE 6 — both were unlabelled `<input type="date">`, announcing
themselves as bare date fields with no hint of which end of the
range they set. The min/max cross-guard is preserved, including
`todayStr` as the ceiling on the To date. */}
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<input type="date" value={fromdate} max={todate} onChange={(e) => { setFromdate(e.target.value); setPageno(1); }} className="rounded-full outline-none font-semibold text-xs transition-colors" style={{ padding: '6px 12px', border: `1.5px solid ${edge(BRAND)}`, background: tint(BRAND), color: BRAND }} /> <DateInput
label="From date"
isLabelHidden
size="sm"
value={fromdate as ISODateString}
max={todate as ISODateString}
onChange={(v) => { if (v) { setFromdate(v); setPageno(1); } }}
/>
<span className="text-zinc-400 font-bold px-1 text-xs">—</span> <span className="text-zinc-400 font-bold px-1 text-xs">—</span>
<input type="date" value={todate} min={fromdate} max={todayStr} onChange={(e) => { setTodate(e.target.value); setPageno(1); }} className="rounded-full outline-none font-semibold text-xs transition-colors" style={{ padding: '6px 12px', border: `1.5px solid ${edge(BRAND)}`, background: tint(BRAND), color: BRAND }} /> <DateInput
label="To date"
isLabelHidden
size="sm"
value={todate as ISODateString}
min={fromdate as ISODateString}
max={todayStr as ISODateString}
onChange={(v) => { if (v) { setTodate(v); setPageno(1); } }}
/>
</div> </div>
<button <Button
label="Export CSV"
icon={<Download size={13} />}
isDisabled={tableRows.length === 0}
tooltip="Export to CSV"
onClick={exportCsv} onClick={exportCsv}
disabled={tableRows.length === 0} className="inline-flex items-center gap-1.5 rounded-full font-extrabold text-white cursor-pointer whitespace-nowrap shrink-0"
title="Export to CSV" style={{ height: 'auto', padding: '8px 14px', fontSize: 12, border: 'none', color: '#fff', background: `linear-gradient(135deg, ${BRAND}, ${BRAND_LIGHT})`, boxShadow: `0 6px 18px ${ring(BRAND)}` }}
className="inline-flex items-center gap-1.5 rounded-full font-extrabold text-white cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed whitespace-nowrap shrink-0" />
style={{ padding: '8px 14px', fontSize: 12, background: `linear-gradient(135deg, ${BRAND}, ${BRAND_LIGHT})`, boxShadow: `0 6px 18px ${ring(BRAND)}` }}
>
<Download size={13} /> Export CSV
</button>
</div> </div>
</div> </div>
</FilterBar> </FilterBar>
@@ -415,77 +441,75 @@ export default function SalesRevenueReport({
<h3 className="text-sm font-bold text-slate-800">Completed Order Ledger</h3> <h3 className="text-sm font-bold text-slate-800">Completed Order Ledger</h3>
</div> </div>
{!locationid && uniqueStoreNames.length > 0 && ( {!locationid && uniqueStoreNames.length > 0 && (
<select <Selector
label="Filter ledger by store"
isLabelHidden
size="sm"
value={storeFilter} value={storeFilter}
onChange={(e) => { setStoreFilter(e.target.value); setPageno(1); }} onChange={(v) => { setStoreFilter(v); setPageno(1); }}
className="text-xs font-bold outline-none rounded-lg px-3 py-1.5 border border-slate-200 bg-white text-slate-700 focus:ring-2 focus:ring-emerald-500 focus:border-transparent transition-all cursor-pointer" options={[
> { value: 'All', label: 'All Stores' },
<option value="All">All Stores</option> ...uniqueStoreNames.map((store) => ({ value: store, label: store })),
{uniqueStoreNames.map(store => ( ]}
<option key={store} value={store}>{store}</option> />
))}
</select>
)} )}
</div> </div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full" style={{ minWidth: 800 }}> <Table style={{ minWidth: 800 }}>
<thead> <TableHeader>
<tr> <TableRow>
{['#', 'Order', 'Branch', 'Customer', 'Qty', 'Payment', 'Amount (₹)'].map((h, i) => ( {['#', 'Order', 'Branch', 'Customer', 'Qty', 'Payment', 'Amount (₹)'].map((h, i) => (
<th key={i} className={`px-3 py-2.5 ${i === 6 ? 'text-right' : 'text-left'}`} style={TH_STYLE}>{h}</th> <TableHeaderCell key={i} className={i === 6 ? 'text-right' : 'text-left'}>{h}</TableHeaderCell>
))} ))}
</tr> </TableRow>
</thead> </TableHeader>
<tbody> <TableBody>
{allOrdersQ.isLoading ? ( {allOrdersQ.isLoading ? (
<tr><td colSpan={7} className="px-3 py-12 text-center" style={{ color: TEXT_3 }}> <TableRow><TableCell colSpan={7} className="px-3 py-12 text-center" style={{ color: TEXT_3 }}>
<span className="inline-flex items-center gap-2 text-xs font-semibold"> <span className="inline-flex items-center gap-2 text-xs font-semibold">
<Loader2 size={15} className="animate-spin" style={{ color: BRAND }} /> Loading data… <Loader2 size={15} className="animate-spin" style={{ color: BRAND }} /> Loading data…
</span> </span>
</td></tr> </TableCell></TableRow>
) : pageRows.length === 0 ? ( ) : pageRows.length === 0 ? (
<tr><td colSpan={7} className="px-3 py-12 text-center text-xs" style={{ color: TEXT_3 }}> <TableRow><TableCell colSpan={7} className="px-3 py-12 text-center text-xs" style={{ color: TEXT_3 }}>
No completed orders found for this date range. No completed orders found for this date range.
</td></tr> </TableCell></TableRow>
) : ( ) : (
pageRows.map((r, i) => { pageRows.map((r, i) => {
const amount = fnum(r.ordervalue) || fnum(r.orderamount) || fnum(r.deliveryamt); const amount = fnum(r.ordervalue) || fnum(r.orderamount) || fnum(r.deliveryamt);
return ( return (
<tr <TableRow
key={fstr(r.orderid) || fstr(r.orderheaderid) || i} key={fstr(r.orderid) || fstr(r.orderheaderid) || i}
className="transition-colors align-top" className="align-top"
style={{ borderBottom: `1px solid ${DIVIDER}` }}
onMouseEnter={(e) => (e.currentTarget.style.background = SURFACE_ALT)}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
> >
<td className="px-3 py-2.5 font-mono" style={{ color: TEXT_3 }}>{(pageno - 1) * PAGE_SIZE + i + 1}</td> <TableCell className="font-mono" style={{ color: TEXT_3 }}>{(pageno - 1) * PAGE_SIZE + i + 1}</TableCell>
<td className="px-3 py-2.5"> <TableCell>
<p className="font-extrabold font-mono text-[13px]" style={{ color: TEXT }}>{fstr(r.orderid) || `#${fstr(r.orderheaderid)}`}</p> <p className="font-extrabold font-mono text-[13px]" style={{ color: TEXT }}>{fstr(r.orderid) || `#${fstr(r.orderheaderid)}`}</p>
<p className="text-[10px]" style={{ color: TEXT_2 }}>{shortTime(r.orderdate || r.deliverydate)}</p> <p className="text-[10px]" style={{ color: TEXT_2 }}>{shortTime(r.orderdate || r.deliverydate)}</p>
</td> </TableCell>
<td className="px-3 py-2.5"> <TableCell>
<span className="inline-flex items-center gap-1 font-bold text-[12px]" style={{ color: BRAND }}> <span className="inline-flex items-center gap-1 font-bold text-[12px]" style={{ color: BRAND }}>
<MapPin size={11} /> {fstr(r.applocation) || fstr(r.locationname) || '—'} <MapPin size={11} /> {fstr(r.applocation) || fstr(r.locationname) || '—'}
</span> </span>
</td> </TableCell>
<td className="px-3 py-2.5"> <TableCell>
<p className="font-bold text-[12px] truncate max-w-[140px]" style={{ color: TEXT }}>{fstr(r.deliverycustomer) || fstr(r.pickupcustomer) || fstr(r.tenantname) || '—'}</p> <p className="font-bold text-[12px] truncate max-w-[140px]" style={{ color: TEXT }}>{fstr(r.deliverycustomer) || fstr(r.pickupcustomer) || fstr(r.tenantname) || '—'}</p>
</td> </TableCell>
<td className="px-3 py-2.5 font-mono text-[12px]" style={{ color: TEXT }}>{fnum(r.quantity) || '—'}</td> <TableCell className="font-mono text-[12px]" style={{ color: TEXT }}>{fnum(r.quantity) || '—'}</TableCell>
<td className="px-3 py-2.5"> <TableCell>
<span className="inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-black tracking-wide bg-slate-100 text-slate-600"> <span className="inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-black tracking-wide bg-slate-100 text-slate-600">
{(fstr(r.paymentmode) || fstr(r.paymenttype) || 'CASH').toUpperCase()} {(fstr(r.paymentmode) || fstr(r.paymenttype) || 'CASH').toUpperCase()}
</span> </span>
</td> </TableCell>
<td className="px-3 py-2.5 text-right font-mono text-[13px] font-bold text-emerald-600"> <TableCell className="text-right font-mono text-[13px] font-bold text-emerald-600">
{amount > 0 ? `₹${amount.toLocaleString('en-IN')}` : '—'} {amount > 0 ? `₹${amount.toLocaleString('en-IN')}` : '—'}
</td> </TableCell>
</tr> </TableRow>
); );
}) })
)} )}
</tbody> </TableBody>
</table> </Table>
</div> </div>
{/* Pagination */} {/* Pagination */}
@@ -494,16 +518,26 @@ export default function SalesRevenueReport({
Page {pageno} · {pageRows.length} of {tableRows.length} shown Page {pageno} · {pageRows.length} of {tableRows.length} shown
</span> </span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button disabled={pageno === 1} onClick={() => setPageno((p) => Math.max(1, p - 1))} <Button
className="inline-flex items-center gap-1 rounded-full font-bold transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed" label="Previous page"
style={{ padding: '6px 12px', fontSize: 11, border: `1px solid ${BORDER}`, background: '#fff', color: TEXT_2 }}> icon={<ChevronLeft size={13} />}
<ChevronLeft size={13} /> Prev isDisabled={pageno === 1}
</button> onClick={() => setPageno((p) => Math.max(1, p - 1))}
<button disabled={!hasNext} onClick={() => setPageno((p) => p + 1)} className="inline-flex items-center gap-1 rounded-full font-bold cursor-pointer"
className="inline-flex items-center gap-1 rounded-full font-bold transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed" style={{ height: 'auto', padding: '6px 12px', fontSize: 11, border: `1px solid ${BORDER}`, background: '#fff', color: TEXT_2 }}
style={{ padding: '6px 12px', fontSize: 11, border: `1px solid ${BORDER}`, background: '#fff', color: TEXT_2 }}> >
Next <ChevronRight size={13} /> Prev
</button> </Button>
<Button
label="Next page"
endContent={<ChevronRight size={13} />}
isDisabled={!hasNext}
onClick={() => setPageno((p) => p + 1)}
className="inline-flex items-center gap-1 rounded-full font-bold cursor-pointer"
style={{ height: 'auto', padding: '6px 12px', fontSize: 11, border: `1px solid ${BORDER}`, background: '#fff', color: TEXT_2 }}
>
Next
</Button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,6 +1,7 @@
import React, { useMemo, useRef } from 'react'; import React, { useMemo, useRef } from 'react';
import { QRCodeSVG, QRCodeCanvas } from 'qrcode.react'; import { QRCodeSVG, QRCodeCanvas } from 'qrcode.react';
import { MapPin, ScanLine, AlertTriangle, Download } from 'lucide-react'; import { MapPin, ScanLine, AlertTriangle, Download } from 'lucide-react';
import { Button } from '@astryxdesign/core/Button';
import { buildStoreQrPayload } from '../services/fiestaApi'; import { buildStoreQrPayload } from '../services/fiestaApi';
interface StoreQRViewProps { interface StoreQRViewProps {
@@ -119,13 +120,13 @@ export default function StoreQRView({
{/* Download Action Button */} {/* Download Action Button */}
<div className="w-full mt-6"> <div className="w-full mt-6">
<button <Button
label="Download QR Code"
icon={<Download size={14} />}
onClick={downloadPng} onClick={downloadPng}
style={{ height: 'auto' }}
className="w-full flex items-center justify-center gap-2 py-3 px-5 bg-gradient-to-r from-[#662582] to-indigo-650 hover:from-purple-800 hover:to-indigo-700 text-white rounded-xl text-xs font-bold uppercase tracking-wider cursor-pointer active:scale-95 transition-all shadow-md border-none" className="w-full flex items-center justify-center gap-2 py-3 px-5 bg-gradient-to-r from-[#662582] to-indigo-650 hover:from-purple-800 hover:to-indigo-700 text-white rounded-xl text-xs font-bold uppercase tracking-wider cursor-pointer active:scale-95 transition-all shadow-md border-none"
> />
<Download size={14} />
Download QR Code
</button>
</div> </div>
{/* Hidden high-res canvas — source for the PNG download */} {/* Hidden high-res canvas — source for the PNG download */}

View File

@@ -28,6 +28,8 @@ import {
useFiestaOrderSummary, useFiestaOrderSummary,
FIESTA_TENANT_ID, FIESTA_TENANT_ID,
} from '../services/fiestaQueries'; } from '../services/fiestaQueries';
import { Button } from '@astryxdesign/core/Button';
import { IconButton } from '@astryxdesign/core/IconButton';
import { str as fstr, num as fnum, roleName, ymd } from '../services/fiestaApi'; import { str as fstr, num as fnum, roleName, ymd } from '../services/fiestaApi';
import type { AuthUser } from '../services/auth'; import type { AuthUser } from '../services/auth';
import Header from './Header'; import Header from './Header';
@@ -240,15 +242,17 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
<p className="text-[15px] text-slate-500 leading-relaxed mb-6"> <p className="text-[15px] text-slate-500 leading-relaxed mb-6">
We couldn’t reach the store service. Check your connection and try again. We couldn’t reach the store service. Check your connection and try again.
</p> </p>
<button <Button
label="Retry loading your store"
onClick={() => { onClick={() => {
locationsQ.refetch(); locationsQ.refetch();
orderSummaryQ.refetch(); orderSummaryQ.refetch();
}} }}
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" style={{ height: 'auto' }}
className="px-5 py-2.5 bg-[#662582] hover:bg-purple-800 text-white text-sm font-bold rounded-xl border-none cursor-pointer transition-colors shadow-sm"
> >
Retry Retry
</button> </Button>
</div> </div>
</div> </div>
); );
@@ -431,14 +435,15 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
onClick={(e) => { if (e.target === e.currentTarget) setShowQrModal(false); }} onClick={(e) => { if (e.target === e.currentTarget) setShowQrModal(false); }}
> >
<div className="relative w-full mx-auto" style={{ maxWidth: 600 }}> <div className="relative w-full mx-auto" style={{ maxWidth: 600 }}>
<button <IconButton
icon={<X size={16} />}
label="Close store QR code"
tooltip="Close"
variant="ghost"
onClick={() => setShowQrModal(false)} onClick={() => setShowQrModal(false)}
title="Close" style={{ height: 'auto', width: 'auto', minWidth: 0 }}
aria-label="Close"
className="absolute top-3 right-3 z-10 p-1.5 rounded-full bg-white/15 hover:bg-white/25 text-white ring-1 ring-white/25 backdrop-blur-sm cursor-pointer transition-colors" className="absolute top-3 right-3 z-10 p-1.5 rounded-full bg-white/15 hover:bg-white/25 text-white ring-1 ring-white/25 backdrop-blur-sm cursor-pointer transition-colors"
> />
<X size={16} />
</button>
<StoreQRView <StoreQRView
tenantId={tenantId} tenantId={tenantId}
locationid={resolvedLocationId || undefined} locationid={resolvedLocationId || undefined}

View File

@@ -18,6 +18,9 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { X, Info } from 'lucide-react'; import { X, Info } from 'lucide-react';
import { Button } from '@astryxdesign/core/Button';
import { IconButton } from '@astryxdesign/core/IconButton';
import { TextInput } from '@astryxdesign/core/TextInput';
import { Card as AxCard } from '@astryxdesign/core/Card'; import { Card as AxCard } from '@astryxdesign/core/Card';
import { Skeleton as AxSkeleton } from '@astryxdesign/core/Skeleton'; import { Skeleton as AxSkeleton } from '@astryxdesign/core/Skeleton';
import { Tooltip as AxTooltip } from '@astryxdesign/core/Tooltip'; import { Tooltip as AxTooltip } from '@astryxdesign/core/Tooltip';
@@ -218,18 +221,53 @@ interface PillProps {
children: React.ReactNode; children: React.ReactNode;
count?: number | string; count?: number | string;
} }
/**
* Flattens a node to its text, so a Pill whose children are `<Icon /> Label`
* can still name itself. Only used to build the accessible name.
*/
function textOf(node: React.ReactNode): string {
if (node == null || typeof node === 'boolean') return '';
if (typeof node === 'string' || typeof node === 'number') return String(node);
if (Array.isArray(node)) return node.map(textOf).join('');
if (React.isValidElement(node)) return textOf((node.props as { children?: React.ReactNode }).children);
return '';
}
export function Pill({ active, color, onClick, title, children, count }: PillProps) { export function Pill({ active, color, onClick, title, children, count }: PillProps) {
// PHASE 6 — Astryx Button carrying the pill's own visual.
//
// Why Button and not ToggleButton, which is the semantic match: ToggleButton
// destructures `className` as `_className` and throws it away, and every
// colour a Pill wears arrives at runtime through the `color` prop (fed from
// ORDER_STATUS / DELIVERY_STATUS). Losing the class hook means losing the
// layout with it. Button keeps both, and `aria-pressed` can be set by hand.
//
// What this fixes: the pill announced nothing about being chosen. Selection
// was carried by fill colour alone, so the eight filter bars built on this
// read as eight identical buttons to a screen reader. `aria-pressed` is not
// the last word — every call site is single-select (`active={tab === t.key}`),
// which really wants a radiogroup — but that is a change to the call sites,
// and this is a change to one file.
//
// The name is assembled rather than left to the text content so the count
// reads as its own word: "Pending, 12" instead of "Pending12".
const name = [title ?? textOf(children).trim(), count != null ? String(count) : '']
.filter(Boolean)
.join(', ');
return ( return (
<button <Button
label={name}
onClick={onClick} onClick={onClick}
title={title} tooltip={title}
aria-pressed={active}
className="inline-flex items-center gap-1.5 rounded-full font-bold whitespace-nowrap transition-all duration-150 cursor-pointer shrink-0" className="inline-flex items-center gap-1.5 rounded-full font-bold whitespace-nowrap transition-all duration-150 cursor-pointer shrink-0"
style={ style={
active active
? { padding: '5px 12px', fontSize: 12.5, background: color, color: '#fff', border: `1.5px solid ${color}`, boxShadow: `0 6px 18px ${ring(color)}` } ? { height: 'auto', padding: '5px 12px', fontSize: 12.5, background: color, color: '#fff', border: `1.5px solid ${color}`, boxShadow: `0 6px 18px ${ring(color)}` }
: { padding: '5px 12px', fontSize: 12.5, background: tint(color), color, border: `1.5px solid ${edge(color)}` } : { height: 'auto', padding: '5px 12px', fontSize: 12.5, background: tint(color), color, border: `1.5px solid ${edge(color)}` }
} }
> >
<span className="inline-flex items-center gap-1.5">
{children} {children}
{count != null && ( {count != null && (
<span <span
@@ -243,7 +281,8 @@ export function Pill({ active, color, onClick, title, children, count }: PillPro
{count} {count}
</span> </span>
)} )}
</button> </span>
</Button>
); );
} }
@@ -284,27 +323,49 @@ export function StampCell({ date, time }: { date?: string; time?: string }) {
// ── Search pill ────────────────────────────────────────────────────────────────── // ── Search pill ──────────────────────────────────────────────────────────────────
export function SearchPill({ value, onChange, placeholder, color = BRAND, inputRef }: { value: string; onChange: (v: string) => void; placeholder?: string; color?: string; inputRef?: React.Ref<HTMLInputElement> }) { export function SearchPill({ value, onChange, placeholder, color = BRAND, inputRef }: { value: string; onChange: (v: string) => void; placeholder?: string; color?: string; inputRef?: React.Ref<HTMLInputElement> }) {
// PHASE 6 — Astryx TextInput.
//
// The defect this closes: the field had no label at all. It carried a
// placeholder, which vanishes the moment anyone types and is not a label to
// begin with, so the control announced itself as an unnamed text box on every
// filter bar in the console. `label` + `isLabelHidden` gives it a real
// accessible name and keeps the compact pill.
//
// Astryx puts `className`/`style` on the input *wrapper* — the bordered box
// itself — so the tinted pill survives intact. The manual onFocus/onBlur
// ring is gone with it: the wrapper has its own :focus-within treatment, and
// hand-painting a second one on top only fought it.
const name = placeholder || 'Search';
return ( return (
<div className="relative w-full"> <div className="relative w-full">
<svg className="absolute left-3 top-1/2 -translate-y-1/2" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2.5" strokeLinecap="round"> <TextInput
ref={inputRef}
label={name}
isLabelHidden
value={value}
onChange={(v) => onChange(v)}
placeholder={placeholder}
startIcon={
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2.5" strokeLinecap="round">
<circle cx="11" cy="11" r="7" /> <circle cx="11" cy="11" r="7" />
<path d="m21 21-4.3-4.3" /> <path d="m21 21-4.3-4.3" />
</svg> </svg>
<input }
ref={inputRef} className="w-full rounded-full transition-all box-border"
type="text" style={{ height: 38, paddingLeft: 12, paddingRight: value ? 30 : 14, fontSize: 12.5, background: tint(color), border: `1.5px solid ${edge(color)}`, color: TEXT }}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="block w-full rounded-full outline-none font-medium transition-all box-border"
style={{ height: 38, paddingLeft: 32, paddingRight: value ? 30 : 14, fontSize: 12.5, background: tint(color), border: `1.5px solid ${edge(color)}`, color: TEXT }}
onFocus={(e) => { e.currentTarget.style.borderColor = color; e.currentTarget.style.boxShadow = `0 0 0 3px ${ring(color)}`; }}
onBlur={(e) => { e.currentTarget.style.borderColor = edge(color); e.currentTarget.style.boxShadow = 'none'; }}
/> />
{value && ( {value && (
<button onClick={() => onChange('')} className="absolute right-3 top-1/2 -translate-y-1/2 cursor-pointer" style={{ color: TEXT_3 }} title="Clear"> <IconButton
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"><path d="M18 6 6 18M6 6l12 12" /></svg> icon={<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"><path d="M18 6 6 18M6 6l12 12" /></svg>}
</button> // Not `Clear ${name}` — placeholders here carry shortcut hints, and
// lowercasing one gave "Clear search orders (ctrl+k)…".
label="Clear search"
tooltip="Clear"
variant="ghost"
onClick={() => onChange('')}
className="absolute right-2 top-1/2 -translate-y-1/2 cursor-pointer z-10"
style={{ height: 'auto', width: 'auto', minWidth: 0, padding: 2, background: 'transparent', border: 'none', color: TEXT_3 }}
/>
)} )}
</div> </div>
); );
@@ -458,12 +519,18 @@ export function SlideDrawer({
{/* Minimalist Header */} {/* Minimalist Header */}
<div className="flex items-center justify-between px-7 py-5 bg-[#662582] border-b border-purple-900 z-10 shrink-0"> <div className="flex items-center justify-between px-7 py-5 bg-[#662582] border-b border-purple-900 z-10 shrink-0">
<h2 className="text-lg font-bold text-white tracking-tight">{title}</h2> <h2 className="text-lg font-bold text-white tracking-tight">{title}</h2>
<button {/* PHASE 6 — IconButton. It had no accessible name at all: an X glyph
in a button with no text and no aria-label, so the only way out of
the drawer announced itself as "button". */}
<IconButton
icon={<X size={20} strokeWidth={2} />}
label={`Close ${title}`}
tooltip="Close"
variant="ghost"
onClick={onClose} onClick={onClose}
className="w-8 h-8 flex items-center justify-center rounded-full hover:bg-white/10 text-white/80 hover:text-white transition-colors" className="w-8 h-8 flex items-center justify-center rounded-full hover:bg-white/10 text-white/80 hover:text-white transition-colors"
> style={{ height: 32, width: 32, minWidth: 0, background: 'transparent', border: 'none', color: 'rgba(255,255,255,0.8)' }}
<X size={20} strokeWidth={2} /> />
</button>
</div> </div>
{/* Scrollable Content */} {/* Scrollable Content */}