Files
Doormilexpress_console/src/pages/nearle/login.js

574 lines
18 KiB
JavaScript

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 <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 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 <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;
}
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 (
<Theme theme={doormileTheme} mode="light">
<AppShell contentPadding={0}>
<style>{LOGIN_CSS}</style>
<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.
</p>
<ul className="dm-login__features">
{BULLETS.map((t) => (
<li key={t} className="dm-login__feature">
<span className="dm-login__tick" aria-hidden="true">
<MdCheck size={14} />
</span>
{t}
</li>
))}
</ul>
</div>
</aside>
{/* ---- 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="Email address"
type="email"
size="lg"
placeholder="you@company.com"
value={email}
isDisabled={loading}
onChange={(value) => setEmail(value.toLocaleLowerCase())}
/>
<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"
width="100%"
isLoading={loading}
isDisabled={loading}
/>
</VStack>
</form>
</VStack>
</Card>
<div className="dm-login__footer">
<span>&copy; {new Date().getFullYear()} Doormile</span>
<Link href="https://doormile.com/terms-of-service" target="_blank" isExternalLink type="supporting">
Terms &amp; Conditions
</Link>
<Link href="https://doormile.com/privacy-policy" target="_blank" isExternalLink type="supporting">
Privacy Policy
</Link>
</div>
</div>
</main>
</div>
</AppShell>
</Theme>
);
};
export default Login;