import { useState, useEffect, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import { MdCheck, MdOutlineVisibility, MdOutlineVisibilityOff } from 'react-icons/md'; import logo from 'assets/images/doormile-logo.png'; import { useDispatch } from 'react-redux'; import { OpenToast } from 'components/third-party/OpenToast'; import { setLoginUser } from 'store/reducers/loginUserSlice'; import { markSessionStart } from 'utils/session'; import { loginAdmin } from 'pages/api/doormileApi'; // 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 { VStack } from '@astryxdesign/core/VStack'; import { Card } from '@astryxdesign/core/Card'; 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'; // 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 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 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 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 // inactivity/cross-tab logout machinery all key off it, unrelated to which // backend actually authenticated the session. if (localStorage.getItem('authname')) { navigate('/doormile/dispatch'); } }, []); // 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 , 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; } setLoading(true); try { const res = await loginAdmin(email, password); if (res.success) { const user = res.user || {}; // Compatibility keys: existing pages/api.js read userid/roleid/tenantid // straight from localStorage rather than through a context. 'authname' // stays the AUTH_PRESENCE_KEY so the rest of the session contract // (useInactivityLogout, cross-tab logout, App.js gate) needs no changes. localStorage.setItem('authname', user.email || user.name || email); localStorage.setItem('firstname', user.name || ''); localStorage.setItem('userid', user.id ?? ''); localStorage.setItem('roleid', user.role ?? ''); localStorage.setItem('tenantid', user.tenantid ?? ''); dispatch(setLoginUser(user)); markSessionStart(); opentoast('Login successful', 'success'); navigate('/doormile/dispatch'); } else { opentoast(res.message || 'Login failed'); } } catch (err) { opentoast(err?.message || 'Login failed'); } finally { setLoading(false); } }; return ( {/* ---- Brand panel. The ONLY Doormile logo on this page. ---- */} {/* ---- Form panel ---- */} Welcome back Sign in to the Doormile Express console setEmail(value.toLocaleLowerCase())} /> setPassword(value)} /> setShowPassword((v) => !v)} aria-label={showPassword ? 'Hide password' : 'Show password'} aria-pressed={showPassword} > {showPassword ? : } © {new Date().getFullYear()} Doormile Terms & Conditions Privacy Policy ); }; export default Login;
Sign in to the Doormile Express console