updates on the design on the kpi cards
This commit is contained in:
@@ -1,40 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { Heading } from '@astryxdesign/core/Heading';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
|
||||
// ===========================|| HOVER SOCIAL CARD ||=========================== //
|
||||
// Solid-colour metric tile used on the order-preview summary row. `color` is a
|
||||
// caller-supplied background hex, so it stays an inline style — Astryx's Card
|
||||
// `variant` palette can't express an arbitrary colour.
|
||||
|
||||
const HoverSocialCard = ({ primary, secondary, percentage, color, style }) => (
|
||||
<Card padding={4} elevation="none" height="100%" style={{ background: color, color: '#fff', border: 'none', ...style }}>
|
||||
<HStack justify="between" vAlign="start" gap={2}>
|
||||
<VStack gap={0.5} padding={0} minWidth={0}>
|
||||
<Heading level={2} color="inherit" maxLines={1}>
|
||||
{secondary}
|
||||
</Heading>
|
||||
<Text color="inherit" weight="medium">
|
||||
{primary}
|
||||
</Text>
|
||||
</VStack>
|
||||
<Text color="inherit" size="2xl" display="block">
|
||||
{typeof percentage === 'number' ? `${percentage} %` : percentage}
|
||||
</Text>
|
||||
</HStack>
|
||||
</Card>
|
||||
);
|
||||
|
||||
HoverSocialCard.propTypes = {
|
||||
primary: PropTypes.node,
|
||||
secondary: PropTypes.node,
|
||||
percentage: PropTypes.node,
|
||||
color: PropTypes.string,
|
||||
style: PropTypes.object
|
||||
};
|
||||
|
||||
export default HoverSocialCard;
|
||||
@@ -6,40 +6,83 @@ import { Heading } from '@astryxdesign/core/Heading';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { StatusDot } from '@astryxdesign/core/StatusDot';
|
||||
|
||||
// ==============================|| PAGE HEADER (Doormile-style clean title) ||============================== //
|
||||
// A plain, un-boxed page title + optional live subtitle + an action slot
|
||||
// (zone selector, primary button, etc.). Replaces the heavy boxed/gradient
|
||||
// header so every operator page opens with a calm, corporate heading.
|
||||
//
|
||||
// Astryx: Heading level={1} resolves to --text-heading-1-size (1.5rem), the
|
||||
// same size the old MUI `variant="h3"` rendered — the visual weight of every
|
||||
// page title is unchanged by the migration. The old responsive
|
||||
// `direction={{ xs: 'column', sm: 'row' }}` is now wrap="wrap" on a single
|
||||
// HStack: the action slot drops to its own line once it can't fit, which is
|
||||
// the behaviour that breakpoint was approximating.
|
||||
import { DT } from 'themes/dt/tokens';
|
||||
|
||||
export default function PageHeader({ title, subtitle, live = false, action }) {
|
||||
// ==============================|| PAGE HEADER ||============================== //
|
||||
//
|
||||
// A plain, un-boxed page title + optional live indicator + an action slot
|
||||
// (zone selector, primary button, etc.), so every operator page opens with a
|
||||
// calm, corporate heading.
|
||||
//
|
||||
// The STRUCTURE follows KROW's page frame; the colours and the type are the
|
||||
// console's own and are unchanged — `Heading level={1}` and `Text
|
||||
// type="supporting"` resolve to exactly the same tokens they did before.
|
||||
//
|
||||
// Title · meta · ● Live [ actions ]
|
||||
// subtitle
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Two things the KROW work changed:
|
||||
//
|
||||
// - The bottom hairline. KROW closes every page header with a rule, and it
|
||||
// is what makes the header read as a REGION of the page rather than as
|
||||
// the first item in the content stack — without it, a page header and a
|
||||
// section heading look identical and the page has no top.
|
||||
//
|
||||
// - `live` moved from beside the subtitle up onto the title row. It was
|
||||
// putting the page's only motion cue on its quietest line; a live
|
||||
// indicator belongs where people look for it. It is still the same
|
||||
// pulsing `StatusDot variant="success"` — nothing about its colour moved.
|
||||
//
|
||||
// `meta` is new and optional: a real count that belongs beside the name
|
||||
// ("1,248 orders"), with tabular figures so it doesn't jitter as it updates.
|
||||
//
|
||||
// `action` keeps its name and still wraps to its own line once it can't fit,
|
||||
// which is the behaviour the old responsive `direction={{ xs: 'column', sm:
|
||||
// 'row' }}` was approximating.
|
||||
|
||||
export default function PageHeader({ title, subtitle, meta, live = false, action }) {
|
||||
return (
|
||||
<HStack justify="between" vAlign="center" wrap="wrap" gap={2} paddingBlock={1}>
|
||||
<VStack gap={0.5} padding={0} minWidth={0}>
|
||||
<Heading level={1} maxLines={1}>
|
||||
{title}
|
||||
</Heading>
|
||||
{subtitle && (
|
||||
<HStack gap={1} vAlign="center">
|
||||
<VStack
|
||||
gap={0}
|
||||
padding={0}
|
||||
style={{
|
||||
paddingBottom: 16,
|
||||
// The existing hairline at 50% alpha — a page header's rule should sit
|
||||
// under the table borders it introduces, not match them.
|
||||
borderBottom: `1px solid ${DT.borderSubtle}80`
|
||||
}}
|
||||
>
|
||||
<HStack justify="between" vAlign="center" wrap="wrap" gap={2} paddingBlock={1}>
|
||||
{/* minWidth in `style` — VStack has no such prop (see StatCard). */}
|
||||
<VStack gap={0.5} padding={0} style={{ minWidth: 0 }}>
|
||||
<HStack gap={2} vAlign="center" wrap="wrap">
|
||||
<Heading level={1} maxLines={1}>
|
||||
{title}
|
||||
</Heading>
|
||||
{meta != null && (
|
||||
<Text type="supporting" hasTabularNumbers>
|
||||
{meta}
|
||||
</Text>
|
||||
)}
|
||||
{live && <StatusDot variant="success" label="Live" isPulsing />}
|
||||
<Text type="supporting">{subtitle}</Text>
|
||||
</HStack>
|
||||
{subtitle && <Text type="supporting">{subtitle}</Text>}
|
||||
</VStack>
|
||||
{action && (
|
||||
<HStack gap={1.5} vAlign="center" wrap="wrap" style={{ flexShrink: 0 }}>
|
||||
{action}
|
||||
</HStack>
|
||||
)}
|
||||
</VStack>
|
||||
{action && <HStack gap={1.5}>{action}</HStack>}
|
||||
</HStack>
|
||||
</HStack>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
PageHeader.propTypes = {
|
||||
title: PropTypes.node,
|
||||
subtitle: PropTypes.node,
|
||||
meta: PropTypes.node,
|
||||
live: PropTypes.bool,
|
||||
action: PropTypes.node
|
||||
};
|
||||
|
||||
@@ -62,7 +62,7 @@ const FILL_CLASS = 'dt-page-fill';
|
||||
// shell (e.g. Create Order's `.co-root` token block). It used to be dropped
|
||||
// silently, which made every page-scoped rule fail to match with no error —
|
||||
// the styles simply never applied.
|
||||
export function PageShell({ children, gap = 4, fill = false, className }) {
|
||||
export function PageShell({ children, gap = 3, fill = false, className }) {
|
||||
return (
|
||||
<>
|
||||
{fill && (
|
||||
@@ -106,8 +106,12 @@ PageShell.propTypes = {
|
||||
//
|
||||
// `max` caps the column count so 3 cards don't each become 400px wide on an
|
||||
// ultrawide monitor; it defaults to the number of children.
|
||||
//
|
||||
// minWidth is 200, fitting four across smoothly while keeping labels single-line.
|
||||
//
|
||||
// gap is 2.5 (10px) — tight, cohesive summary strip.
|
||||
// ---------------------------------------------------------------------------
|
||||
export function KpiGrid({ children, minWidth = 180, gap = 3, max }) {
|
||||
export function KpiGrid({ children, minWidth = 200, gap = 2.5, max }) {
|
||||
const count = Array.isArray(children) ? children.filter(Boolean).length : 1;
|
||||
return (
|
||||
<Grid columns={{ minWidth, max: max || Math.max(count, 1), repeat: 'fit' }} gap={gap}>
|
||||
|
||||
@@ -1,101 +1,208 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { Center } from '@astryxdesign/core/Center';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { Skeleton } from '@astryxdesign/core/Skeleton';
|
||||
|
||||
// ==============================|| STAT / KPI CARD (Doormile-style Modern) ||============================== //
|
||||
// Modern, space-efficient KPI tile:
|
||||
// - Left-aligned icon tile directly grouped with the metric stack to eliminate internal dead space.
|
||||
// - Soft ambient accent gradient tint based on semantic status color.
|
||||
// - Crisp typography and tight vertical footprint (~56px height) saving ~30% page height.
|
||||
// - Micro-hover interaction (subtle lift + shadow depth).
|
||||
//
|
||||
// `icon` is a rendered node, e.g. icon={<MdLocalShipping size={20} />}.
|
||||
// `color` is a hex accent (defaults to brand black); `caption` is optional supporting text.
|
||||
import { DT, STATUS } from 'themes/dt/tokens';
|
||||
|
||||
// ==============================|| STAT / KPI CARD ||============================== //
|
||||
//
|
||||
// Ultra-compact, dense, space-efficient enterprise KPI component.
|
||||
// Shared across Deliveries, Orders, Pricing, Tenants, Riders, Customers, Hubs,
|
||||
// Vehicles, Tripsheets, Exceptions, App Users, Reports and Dispatch.
|
||||
//
|
||||
// ---------------------------------------------------------------------------
|
||||
// ULTRA-COMPACT HORIZONTAL COMPOSITION (~48px target height)
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Icon chip: 26px × 26px with 6px radius and subtle 7% tint (`${accent}12`).
|
||||
// Glyph size normalized to 14px.
|
||||
// 2. Value: 18px bold (`lineHeight: 1`, `fontVariantNumeric: 'tabular-nums'`).
|
||||
// 3. Label: 12px / 500 in secondary ink (`#64748b`), aligned horizontally on
|
||||
// the same baseline beside the value for maximum space efficiency and instant
|
||||
// (<1s) visual scanning.
|
||||
// 4. Tight elevation, flat white surface, 8px rounded corners, 7px 12px padding.
|
||||
// 5. Interactive/clickable hover support with smooth 150ms transition. Static
|
||||
// cards remain clean without hover distraction.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MIN_HEIGHT = 48;
|
||||
|
||||
const VARIANTS = {
|
||||
neutral: STATUS.muted,
|
||||
brand: DT.brand,
|
||||
blue: STATUS.info,
|
||||
green: STATUS.delivered,
|
||||
amber: STATUS.pending,
|
||||
red: STATUS.cancelled
|
||||
};
|
||||
|
||||
export default function StatCard({
|
||||
title,
|
||||
value,
|
||||
icon,
|
||||
color,
|
||||
variant = 'brand',
|
||||
caption,
|
||||
loading = false,
|
||||
onClick,
|
||||
clickable,
|
||||
className = '',
|
||||
style = {}
|
||||
}) {
|
||||
const accent = color || VARIANTS[variant] || DT.brand;
|
||||
const isClickable = Boolean(onClick || clickable);
|
||||
|
||||
export default function StatCard({ title, value, icon, color = '#000000', caption, loading = false }) {
|
||||
return (
|
||||
<Card
|
||||
height="100%"
|
||||
padding={1.5}
|
||||
padding={0}
|
||||
elevation="none"
|
||||
className="dt-stat-card"
|
||||
className={['dt-stat-card', isClickable ? 'dt-stat-card-clickable' : '', className].filter(Boolean).join(' ')}
|
||||
data-clickable={isClickable ? 'true' : undefined}
|
||||
onClick={onClick}
|
||||
role={isClickable ? 'button' : undefined}
|
||||
tabIndex={isClickable ? 0 : undefined}
|
||||
onKeyDown={
|
||||
isClickable && onClick
|
||||
? (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onClick(e);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
style={{
|
||||
position: 'relative',
|
||||
background: `linear-gradient(135deg, ${color}0A 0%, rgba(255, 255, 255, 0.98) 45%, #ffffff 100%)`,
|
||||
border: `1px solid ${color}24`,
|
||||
borderRadius: 'var(--radius-container, 6px)',
|
||||
boxShadow: '0 1px 2px rgba(15, 23, 42, 0.04)',
|
||||
cursor: 'default'
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
minHeight: MIN_HEIGHT,
|
||||
maxHeight: 56,
|
||||
padding: '7px 12px',
|
||||
background: DT.surface,
|
||||
border: `1px solid ${DT.borderSubtle}`,
|
||||
borderRadius: 8,
|
||||
boxShadow: '0 1px 2px rgba(15, 23, 42, 0.03)',
|
||||
cursor: isClickable ? 'pointer' : 'default',
|
||||
userSelect: isClickable ? 'none' : 'auto',
|
||||
boxSizing: 'border-box',
|
||||
overflow: 'hidden',
|
||||
width: '100%',
|
||||
...style
|
||||
}}
|
||||
>
|
||||
<HStack vAlign="center" gap={2} style={{ width: '100%', minHeight: 40 }}>
|
||||
<div
|
||||
className="dt-stat-content"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
width: '100%',
|
||||
minWidth: 0
|
||||
}}
|
||||
>
|
||||
{icon && (
|
||||
<Center
|
||||
width={34}
|
||||
height={34}
|
||||
<div
|
||||
className="dt-stat-icon"
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
borderRadius: 'var(--radius-element, 6px)',
|
||||
backgroundColor: `${color}14`,
|
||||
color,
|
||||
border: `1px solid ${color}2A`
|
||||
width: 26,
|
||||
height: 26,
|
||||
minWidth: 26,
|
||||
minHeight: 26,
|
||||
borderRadius: 6,
|
||||
backgroundColor: `${accent}12`,
|
||||
color: accent,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</Center>
|
||||
</div>
|
||||
)}
|
||||
<VStack gap={0} padding={0} minWidth={0} style={{ flex: 1 }}>
|
||||
<Text
|
||||
type="supporting"
|
||||
size="xsm"
|
||||
weight="semibold"
|
||||
maxLines={1}
|
||||
|
||||
<div
|
||||
className="dt-stat-body"
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
minWidth: 0,
|
||||
flex: 1
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="dt-stat-primary"
|
||||
style={{
|
||||
fontSize: '11.5px',
|
||||
color: 'var(--color-text-supporting, #64748b)',
|
||||
letterSpacing: '0.01em',
|
||||
lineHeight: 1.2
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
gap: '6px',
|
||||
minWidth: 0,
|
||||
flexWrap: 'wrap'
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
{loading ? (
|
||||
<Skeleton width={48} height={20} radius={2} style={{ marginTop: 2 }} />
|
||||
) : (
|
||||
<Text
|
||||
size="lg"
|
||||
weight="bold"
|
||||
display="block"
|
||||
maxLines={1}
|
||||
hasTabularNumbers
|
||||
{loading ? (
|
||||
<Skeleton width={36} height={18} radius={DT.radiusSm} />
|
||||
) : (
|
||||
<span
|
||||
className="dt-stat-value"
|
||||
style={{
|
||||
fontFamily: 'var(--font-family-heading, Sora), -apple-system, BlinkMacSystemFont, sans-serif',
|
||||
fontSize: '18px',
|
||||
fontWeight: 700,
|
||||
lineHeight: 1,
|
||||
letterSpacing: '-0.02em',
|
||||
color: 'var(--color-text-primary, #0f172a)',
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span
|
||||
className="dt-stat-label"
|
||||
title={typeof title === 'string' ? title : undefined}
|
||||
style={{
|
||||
fontSize: '19px',
|
||||
lineHeight: 1.2,
|
||||
color: 'var(--color-text-primary, #0f172a)',
|
||||
marginTop: 1
|
||||
fontFamily: 'var(--font-family-body, Inter), -apple-system, BlinkMacSystemFont, sans-serif',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
lineHeight: 1,
|
||||
color: DT.textSecondary,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
flex: '1 1 auto',
|
||||
minWidth: 0
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</Text>
|
||||
)}
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{caption && (
|
||||
<Text
|
||||
type="supporting"
|
||||
size="xsm"
|
||||
display="block"
|
||||
style={{ fontSize: '11px', marginTop: 1 }}
|
||||
<span
|
||||
className="dt-stat-caption"
|
||||
title={typeof caption === 'string' ? caption : undefined}
|
||||
style={{
|
||||
fontFamily: 'var(--font-family-body, Inter), -apple-system, BlinkMacSystemFont, sans-serif',
|
||||
fontSize: '10.5px',
|
||||
lineHeight: '12px',
|
||||
color: DT.textMuted,
|
||||
marginTop: 1,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis'
|
||||
}}
|
||||
>
|
||||
{caption}
|
||||
</Text>
|
||||
</span>
|
||||
)}
|
||||
</VStack>
|
||||
</HStack>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -104,7 +211,14 @@ StatCard.propTypes = {
|
||||
title: PropTypes.node,
|
||||
value: PropTypes.node,
|
||||
icon: PropTypes.node,
|
||||
/** Raw accent hex. Takes precedence over `variant`. */
|
||||
color: PropTypes.string,
|
||||
/** Semantic alias over the same palette, for call sites not importing STATUS. */
|
||||
variant: PropTypes.oneOf(['neutral', 'brand', 'blue', 'green', 'amber', 'red']),
|
||||
caption: PropTypes.node,
|
||||
loading: PropTypes.bool
|
||||
loading: PropTypes.bool,
|
||||
onClick: PropTypes.func,
|
||||
clickable: PropTypes.bool,
|
||||
className: PropTypes.string,
|
||||
style: PropTypes.object
|
||||
};
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
/* Global visual polish that used to come from MUI's <CssBaseline>/<GlobalStyles>
|
||||
in themes/index.js (ThemeCustomization). Kept as plain CSS now that every
|
||||
page runs on Astryx — see root CLAUDE.md §6.6. Astryx's own reset
|
||||
page runs on Astryx — see root CLAUDE.md §6. Astryx's own reset
|
||||
(@astryxdesign/core/reset.css, imported in index.js) covers the base
|
||||
element resets; this file only carries the two enterprise-polish touches
|
||||
that reset doesn't: crisp font rendering and a thin, unobtrusive scrollbar. */
|
||||
element resets; this file carries the handful of things a design token
|
||||
cannot express: font rendering, scrollbar pseudo-elements, keyframes, the
|
||||
focus treatment, and the global reduced-motion rule.
|
||||
|
||||
NOTE ON COLOUR: nothing here introduces a colour. The console keeps its own
|
||||
palette (themes/dt/tokens.js) — the KROW work adopted KROW's geometry and
|
||||
type treatment only. Where a rule below needs an accent it reads
|
||||
`var(--color-accent)` rather than naming one. */
|
||||
|
||||
body {
|
||||
/* Astryx sets --font-family-body on :root (from themes/astryx.js's
|
||||
@@ -19,11 +25,58 @@ body {
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
/* Scrollbars are chrome, not content — they should recede until used.
|
||||
The thumb was rgba(0,0,0,0.26) at 10px, which drew a solid dark bar down the
|
||||
right edge of every scrollable table and read as a heavy border rather than
|
||||
as a scrollbar. Lightened and narrowed; it still darkens on hover so it stays
|
||||
findable. */
|
||||
/* --------------------------------------------------------------------------
|
||||
Motion vocabulary.
|
||||
|
||||
One set of durations and one easing curve for the whole app, so a hover, a
|
||||
row highlight and a card entrance all feel like the same product. Three
|
||||
steps only: `fast` for colour-only changes (anything slower feels laggy
|
||||
under a moving cursor), `base` for the default, `slow` for layout and
|
||||
entrances.
|
||||
|
||||
Prefixed --dt-* deliberately. These live on :root, and Dispatch.css ALSO
|
||||
writes :root from a lazy-loaded chunk — an unprefixed name there would win
|
||||
or lose purely on bundle order rather than on intent.
|
||||
-------------------------------------------------------------------------- */
|
||||
:root {
|
||||
--dt-ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--dt-ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
|
||||
--dt-duration-fast: 120ms;
|
||||
--dt-duration-base: 200ms;
|
||||
--dt-duration-slow: 320ms;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Focus.
|
||||
|
||||
Focus is never the browser default. One ring, one width, everywhere. The
|
||||
colour is the app's own accent token, not a value chosen here, so the ring
|
||||
follows the brand automatically.
|
||||
|
||||
:focus-visible, not :focus — a mouse click on a button should not leave a
|
||||
ring behind it.
|
||||
-------------------------------------------------------------------------- */
|
||||
.dt-focus-ring:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px #ffffff, 0 0 0 4px var(--color-accent);
|
||||
}
|
||||
|
||||
/* The inset variant, for a control flush inside a row or table cell where an
|
||||
offset ring would be clipped by the overflow container. */
|
||||
.dt-focus-ring-inset:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: inset 0 0 0 2px var(--color-accent);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Scrollbars.
|
||||
|
||||
Chrome, not content — they should recede until used. The thumb was
|
||||
rgba(0,0,0,0.26) at 10px, which drew a solid dark bar down the right edge of
|
||||
every scrollable table and read as a heavy border rather than as a
|
||||
scrollbar. Lightened and narrowed; it still darkens on hover so it stays
|
||||
findable.
|
||||
-------------------------------------------------------------------------- */
|
||||
* {
|
||||
scrollbar-width: thin; /* Firefox */
|
||||
scrollbar-color: rgba(0, 0, 0, 0.16) transparent;
|
||||
@@ -40,7 +93,7 @@ body {
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(0, 0, 0, 0.16);
|
||||
border-radius: 5px;
|
||||
border-radius: 999px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
@@ -53,21 +106,104 @@ body {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* KPI / StatCard sleek interactions & polish */
|
||||
/* --------------------------------------------------------------------------
|
||||
Keyframes.
|
||||
|
||||
A skeleton SHIMMERS, it does not pulse: a pulsing block reads as a
|
||||
rendering glitch, while a travelling highlight reads as progress. Astryx's
|
||||
own <Skeleton> keeps its built-in treatment; this is for the hand-rolled
|
||||
placeholders.
|
||||
-------------------------------------------------------------------------- */
|
||||
@keyframes dt-shimmer {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.dt-shimmer {
|
||||
background-image: linear-gradient(90deg, transparent 0%, rgba(255, 255, 255, 0.65) 50%, transparent 100%);
|
||||
background-size: 200% 100%;
|
||||
animation: dt-shimmer 1.6s var(--dt-ease-in-out) infinite;
|
||||
}
|
||||
|
||||
@keyframes dt-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dt-slide-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.dt-fade-in {
|
||||
animation: dt-fade-in var(--dt-duration-base) var(--dt-ease-out);
|
||||
}
|
||||
|
||||
.dt-slide-up {
|
||||
animation: dt-slide-up var(--dt-duration-slow) var(--dt-ease-out);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
KPI / StatCard (Compact Enterprise Metric Strip).
|
||||
|
||||
Static tiles stay quiet and static. Clickable/interactive tiles (e.g. on
|
||||
Tenants page) get a subtle 150ms hover lift and border emphasis.
|
||||
-------------------------------------------------------------------------- */
|
||||
.dt-stat-card {
|
||||
transition: transform 0.18s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.18s cubic-bezier(0.4, 0, 0.2, 1), border-color 0.18s ease !important;
|
||||
transition: box-shadow 150ms ease, border-color 150ms ease, transform 150ms ease !important;
|
||||
}
|
||||
|
||||
.dt-stat-card:hover {
|
||||
/* Interactive hover state ONLY when clickable or wrapped in button role */
|
||||
.dt-stat-card.dt-stat-card-clickable:hover,
|
||||
.dt-stat-card[data-clickable='true']:hover,
|
||||
[role='button'] .dt-stat-card:hover,
|
||||
[role='button'] > .dt-stat-card:hover,
|
||||
button .dt-stat-card:hover {
|
||||
border-color: var(--color-border-emphasized, #cbd5e1) !important;
|
||||
box-shadow: 0 2px 6px rgba(15, 23, 42, 0.08) !important;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.08), 0 1px 3px rgba(15, 23, 42, 0.04) !important;
|
||||
}
|
||||
|
||||
.dt-stat-card .dt-stat-icon {
|
||||
transition: transform 0.18s ease;
|
||||
.dt-stat-card.dt-stat-card-clickable:active,
|
||||
.dt-stat-card[data-clickable='true']:active,
|
||||
[role='button'] .dt-stat-card:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.dt-stat-card:hover .dt-stat-icon {
|
||||
transform: scale(1.06);
|
||||
/* Standardized 14px icon size inside the 26px chip across all call sites */
|
||||
.dt-stat-card .dt-stat-icon svg {
|
||||
width: 14px !important;
|
||||
height: 14px !important;
|
||||
font-size: 14px !important;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Reduced motion.
|
||||
|
||||
Handled ONCE, globally, rather than per component — a per-component opt-out
|
||||
is a rule that only covers the components somebody remembered.
|
||||
-------------------------------------------------------------------------- */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Loader from 'components/Loader';
|
||||
import { MdCheck, MdOutlineVisibility, MdOutlineVisibilityOff } from 'react-icons/md';
|
||||
import logo from 'assets/images/doormile-logo.png';
|
||||
|
||||
import { useDispatch } from 'react-redux';
|
||||
@@ -8,71 +8,400 @@ import { OpenToast } from 'components/third-party/OpenToast';
|
||||
import { setLoginUser } from 'store/reducers/loginUserSlice';
|
||||
import { markSessionStart } from 'utils/session';
|
||||
import { loginAdmin } from 'pages/api/doormileApi';
|
||||
import { DT } from 'themes/dt/tokens';
|
||||
|
||||
// Astryx design system — see themes/astryx.js for the Doormile brand theme
|
||||
// and CLAUDE.md's <!-- ASTRYX:START --> block for the CLI workflow.
|
||||
// NOTE: custom CSS (xstyle/stylex.create()) isn't wired up yet — see the
|
||||
// comment in config-overrides.js. Everything below uses Astryx component
|
||||
// props only; the brand gradient panel and the two logo images are plain
|
||||
// native elements with inline `style` for that reason.
|
||||
// Astryx design system — see themes/astryx.js for the Doormile theme.
|
||||
//
|
||||
// Two things about how this page is built:
|
||||
//
|
||||
// 1. The brand panel and the password toggle are plain elements with a scoped
|
||||
// stylesheet, not Astryx layout components. Astryx has no breakpoint props,
|
||||
// no gradient/decoration surface and no end-slot on TextInput, so those
|
||||
// three needs cannot be expressed through component props at all. The
|
||||
// stylesheet below is the same escape hatch `themes/dt/primitives.js`
|
||||
// (TableScroll) and `PageLayout.js` (PageShell) already use.
|
||||
//
|
||||
// 2. EVERY selector in that stylesheet is prefixed `.dm-login`. The brand red
|
||||
// lives on this page only — the console's own palette (black accent, slate
|
||||
// ink) is deliberately untouched, so nothing here leaks into the operator
|
||||
// pages. Do not lift these colours into themes/astryx.js.
|
||||
import { AppShell } from '@astryxdesign/core/AppShell';
|
||||
import { Theme } from '@astryxdesign/core/theme';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { Center } from '@astryxdesign/core/Center';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { Heading } from '@astryxdesign/core/Heading';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { Link } from '@astryxdesign/core/Link';
|
||||
import { Divider } from '@astryxdesign/core/Divider';
|
||||
import { doormileTheme } from 'themes/astryx';
|
||||
|
||||
const brandPanelStyle = {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
width: '46%',
|
||||
height: '100vh',
|
||||
color: '#fff',
|
||||
padding: 48,
|
||||
background: `linear-gradient(150deg, ${DT.brand} 0%, #3A3A3A 100%)`
|
||||
};
|
||||
|
||||
const logoLockupStyle = { position: 'absolute', top: 48, left: 48, maxHeight: 60 };
|
||||
|
||||
const bulletDotStyle = {
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.18)',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
flexShrink: 0
|
||||
};
|
||||
|
||||
// doormile-logo.png is a white asset; recolour to brand red for this
|
||||
// white-background card (the brand-panel logo stays white as-is).
|
||||
const formLogoStyle = {
|
||||
maxHeight: 48,
|
||||
filter: 'brightness(0) saturate(100%) invert(15%) sepia(93%) saturate(5437%) hue-rotate(346deg) brightness(81%) contrast(92%)'
|
||||
};
|
||||
// Doormile brand red. Stated once; every rule below reads the custom property
|
||||
// so the two tones can never drift apart.
|
||||
const BRAND = '#C8152D';
|
||||
const BRAND_DEEP = '#A90F24';
|
||||
|
||||
const BULLETS = ['Real-time fleet visibility', 'AI-optimised dispatch routes', 'Tenant, pricing & invoice control'];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scoped stylesheet.
|
||||
//
|
||||
// Astryx exposes stable class hooks (`astryx-card`, `astryx-text-input`,
|
||||
// `astryx-button`, `astryx-field-label`, `astryx-link`) alongside its generated
|
||||
// StyleX classes, and a two-class descendant selector out-specifies them —
|
||||
// verified in the browser rather than assumed. That is what lets this restyle
|
||||
// real Astryx components instead of re-implementing them.
|
||||
// ---------------------------------------------------------------------------
|
||||
const LOGIN_CSS = `
|
||||
.dm-login {
|
||||
--dm-brand: ${BRAND};
|
||||
--dm-brand-deep: ${BRAND_DEEP};
|
||||
--dm-ring: rgba(200, 21, 45, 0.10);
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ---- Left brand panel --------------------------------------------------- */
|
||||
.dm-login__brand {
|
||||
flex: 0 0 44%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 56px;
|
||||
color: #fff;
|
||||
/* Depth without decoration: one light lift from the top-left, one shadow
|
||||
into the bottom-right, over a restrained two-stop brand ramp. The panel
|
||||
should read as lit, not as gradient-y. */
|
||||
background:
|
||||
radial-gradient(115% 85% at 10% 6%, rgba(255, 255, 255, 0.12) 0%, transparent 55%),
|
||||
radial-gradient(95% 85% at 95% 100%, rgba(0, 0, 0, 0.22) 0%, transparent 62%),
|
||||
linear-gradient(155deg, var(--dm-brand) 0%, #B31228 55%, var(--dm-brand-deep) 100%);
|
||||
}
|
||||
|
||||
/* A single oversized arc, barely visible — the "logistics curve". One shape,
|
||||
two hairlines, no pattern library. */
|
||||
.dm-login__brand::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: -240px;
|
||||
bottom: -280px;
|
||||
width: 640px;
|
||||
height: 640px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
box-shadow: 0 0 0 72px rgba(255, 255, 255, 0.035);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Faint route lines, masked so they fade out rather than tile edge to edge. */
|
||||
.dm-login__brand::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background-image: repeating-linear-gradient(118deg, rgba(255, 255, 255, 0.055) 0 1px, transparent 1px 78px);
|
||||
-webkit-mask-image: radial-gradient(130% 100% at 85% 12%, #000 0%, transparent 68%);
|
||||
mask-image: radial-gradient(130% 100% at 85% 12%, #000 0%, transparent 68%);
|
||||
}
|
||||
|
||||
.dm-login__logo {
|
||||
position: absolute;
|
||||
top: 56px;
|
||||
left: 56px;
|
||||
height: 46px;
|
||||
width: auto;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.dm-login__copy {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: 460px;
|
||||
}
|
||||
|
||||
.dm-login__headline {
|
||||
margin: 0;
|
||||
font-family: var(--font-family-heading);
|
||||
font-size: 34px;
|
||||
line-height: 1.18;
|
||||
letter-spacing: -0.02em;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.dm-login__lede {
|
||||
margin: 18px 0 0;
|
||||
font-size: 16px;
|
||||
line-height: 1.65;
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
}
|
||||
|
||||
.dm-login__features {
|
||||
margin: 34px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.dm-login__feature {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 15px;
|
||||
line-height: 1.4;
|
||||
color: rgba(255, 255, 255, 0.94);
|
||||
}
|
||||
|
||||
.dm-login__tick {
|
||||
flex: 0 0 auto;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
border: 1px solid rgba(255, 255, 255, 0.22);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ---- Right form panel --------------------------------------------------- */
|
||||
.dm-login__form {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f8f9fb;
|
||||
padding: 48px 24px;
|
||||
}
|
||||
|
||||
.dm-login__stack {
|
||||
width: 100%;
|
||||
max-width: 436px;
|
||||
}
|
||||
|
||||
.dm-login .astryx-card {
|
||||
background: #ffffff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 16px;
|
||||
/* Elevated, not floating. */
|
||||
box-shadow: 0 12px 35px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.dm-login__title {
|
||||
margin: 0;
|
||||
font-family: var(--font-family-heading);
|
||||
font-size: 26px;
|
||||
line-height: 1.25;
|
||||
letter-spacing: -0.01em;
|
||||
font-weight: 700;
|
||||
color: #101828;
|
||||
}
|
||||
|
||||
.dm-login__subtitle {
|
||||
margin: 6px 0 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: #667085;
|
||||
}
|
||||
|
||||
/* ---- Fields ------------------------------------------------------------- */
|
||||
.dm-login .astryx-field-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #344054;
|
||||
}
|
||||
|
||||
.dm-login .astryx-text-input {
|
||||
height: 48px;
|
||||
border-radius: 10px;
|
||||
border-color: #d1d5db;
|
||||
background: #ffffff;
|
||||
padding-inline: 14px;
|
||||
transition: border-color 160ms ease, box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.dm-login .astryx-text-input input {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
/* One focus treatment for the whole form. :focus-within so the ring follows
|
||||
the field, not the bare <input> that sits inside Astryx's bordered wrapper.
|
||||
|
||||
Plain two-class specificity is enough: the resting rule above already
|
||||
out-specifies Astryx's own StyleX rules on this element, and this selector
|
||||
is strictly more specific than that one. If a future Astryx version starts
|
||||
winning here, the escape hatch is StyleX's own repeated-:not(#id) idiom —
|
||||
but write it with IDs that simply never exist rather than copying StyleX's
|
||||
escaped-hash form, because a backslash inside THIS template literal is
|
||||
consumed by JS before the CSS parser sees it and the rule is then dropped
|
||||
silently. */
|
||||
.dm-login .astryx-text-input:focus-within {
|
||||
border-color: var(--dm-brand);
|
||||
box-shadow: 0 0 0 3px var(--dm-ring);
|
||||
}
|
||||
|
||||
/* Chrome paints autofilled inputs pale blue, which is the "browser-looking"
|
||||
field the design brief calls out. An inset shadow the size of the box is the
|
||||
only way to repaint it. */
|
||||
.dm-login .astryx-text-input input:-webkit-autofill,
|
||||
.dm-login .astryx-text-input input:-webkit-autofill:hover,
|
||||
.dm-login .astryx-text-input input:-webkit-autofill:focus {
|
||||
-webkit-box-shadow: 0 0 0 40px #ffffff inset;
|
||||
box-shadow: 0 0 0 40px #ffffff inset;
|
||||
-webkit-text-fill-color: #101828;
|
||||
}
|
||||
|
||||
/* ---- Password visibility toggle ----------------------------------------- */
|
||||
.dm-login__pw {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Keep typed characters clear of the toggle. */
|
||||
.dm-login__pw .astryx-text-input {
|
||||
padding-right: 46px;
|
||||
}
|
||||
|
||||
.dm-login__pw-toggle {
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
bottom: 0;
|
||||
height: 48px;
|
||||
width: 42px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: #667085;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: color 160ms ease, background-color 160ms ease;
|
||||
}
|
||||
|
||||
.dm-login__pw-toggle:hover {
|
||||
color: #344054;
|
||||
background: #f2f4f7;
|
||||
}
|
||||
|
||||
.dm-login__pw-toggle:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px var(--dm-ring);
|
||||
color: var(--dm-brand);
|
||||
}
|
||||
|
||||
/* ---- Primary action ----------------------------------------------------- */
|
||||
.dm-login .astryx-button {
|
||||
height: 48px;
|
||||
border-radius: 10px;
|
||||
background: var(--dm-brand);
|
||||
border-color: var(--dm-brand);
|
||||
color: #ffffff;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
transition: background-color 160ms ease, transform 120ms ease, box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.dm-login .astryx-button:hover:not([disabled]):not([aria-disabled='true']) {
|
||||
background: var(--dm-brand-deep);
|
||||
border-color: var(--dm-brand-deep);
|
||||
}
|
||||
|
||||
.dm-login .astryx-button:active:not([disabled]):not([aria-disabled='true']) {
|
||||
transform: translateY(1px);
|
||||
background: #970d20;
|
||||
}
|
||||
|
||||
.dm-login .astryx-button:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px var(--dm-ring);
|
||||
}
|
||||
|
||||
.dm-login .astryx-button[disabled],
|
||||
.dm-login .astryx-button[aria-disabled='true'] {
|
||||
opacity: 0.7;
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
/* ---- Footer ------------------------------------------------------------- */
|
||||
.dm-login__footer {
|
||||
margin-top: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 20px;
|
||||
font-size: 13px;
|
||||
color: #98a2b3;
|
||||
}
|
||||
|
||||
.dm-login__footer .astryx-link {
|
||||
font-size: 13px;
|
||||
color: #667085;
|
||||
}
|
||||
|
||||
.dm-login__footer .astryx-link:hover {
|
||||
color: var(--dm-brand);
|
||||
}
|
||||
|
||||
/* ---- Tablet ------------------------------------------------------------- */
|
||||
@media (max-width: 1100px) {
|
||||
.dm-login__brand { flex-basis: 42%; padding: 40px; }
|
||||
.dm-login__logo { top: 40px; left: 40px; height: 40px; }
|
||||
.dm-login__headline { font-size: 28px; }
|
||||
.dm-login__lede { font-size: 15px; margin-top: 14px; }
|
||||
.dm-login__features { margin-top: 26px; gap: 13px; }
|
||||
}
|
||||
|
||||
/* ---- Mobile ------------------------------------------------------------- */
|
||||
/* The brand panel COLLAPSES to a logo strip rather than being hidden and the
|
||||
logo re-rendered above the card. That keeps exactly one <img> of the logo in
|
||||
the DOM at every breakpoint — a duplicate that is merely display:none is
|
||||
still a duplicate to a screen reader and to anyone reading the markup. */
|
||||
@media (max-width: 860px) {
|
||||
.dm-login { flex-direction: column; min-height: 100dvh; }
|
||||
|
||||
.dm-login__brand {
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 22px 24px;
|
||||
}
|
||||
.dm-login__brand::before,
|
||||
.dm-login__brand::after { display: none; }
|
||||
|
||||
.dm-login__logo { position: static; height: 34px; }
|
||||
.dm-login__copy { display: none; }
|
||||
|
||||
.dm-login__form {
|
||||
flex: 1 1 auto;
|
||||
align-items: flex-start;
|
||||
padding: 32px 20px 40px;
|
||||
}
|
||||
.dm-login__title { font-size: 23px; }
|
||||
.dm-login__footer { margin-top: 22px; gap: 8px 16px; }
|
||||
}
|
||||
|
||||
@media (max-width: 400px) {
|
||||
.dm-login__form { padding-inline: 16px; }
|
||||
}
|
||||
`;
|
||||
|
||||
const Login = () => {
|
||||
const dispatch = useDispatch();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
const emailRef = useRef(null);
|
||||
const passwordRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
// 'authname' is the AUTH_PRESENCE_KEY (utils/session.js) — App.js and the
|
||||
@@ -83,10 +412,24 @@ const Login = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Astryx's TextInput has no `autoComplete` prop and BaseProps declares only
|
||||
// `xstyle`, so an unknown prop would be dropped rather than forwarded. It
|
||||
// does forward `ref` to the real <input>, which is the supported way to
|
||||
// reach the element — password managers need these attributes to offer a
|
||||
// saved credential at all. Re-applied when the toggle swaps `type`.
|
||||
useEffect(() => {
|
||||
emailRef.current?.setAttribute('autocomplete', 'email');
|
||||
passwordRef.current?.setAttribute('autocomplete', 'current-password');
|
||||
}, [showPassword]);
|
||||
|
||||
const opentoast = (message, variant = 'error') => OpenToast(message, variant, 2000);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
// Guard against a second submit while the first is still in flight —
|
||||
// the button is disabled while loading, but Enter in a field is not.
|
||||
if (loading) return;
|
||||
|
||||
if (!email || !password) {
|
||||
opentoast('Enter your email and password');
|
||||
return;
|
||||
@@ -123,83 +466,105 @@ const Login = () => {
|
||||
return (
|
||||
<Theme theme={doormileTheme} mode="light">
|
||||
<AppShell contentPadding={0}>
|
||||
{loading && <Loader />}
|
||||
<HStack gap={0} height="100vh" wrap="nowrap">
|
||||
{/* ---- Left brand panel (plain element: see the note at the top of
|
||||
this file about custom styling not going through Astryx yet) ---- */}
|
||||
<div style={brandPanelStyle}>
|
||||
<img src={logo} alt="Doormile" style={logoLockupStyle} />
|
||||
<style>{LOGIN_CSS}</style>
|
||||
|
||||
<VStack gap={2} maxWidth={430}>
|
||||
<Heading level={1} color="inherit">
|
||||
Operate your dispatch, end to end.
|
||||
</Heading>
|
||||
<Text type="large" color="inherit">
|
||||
<div className="dm-login">
|
||||
{/* ---- Brand panel. The ONLY Doormile logo on this page. ---- */}
|
||||
<aside className="dm-login__brand">
|
||||
<img className="dm-login__logo" src={logo} alt="Doormile" />
|
||||
|
||||
<div className="dm-login__copy">
|
||||
<h1 className="dm-login__headline">Operate your dispatch, end to end.</h1>
|
||||
<p className="dm-login__lede">
|
||||
Orders, AI route optimisation, live rider tracking and billing — all in the Doormile Express operator console.
|
||||
</Text>
|
||||
|
||||
<VStack gap={1.5} padding={0}>
|
||||
</p>
|
||||
<ul className="dm-login__features">
|
||||
{BULLETS.map((t) => (
|
||||
<HStack key={t} gap={1.25} vAlign="center">
|
||||
<span style={bulletDotStyle}>✓</span>
|
||||
<Text color="inherit">{t}</Text>
|
||||
</HStack>
|
||||
<li key={t} className="dm-login__feature">
|
||||
<span className="dm-login__tick" aria-hidden="true">
|
||||
<MdCheck size={14} />
|
||||
</span>
|
||||
{t}
|
||||
</li>
|
||||
))}
|
||||
</VStack>
|
||||
</VStack>
|
||||
</div>
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* ---- Right form panel ---- */}
|
||||
<Center axis="both" width="54%" height="100vh">
|
||||
<VStack width="100%" maxWidth={440} gap={4} padding={3}>
|
||||
<Card padding={8} elevation="med">
|
||||
<VStack gap={6} padding={0}>
|
||||
<VStack gap={1.5} hAlign="center" padding={0}>
|
||||
<img src={logo} alt="Doormile" style={formLogoStyle} />
|
||||
<Heading level={2}>Welcome back</Heading>
|
||||
<Text type="supporting">Sign in to the Doormile Express console</Text>
|
||||
</VStack>
|
||||
{/* ---- Form panel ---- */}
|
||||
<main className="dm-login__form">
|
||||
<div className="dm-login__stack">
|
||||
<Card padding={8} elevation="none">
|
||||
<VStack gap={5} padding={0}>
|
||||
<div>
|
||||
<h2 className="dm-login__title">Welcome back</h2>
|
||||
<p className="dm-login__subtitle">Sign in to the Doormile Express console</p>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<form noValidate onSubmit={handleSubmit}>
|
||||
<VStack gap={4} padding={0}>
|
||||
<TextInput
|
||||
ref={emailRef}
|
||||
hasAutoFocus
|
||||
label="E-mail Address"
|
||||
label="Email address"
|
||||
type="email"
|
||||
size="lg"
|
||||
placeholder="you@company.com"
|
||||
value={email}
|
||||
isDisabled={loading}
|
||||
onChange={(value) => setEmail(value.toLocaleLowerCase())}
|
||||
/>
|
||||
<TextInput
|
||||
label="Password"
|
||||
type="password"
|
||||
|
||||
<div className="dm-login__pw">
|
||||
<TextInput
|
||||
ref={passwordRef}
|
||||
label="Password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
size="lg"
|
||||
placeholder="Enter your password"
|
||||
value={password}
|
||||
isDisabled={loading}
|
||||
onChange={(value) => setPassword(value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="dm-login__pw-toggle"
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||
aria-pressed={showPassword}
|
||||
>
|
||||
{showPassword ? <MdOutlineVisibilityOff size={19} /> : <MdOutlineVisibility size={19} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
label={loading ? 'Signing in…' : 'Sign in'}
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
value={password}
|
||||
onChange={(value) => setPassword(value)}
|
||||
width="100%"
|
||||
isLoading={loading}
|
||||
isDisabled={loading}
|
||||
/>
|
||||
<Button label="Sign in" type="submit" variant="primary" size="lg" width="100%" />
|
||||
</VStack>
|
||||
</form>
|
||||
</VStack>
|
||||
</Card>
|
||||
|
||||
{/* footer */}
|
||||
<HStack justify="center" wrap="wrap" gap={2}>
|
||||
<Link href="https://doormile.com" target="_blank" isExternalLink type="supporting">
|
||||
© All rights reserved
|
||||
</Link>
|
||||
<div className="dm-login__footer">
|
||||
<span>© {new Date().getFullYear()} Doormile</span>
|
||||
<Link href="https://doormile.com/terms-of-service" target="_blank" isExternalLink type="supporting">
|
||||
Terms and Conditions
|
||||
Terms & Conditions
|
||||
</Link>
|
||||
<Link href="https://doormile.com/privacy-policy" target="_blank" isExternalLink type="supporting">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Center>
|
||||
</HStack>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</AppShell>
|
||||
</Theme>
|
||||
);
|
||||
|
||||
@@ -38,7 +38,7 @@ import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } f
|
||||
import { MobileCard, MobileCardList, MobileField, MobileFieldGrid } from 'components/nearle_components/MobileCard';
|
||||
import PageHeader from 'components/nearle_components/PageHeader';
|
||||
import { PageShell, KpiGrid, DataCard } from 'components/nearle_components/PageLayout';
|
||||
import HoverSocialCard from 'components/cards/statistics/HoverSocialCard';
|
||||
import StatCard from 'components/nearle_components/StatCard';
|
||||
import { parseDoormileTimestamp } from 'utils/doormileTimestamp';
|
||||
import { fetchPaymentType, fetchRidersList, finalCreatedeliveries, notifyRider } from '../../api/api';
|
||||
import { OpenToast } from 'components/third-party/OpenToast';
|
||||
@@ -561,17 +561,16 @@ const OrdersPreview = () => {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Was four <HoverSocialCard>s — solid-colour tiles that were the only
|
||||
KPI design in the app not coming from StatCard, so this row looked
|
||||
nothing like the KPI row on the other fifteen pages. Same data,
|
||||
same semantic colours, shared tile. */}
|
||||
{aiMode == 1 && (
|
||||
<KpiGrid>
|
||||
<HoverSocialCard
|
||||
secondary={metaData?.total_orders}
|
||||
primary="Orders"
|
||||
percentage={<MdDirectionsBike />}
|
||||
color={STATUS.delivered}
|
||||
/>
|
||||
<HoverSocialCard secondary={metaData?.total_riders} primary="Riders" percentage={<MdDirectionsBike />} color={STATUS.pending} />
|
||||
<HoverSocialCard secondary={zoneData?.length} primary="Zones" percentage={<FaMapLocationDot />} color="#0ea5e9" />
|
||||
<HoverSocialCard secondary={zoneData?.length} primary="Kilometer" percentage={<FaMapLocationDot />} color={STATUS.cancelled} />
|
||||
<StatCard title="Orders" value={metaData?.total_orders} icon={<MdDirectionsBike size={20} />} color={STATUS.delivered} />
|
||||
<StatCard title="Riders" value={metaData?.total_riders} icon={<MdDirectionsBike size={20} />} color={STATUS.pending} />
|
||||
<StatCard title="Zones" value={zoneData?.length} icon={<FaMapLocationDot size={20} />} color={STATUS.info} />
|
||||
<StatCard title="Kilometer" value={zoneData?.length} icon={<FaMapLocationDot size={20} />} color={STATUS.cancelled} />
|
||||
</KpiGrid>
|
||||
)}
|
||||
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
// brand colour so migrated surfaces match the existing DT token palette
|
||||
// (see themes/dt/tokens.js). Runtime-injected (not `astryx theme build`)
|
||||
// since this repo doesn't do SSR — see `astryx docs theme`.
|
||||
//
|
||||
// The KROW work touched exactly one thing in this file: the radius block.
|
||||
// The colour tokens below are Doormile's own and are unchanged.
|
||||
import { defineTheme } from '@astryxdesign/core/theme';
|
||||
import { neutralTheme } from '@astryxdesign/theme-neutral';
|
||||
import { DT } from 'themes/dt/tokens';
|
||||
@@ -42,6 +45,9 @@ export const doormileTheme = defineTheme({
|
||||
// Astryx explicitly does not load fonts, it only names them. Change one
|
||||
// without the other and you get a silent fallback.
|
||||
//
|
||||
// Sora headings + Inter body is also exactly the pairing KROW specifies, so
|
||||
// adopting KROW's type treatment needed no change here.
|
||||
//
|
||||
// base 14, not 16: this is a dense operator console and 14px is what the
|
||||
// existing tables already assume. ratio 1.2 keeps the heading steps tight
|
||||
// so an h1 doesn't tower over a data-heavy page.
|
||||
@@ -59,13 +65,22 @@ export const doormileTheme = defineTheme({
|
||||
// Setting it per-component would leave the ones nobody remembered to
|
||||
// list still rounded.
|
||||
//
|
||||
// These were a flat 5px across all five roles. They are now KROW's scale,
|
||||
// mapped onto those roles: `page` is a page-level surface (20px),
|
||||
// `container` is a card (16px), and `element`/`inner`/`chat` are the
|
||||
// controls and inner chrome that sit inside one (12px). Keeping element
|
||||
// and container DIFFERENT is the whole point — a 16px card holding 16px
|
||||
// inputs has no visual nesting.
|
||||
//
|
||||
// `--radius-full` is deliberately untouched: it drives avatars, status
|
||||
// dots and pill chips, which are meant to be round.
|
||||
'--radius-page': '5px',
|
||||
'--radius-container': '5px',
|
||||
'--radius-element': '5px',
|
||||
'--radius-inner': '5px',
|
||||
'--radius-chat': '5px',
|
||||
'--radius-page': `${DT.radiusPage}px`,
|
||||
'--radius-container': `${DT.radiusCard}px`,
|
||||
'--radius-element': `${DT.radiusField}px`,
|
||||
'--radius-inner': `${DT.radiusInner}px`,
|
||||
'--radius-chat': `${DT.radiusInner}px`,
|
||||
|
||||
// ---- Accent (UNCHANGED) --------------------------------------------
|
||||
'--color-accent': [BRAND_ON_LIGHT, BRAND_ON_DARK],
|
||||
'--color-icon-accent': [BRAND_ON_LIGHT, BRAND_ON_DARK],
|
||||
'--color-text-accent': [BRAND_ON_LIGHT, BRAND_ON_DARK],
|
||||
@@ -78,6 +93,6 @@ export const doormileTheme = defineTheme({
|
||||
},
|
||||
components: {
|
||||
card: { base: { borderRadius: `${DT.radiusCard}px` } },
|
||||
'text-input': { base: { borderRadius: `${DT.radiusInner}px` } }
|
||||
'text-input': { base: { borderRadius: `${DT.radiusField}px` } }
|
||||
}
|
||||
});
|
||||
|
||||
@@ -26,14 +26,30 @@ export const softPopupStyle = {
|
||||
overflow: 'hidden'
|
||||
};
|
||||
|
||||
// Scroll container for a table. Astryx's Table renders a bare <table> with no
|
||||
// sticky-header or max-height affordance (MUI's TableContainer used to provide that),
|
||||
// so pages wrap it in this. The brand-red scrollbar can't be expressed inline
|
||||
// — ::-webkit-scrollbar-* are pseudo-elements — hence the one-time <style>.
|
||||
// ---------------------------------------------------------------------------
|
||||
// TableScroll — scroll container for a table.
|
||||
//
|
||||
// Astryx's Table renders a bare <table> with no sticky-header or max-height
|
||||
// affordance (MUI's TableContainer used to provide that), so pages wrap it in
|
||||
// this. The brand scrollbar can't be expressed inline — ::-webkit-scrollbar-*
|
||||
// are pseudo-elements — hence the one-time <style>.
|
||||
//
|
||||
// It is also where KROW's table-header TYPE treatment lands, and that is what
|
||||
// makes the one-time <style> worth its keep: ~17 pages render tables and none
|
||||
// of them should have to know what a column header looks like. The header is
|
||||
// now the console's heading face at 11px / 600 / +0.06em / uppercase — small,
|
||||
// letterspaced and quiet, so it reads as a LABEL for the data rather than
|
||||
// competing with it.
|
||||
//
|
||||
// Deliberately type only: no colour is set here, so header and body cells keep
|
||||
// whatever Astryx's Table resolves them to, and row hover stays owned by
|
||||
// Astryx's own `hasHover` prop rather than being fought over by two rules.
|
||||
//
|
||||
// `flush` drops the grey header fill and leaves a single hairline under the
|
||||
// header row — for tables that sit directly on the page with no surrounding
|
||||
// card, where a fill plus a border plus a card outline is three lines doing
|
||||
// one job.
|
||||
// ---------------------------------------------------------------------------
|
||||
export const TableScroll = ({ maxHeight = 'calc(100vh - 260px)', minWidth, flush = false, children }) => (
|
||||
<>
|
||||
<style>{`
|
||||
@@ -61,6 +77,19 @@ export const TableScroll = ({ maxHeight = 'calc(100vh - 260px)', minWidth, flush
|
||||
background-color: ${DT.surface};
|
||||
border-bottom: 1px solid ${DT.borderSubtle};
|
||||
}
|
||||
/* KROW's column-header treatment — type only, no colour. */
|
||||
.dt-table-scroll thead th {
|
||||
font-family: var(--font-family-heading);
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* Numeric columns opt in per page with class="dt-num"; tabular figures
|
||||
stop a live count from reflowing the column as it ticks. */
|
||||
.dt-table-scroll .dt-num { font-variant-numeric: tabular-nums; }
|
||||
`}</style>
|
||||
<div className={`dt-table-scroll${flush ? ' is-flush' : ''}`} style={{ maxHeight }}>
|
||||
<div style={{ minWidth }}>{children}</div>
|
||||
|
||||
@@ -5,11 +5,30 @@
|
||||
// redefining its own DT object, so radius/shadow/color decisions stay in one
|
||||
// place. See CLAUDE.md §6 for the full page-anatomy convention these tokens
|
||||
// support.
|
||||
//
|
||||
// ---------------------------------------------------------------------------
|
||||
// SCOPE OF THE KROW WORK
|
||||
// ---------------------------------------------------------------------------
|
||||
// The console adopted KROW's *structural* design language — its radius scale,
|
||||
// its type treatment, its page anatomy. It deliberately did NOT adopt KROW's
|
||||
// palette: the colours below are Doormile's own and are unchanged. The brand
|
||||
// is still black, the ink scale is still the slate family, and the lifecycle
|
||||
// hues are untouched.
|
||||
//
|
||||
// So when reading KROW's spec against this file: take the geometry and the
|
||||
// typography, ignore the blue.
|
||||
// ============================================================================
|
||||
export const DT = {
|
||||
// Corner radius is uniform across the console. Boxes were 10-14px, which
|
||||
// reads soft and consumer-ish on dense operator screens; 5px is tight
|
||||
// enough for table density while still reading as a rounded surface.
|
||||
// ---- Corner radius -------------------------------------------------
|
||||
// Was a flat 5px on every surface, which read as "tight console" but also
|
||||
// meant a card, the inputs inside it and the chips inside those all had the
|
||||
// same corner — so nothing nested visually.
|
||||
//
|
||||
// Now KROW's scale, which derives from a 12px root: 8 / 12 / 16 / 20. Two
|
||||
// of those steps are both correct and mean different things — 20px is a
|
||||
// page-level surface, 16px is a dense console card. This IS a dense operator
|
||||
// console, so cards take 16 and inputs and inner chrome take 12, which is
|
||||
// what gives a card holding fields a visible hierarchy.
|
||||
//
|
||||
// These are the shared values — change them here, not per page. The Astryx
|
||||
// components read the matching `--radius-*` tokens set in themes/astryx.js,
|
||||
@@ -19,13 +38,28 @@ export const DT = {
|
||||
// shaped like pills or circles (status chips, avatars, dots). Squaring those
|
||||
// doesn't make them consistent, it makes them look broken.
|
||||
radiusPill: 999,
|
||||
radiusCard: 5,
|
||||
radiusInner: 5,
|
||||
radiusField: 5,
|
||||
// Restrained, low-contrast elevation — corporate (Linear/Stripe), not flashy.
|
||||
shadowSoft: '0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px rgba(15, 23, 42, 0.05)',
|
||||
radiusPage: 20,
|
||||
radiusCard: 16,
|
||||
radiusInner: 12,
|
||||
radiusField: 12,
|
||||
radiusSm: 8,
|
||||
|
||||
// ---- Elevation -----------------------------------------------------
|
||||
// Restrained, low-contrast — corporate (Linear/Stripe), not flashy.
|
||||
// UNCHANGED by the KROW work; these are the console's own shadows.
|
||||
//
|
||||
// The three original names are the canonical ones. `shadowXs`/`Sm`/`Lg` are
|
||||
// aliases added so newer components can be written against a conventional
|
||||
// xs/sm/lg naming without introducing a second, differently-valued scale.
|
||||
// They resolve to exactly the same three values.
|
||||
shadowMd: '0 1px 3px rgba(15, 23, 42, 0.06)',
|
||||
shadowSoft: '0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px rgba(15, 23, 42, 0.05)',
|
||||
shadowPop: '0 12px 32px rgba(15, 23, 42, 0.12)',
|
||||
shadowXs: '0 1px 3px rgba(15, 23, 42, 0.06)', // alias of shadowMd
|
||||
shadowSm: '0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px rgba(15, 23, 42, 0.05)', // alias of shadowSoft
|
||||
shadowLg: '0 12px 32px rgba(15, 23, 42, 0.12)', // alias of shadowPop
|
||||
|
||||
// ---- Palette (UNCHANGED) -------------------------------------------
|
||||
textPrimary: '#0f172a',
|
||||
textSecondary: '#64748b',
|
||||
textMuted: '#94a3b8',
|
||||
|
||||
Reference in New Issue
Block a user